diff --git a/azure-pipelines-staging-ph1-enhance.yml b/azure-pipelines-staging-ph1-enhance.yml new file mode 100644 index 0000000..9ee5b0e --- /dev/null +++ b/azure-pipelines-staging-ph1-enhance.yml @@ -0,0 +1,363 @@ +# Pipeline側でKeyVaultやDocker、AppService等に対する操作権限を持ったServiceConnectionを作成しておくこと +# また、環境変数 STATIC_DICTATION_DEPLOYMENT_TOKEN の値として静的WebAppsのデプロイトークンを設定しておくこと +trigger: + branches: + include: + - release-ph1-enhance + tags: + include: + - stage-* + +jobs: +- job: initialize + displayName: Initialize + pool: + vmImage: ubuntu-latest + steps: + - checkout: self + clean: true + fetchDepth: 1 + persistCredentials: true + - script: | + git fetch origin release-ph1-enhance:release-ph1-enhance + if git merge-base --is-ancestor $(Build.SourceVersion) release-ph1-enhance; then + echo "This commit is in the release-ph1-enhance branch." + else + echo "This commit is not in the release-ph1-enhance branch." + exit 1 + fi + displayName: 'タグが付けられたCommitがrelease-ph1-enhanceブランチに存在するか確認' +- job: backend_test + dependsOn: initialize + condition: succeeded('initialize') + displayName: UnitTest + pool: + vmImage: ubuntu-latest + steps: + - checkout: self + clean: true + fetchDepth: 1 + - task: Bash@3 + displayName: Bash Script (Test) + inputs: + targetType: inline + workingDirectory: dictation_server/.devcontainer + script: | + sudo curl -L "https://github.com/docker/compose/releases/download/v2.20.3/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose --version + docker-compose -f pipeline-docker-compose.yml build + docker-compose -f pipeline-docker-compose.yml up -d + docker-compose exec -T dictation_server sudo npm ci + docker-compose exec -T dictation_server sudo npm run migrate:up:test + docker-compose exec -T dictation_server sudo npm run test +- job: backend_build + dependsOn: backend_test + condition: succeeded('backend_test') + displayName: Build And Push Backend Image + pool: + name: odms-deploy-pipeline + steps: + - checkout: self + clean: true + fetchDepth: 1 + - task: Npm@1 + displayName: npm ci + inputs: + command: ci + workingDir: dictation_server + verbose: false + - task: Docker@0 + displayName: build + inputs: + azureSubscriptionEndpoint: 'omds-service-connection-stg' + azureContainerRegistry: '{"loginServer":"crodmsregistrymaintenance.azurecr.io", "id" : "/subscriptions/108fb131-cdca-4729-a2be-e5bd8c0b3ba7/resourceGroups/maintenance-rg/providers/Microsoft.ContainerRegistry/registries/crOdmsRegistryMaintenance"}' + dockerFile: DockerfileServerDictation.dockerfile + imageName: odmscloud/staging/dictation:$(Build.SourceVersion) + buildArguments: | + BUILD_VERSION=$(Build.SourceVersion) + - task: Docker@0 + displayName: push + inputs: + azureSubscriptionEndpoint: 'omds-service-connection-stg' + azureContainerRegistry: '{"loginServer":"crodmsregistrymaintenance.azurecr.io", "id" : "/subscriptions/108fb131-cdca-4729-a2be-e5bd8c0b3ba7/resourceGroups/maintenance-rg/providers/Microsoft.ContainerRegistry/registries/crOdmsRegistryMaintenance"}' + action: Push an image + imageName: odmscloud/staging/dictation:$(Build.SourceVersion) +- job: frontend_build_staging + dependsOn: backend_build + condition: succeeded('backend_build') + displayName: Build Frontend Files(staging) + variables: + storageAccountName: saomdspipeline + environment: staging + pool: + name: odms-deploy-pipeline + steps: + - checkout: self + clean: true + fetchDepth: 1 + - task: Npm@1 + displayName: npm ci + inputs: + command: ci + workingDir: dictation_client + verbose: false + - task: Bash@3 + displayName: Bash Script + inputs: + targetType: inline + script: cd dictation_client && npm run build:stg + - task: ArchiveFiles@2 + inputs: + rootFolderOrFile: dictation_client/build + includeRootFolder: false + archiveType: 'zip' + archiveFile: '$(Build.ArtifactStagingDirectory)/$(Build.SourceVersion).zip' + replaceExistingArchive: true + - task: AzureCLI@2 + inputs: + azureSubscription: 'omds-service-connection-stg' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + az storage blob upload \ + --auth-mode login \ + --account-name $(storageAccountName) \ + --container-name $(environment) \ + --name $(Build.SourceVersion).zip \ + --type block \ + --overwrite \ + --file $(Build.ArtifactStagingDirectory)/$(Build.SourceVersion).zip +- job: frontend_build_production + dependsOn: frontend_build_staging + condition: succeeded('frontend_build_staging') + displayName: Build Frontend Files(production) + variables: + storageAccountName: saomdspipeline + environment: production + pool: + name: odms-deploy-pipeline + steps: + - checkout: self + clean: true + fetchDepth: 1 + - task: Npm@1 + displayName: npm ci + inputs: + command: ci + workingDir: dictation_client + verbose: false + - task: Bash@3 + displayName: Bash Script + inputs: + targetType: inline + script: cd dictation_client && npm run build:prod + - task: ArchiveFiles@2 + inputs: + rootFolderOrFile: dictation_client/build + includeRootFolder: false + archiveType: 'zip' + archiveFile: '$(Build.ArtifactStagingDirectory)/$(Build.SourceVersion).zip' + replaceExistingArchive: true + - task: AzureCLI@2 + inputs: + azureSubscription: 'omds-service-connection-stg' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + az storage blob upload \ + --auth-mode login \ + --account-name $(storageAccountName) \ + --container-name $(environment) \ + --name $(Build.SourceVersion).zip \ + --type block \ + --overwrite \ + --file $(Build.ArtifactStagingDirectory)/$(Build.SourceVersion).zip +- job: function_test + dependsOn: frontend_build_production + condition: succeeded('frontend_build_production') + displayName: UnitTest + pool: + vmImage: ubuntu-latest + steps: + - checkout: self + clean: true + fetchDepth: 1 + - task: Bash@3 + displayName: Bash Script (Test) + inputs: + targetType: inline + workingDirectory: dictation_function/.devcontainer + script: | + sudo curl -L "https://github.com/docker/compose/releases/download/v2.20.3/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose --version + docker-compose -f pipeline-docker-compose.yml build + docker-compose -f pipeline-docker-compose.yml up -d + docker-compose exec -T dictation_function sudo npm ci + docker-compose exec -T dictation_function sudo npm run test +- job: function_build + dependsOn: function_test + condition: succeeded('function_test') + displayName: Build And Push Function Image + pool: + name: odms-deploy-pipeline + steps: + - checkout: self + clean: true + fetchDepth: 1 + - task: Npm@1 + displayName: npm ci + inputs: + command: ci + workingDir: dictation_function + verbose: false + - task: Docker@0 + displayName: build + inputs: + azureSubscriptionEndpoint: 'omds-service-connection-stg' + azureContainerRegistry: '{"loginServer":"crodmsregistrymaintenance.azurecr.io", "id" : "/subscriptions/108fb131-cdca-4729-a2be-e5bd8c0b3ba7/resourceGroups/maintenance-rg/providers/Microsoft.ContainerRegistry/registries/crOdmsRegistryMaintenance"}' + dockerFile: DockerfileFunctionDictation.dockerfile + imageName: odmscloud/staging/dictation_function:$(Build.SourceVersion) + buildArguments: | + BUILD_VERSION=$(Build.SourceVersion) + - task: Docker@0 + displayName: push + inputs: + azureSubscriptionEndpoint: 'omds-service-connection-stg' + azureContainerRegistry: '{"loginServer":"crodmsregistrymaintenance.azurecr.io", "id" : "/subscriptions/108fb131-cdca-4729-a2be-e5bd8c0b3ba7/resourceGroups/maintenance-rg/providers/Microsoft.ContainerRegistry/registries/crOdmsRegistryMaintenance"}' + action: Push an image + imageName: odmscloud/staging/dictation_function:$(Build.SourceVersion) +- job: backend_deploy + dependsOn: function_build + condition: succeeded('function_build') + displayName: Backend Deploy + pool: + vmImage: ubuntu-latest + steps: + - checkout: self + clean: true + fetchDepth: 1 + - task: AzureWebAppContainer@1 + inputs: + azureSubscription: 'omds-service-connection-stg' + appName: 'app-odms-dictation-stg' + deployToSlotOrASE: true + resourceGroupName: 'stg-application-rg' + slotName: 'staging' + containers: 'crodmsregistrymaintenance.azurecr.io/odmscloud/staging/dictation:$(Build.SourceVersion)' +- job: frontend_deploy + dependsOn: backend_deploy + condition: succeeded('backend_deploy') + displayName: Deploy Frontend Files + variables: + storageAccountName: saomdspipeline + environment: staging + pool: + vmImage: ubuntu-latest + steps: + - checkout: self + clean: true + fetchDepth: 1 + - task: AzureCLI@2 + inputs: + azureSubscription: 'omds-service-connection-stg' + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + az storage blob download \ + --auth-mode login \ + --account-name $(storageAccountName) \ + --container-name $(environment) \ + --name $(Build.SourceVersion).zip \ + --file $(Build.SourcesDirectory)/$(Build.SourceVersion).zip + - task: Bash@3 + displayName: Bash Script + inputs: + targetType: inline + script: unzip $(Build.SourcesDirectory)/$(Build.SourceVersion).zip -d $(Build.SourcesDirectory)/$(Build.SourceVersion) + - task: AzureStaticWebApp@0 + displayName: 'Static Web App: ' + inputs: + workingDirectory: '$(Build.SourcesDirectory)' + app_location: '/$(Build.SourceVersion)' + config_file_location: /dictation_client + skip_app_build: true + skip_api_build: true + is_static_export: false + verbose: false + azure_static_web_apps_api_token: $(STATIC_DICTATION_DEPLOYMENT_TOKEN) +- job: function_deploy + dependsOn: frontend_deploy + condition: succeeded('frontend_deploy') + displayName: Function Deploy + pool: + vmImage: ubuntu-latest + steps: + - checkout: self + clean: true + fetchDepth: 1 + - task: AzureFunctionAppContainer@1 + inputs: + azureSubscription: 'omds-service-connection-stg' + appName: 'func-odms-dictation-stg' + imageName: 'crodmsregistrymaintenance.azurecr.io/odmscloud/staging/dictation_function:$(Build.SourceVersion)' +- job: smoke_test + dependsOn: function_deploy + condition: succeeded('function_deploy') + displayName: 'smoke test' + pool: + name: odms-deploy-pipeline + steps: + - checkout: self + clean: true + fetchDepth: 1 + # スモークテスト用にjobを確保 +- job: swap_slot + dependsOn: smoke_test + condition: succeeded('smoke_test') + displayName: 'Swap Staging and Production' + pool: + name: odms-deploy-pipeline + steps: + - checkout: self + clean: true + fetchDepth: 1 + - task: AzureAppServiceManage@0 + displayName: 'Azure App Service Manage: app-odms-dictation-stg' + inputs: + azureSubscription: 'omds-service-connection-stg' + action: 'Swap Slots' + WebAppName: 'app-odms-dictation-stg' + ResourceGroupName: 'stg-application-rg' + SourceSlot: 'staging' + SwapWithProduction: true +- job: migration + dependsOn: swap_slot + condition: succeeded('swap_slot') + displayName: DB migration + pool: + name: odms-deploy-pipeline + steps: + - checkout: self + clean: true + fetchDepth: 1 + - task: AzureKeyVault@2 + displayName: 'Azure Key Vault: kv-odms-secret-stg' + inputs: + ConnectedServiceName: 'omds-service-connection-stg' + KeyVaultName: kv-odms-secret-stg + - task: CmdLine@2 + displayName: migration + inputs: + script: >2 + # DB接続情報書き換え + sed -i -e "s/DB_NAME/$(db-name-ph1-enhance)/g" ./dictation_server/db/dbconfig.yml + sed -i -e "s/DB_PASS/$(admin-db-pass)/g" ./dictation_server/db/dbconfig.yml + sed -i -e "s/DB_USERNAME/$(admin-db-user)/g" ./dictation_server/db/dbconfig.yml + sed -i -e "s/DB_PORT/$(db-port)/g" ./dictation_server/db/dbconfig.yml + sed -i -e "s/DB_HOST/$(db-host)/g" ./dictation_server/db/dbconfig.yml + sql-migrate --version + cat ./dictation_server/db/dbconfig.yml + # migration実行 + sql-migrate up -config=./dictation_server/db/dbconfig.yml -env=ci diff --git a/dictation_client/src/api/api.ts b/dictation_client/src/api/api.ts index 72982f3..55c4c3a 100644 --- a/dictation_client/src/api/api.ts +++ b/dictation_client/src/api/api.ts @@ -7058,6 +7058,44 @@ export const TasksApiAxiosParamCreator = function (configuration?: Configuration + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * 完了した文字起こしタスクを再開します(ステータスをPendingにします) + * @summary + * @param {number} audioFileId ODMS Cloud上の音声ファイルID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reopen: async (audioFileId: number, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'audioFileId' is not null or undefined + assertParamExists('reopen', 'audioFileId', audioFileId) + const localVarPath = `/tasks/{audioFileId}/reopen` + .replace(`{${"audioFileId"}}`, encodeURIComponent(String(audioFileId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -7224,6 +7262,19 @@ export const TasksApiFp = function(configuration?: Configuration) { const operationBasePath = operationServerMap['TasksApi.getTasks']?.[index]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); }, + /** + * 完了した文字起こしタスクを再開します(ステータスをPendingにします) + * @summary + * @param {number} audioFileId ODMS Cloud上の音声ファイルID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async reopen(audioFileId: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.reopen(audioFileId, options); + const index = configuration?.serverIndex ?? 0; + const operationBasePath = operationServerMap['TasksApi.reopen']?.[index]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); + }, /** * 指定した文字起こしタスクを一時中断します(ステータスをPendingにします) * @summary @@ -7332,6 +7383,16 @@ export const TasksApiFactory = function (configuration?: Configuration, basePath getTasks(limit?: number, offset?: number, status?: string, direction?: string, paramName?: string, options?: any): AxiosPromise { return localVarFp.getTasks(limit, offset, status, direction, paramName, options).then((request) => request(axios, basePath)); }, + /** + * 完了した文字起こしタスクを再開します(ステータスをPendingにします) + * @summary + * @param {number} audioFileId ODMS Cloud上の音声ファイルID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reopen(audioFileId: number, options?: any): AxiosPromise { + return localVarFp.reopen(audioFileId, options).then((request) => request(axios, basePath)); + }, /** * 指定した文字起こしタスクを一時中断します(ステータスをPendingにします) * @summary @@ -7453,6 +7514,18 @@ export class TasksApi extends BaseAPI { return TasksApiFp(this.configuration).getTasks(limit, offset, status, direction, paramName, options).then((request) => request(this.axios, this.basePath)); } + /** + * 完了した文字起こしタスクを再開します(ステータスをPendingにします) + * @summary + * @param {number} audioFileId ODMS Cloud上の音声ファイルID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof TasksApi + */ + public reopen(audioFileId: number, options?: AxiosRequestConfig) { + return TasksApiFp(this.configuration).reopen(audioFileId, options).then((request) => request(this.axios, this.basePath)); + } + /** * 指定した文字起こしタスクを一時中断します(ステータスをPendingにします) * @summary diff --git a/dictation_client/src/features/dictation/operations.ts b/dictation_client/src/features/dictation/operations.ts index a25c5d3..e9c0e63 100644 --- a/dictation_client/src/features/dictation/operations.ts +++ b/dictation_client/src/features/dictation/operations.ts @@ -450,6 +450,78 @@ export const cancelAsync = createAsyncThunk< } }); +export const reopenAsync = createAsyncThunk< + { + /** empty */ + }, + { + direction: DirectionType; + paramName: SortableColumnType; + audioFileId: number; + isTypist: boolean; + }, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("dictations/reopenAsync", async (args, thunkApi) => { + const { audioFileId, direction, paramName, isTypist } = args; + + // apiのConfigurationを取得する + const { getState } = thunkApi; + const state = getState() as RootState; + const { configuration } = state.auth; + const accessToken = getAccessToken(state.auth); + const config = new Configuration(configuration); + const tasksApi = new TasksApi(config); + const usersApi = new UsersApi(config); + try { + // ユーザーがタイピストである場合に、ソート条件を保存する + if (isTypist) { + await usersApi.updateSortCriteria( + { direction, paramName }, + { + headers: { authorization: `Bearer ${accessToken}` }, + } + ); + } + await tasksApi.reopen(audioFileId, { + headers: { authorization: `Bearer ${accessToken}` }, + }); + thunkApi.dispatch( + openSnackbar({ + level: "info", + message: getTranslationID("common.message.success"), + }) + ); + return {}; + } catch (e) { + // e ⇒ errorObjectに変換" + const error = createErrorObject(e); + + // ステータスが[Finished]以外、またはタスクが存在しない場合、またはtypistで自分のタスクでない場合 + if (error.code === "E010601" || error.code === "E010603") { + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("dictationPage.message.reopenFailedError"), + }) + ); + return thunkApi.rejectWithValue({ error }); + } + + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("common.message.internalServerError"), + }) + ); + return thunkApi.rejectWithValue({ error }); + } +}); + export const listBackupPopupTasksAsync = createAsyncThunk< TasksResponse, { diff --git a/dictation_client/src/pages/DictationPage/index.tsx b/dictation_client/src/pages/DictationPage/index.tsx index 1c8a47b..9907a63 100644 --- a/dictation_client/src/pages/DictationPage/index.tsx +++ b/dictation_client/src/pages/DictationPage/index.tsx @@ -32,6 +32,7 @@ import { selectIsLoading, playbackAsync, cancelAsync, + reopenAsync, PRIORITY, deleteTaskAsync, isSortableColumnType, @@ -491,6 +492,58 @@ const DictationPage: React.FC = (): JSX.Element => { ] ); + const onReopen = useCallback( + async (audioFileId: number) => { + if ( + /* eslint-disable-next-line no-alert */ + !window.confirm(t(getTranslationID("common.message.dialogConfirm"))) + ) { + return; + } + + const { meta } = await dispatch( + reopenAsync({ + audioFileId, + direction: sortDirection, + paramName: sortableParamName, + isTypist, + }) + ); + if (meta.requestStatus === "fulfilled") { + const filter = getFilter( + filterUploaded, + filterInProgress, + filterPending, + filterFinished, + filterBackup + ); + dispatch( + listTasksAsync({ + limit: LIMIT_TASK_NUM, + offset: 0, + filter, + direction: sortDirection, + paramName: sortableParamName, + }) + ); + dispatch(listTypistsAsync()); + dispatch(listTypistGroupsAsync()); + } + }, + [ + dispatch, + filterBackup, + filterFinished, + filterInProgress, + filterPending, + filterUploaded, + isTypist, + sortDirection, + sortableParamName, + t, + ] + ); + const onCloseBackupPopup = useCallback(() => { setIsBackupPopupOpen(false); }, []); @@ -1263,6 +1316,27 @@ const DictationPage: React.FC = (): JSX.Element => { )} +
  • + {/* タスクのステータスがFinishedかつ、ログインユーザーがAdminかTypistの場合、Change status to Pendingボタンを活性化する */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */} + { + onReopen(x.audioFileId); + }} + > + {t( + getTranslationID( + "dictationPage.label.reopenDictation" + ) + )} + +
  • {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */} { // 通知処理が想定通りの引数で呼ばれているか確認 expect(notificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${typistUserId}`], + `user_${typistUserId}`, { authorId: 'AUTHOR_ID', filename: 'file', + id: '2', priority: 'High', uploadedAt: '2023-05-26T11:22:33.444', }, @@ -472,10 +473,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => { // 通知処理が想定通りの引数で呼ばれているか確認 expect(notificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${typistUserId}`], + `user_${typistUserId}`, { authorId: 'AUTHOR_ID', filename: 'file', + id: '2', priority: 'High', uploadedAt: '2023-05-26T11:22:33.444', }, @@ -737,10 +739,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => { // 通知処理が想定通りの引数で呼ばれているか確認 expect(notificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${typistUserId}`], + `user_${typistUserId}`, { authorId: 'AUTHOR_ID', filename: 'file', + id: '2', priority: 'High', uploadedAt: '2023-05-26T11:22:33.444', }, @@ -1408,10 +1411,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => { // 通知処理が想定通りの引数で呼ばれているか確認 expect(notificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${typistUserId}`], + `user_${typistUserId}`, { authorId: 'AUTHOR_ID', filename: 'file', + id: '2', priority: 'High', uploadedAt: '2023-05-26T11:22:33.444', }, @@ -1526,10 +1530,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => { // 通知処理が想定通りの引数で呼ばれているか確認 expect(notificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${typistUserId}`], + `user_${typistUserId}`, { authorId: 'AUTHOR_ID', filename: 'file', + id: '2', priority: 'High', uploadedAt: '2023-05-26T11:22:33.444', }, @@ -1654,10 +1659,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => { // 通知処理が想定通りの引数で呼ばれているか確認 expect(notificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${typistUserId}`], + `user_${typistUserId}`, { authorId: 'AUTHOR_ID', filename: 'file', + id: '2', priority: 'High', uploadedAt: '2023-05-26T11:22:33.444', }, @@ -1782,10 +1788,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => { // 通知処理が想定通りの引数で呼ばれているか確認 expect(notificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${typistUserId}`], + `user_${typistUserId}`, { authorId: 'AUTHOR_ID', filename: 'file', + id: '2', priority: 'High', uploadedAt: '2023-05-26T11:22:33.444', }, @@ -1900,10 +1907,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => { // 通知処理が想定通りの引数で呼ばれているか確認 expect(notificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${typistUserId}`], + `user_${typistUserId}`, { authorId: 'AUTHOR_ID', filename: 'file', + id: '2', priority: 'High', uploadedAt: '2023-05-26T11:22:33.444', }, @@ -2050,10 +2058,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => { // 通知処理が想定通りの引数で呼ばれているか確認 expect(notificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${typistUserId}`], + `user_${typistUserId}`, { authorId: 'AUTHOR_ID', filename: 'file', + id: '2', priority: 'High', uploadedAt: '2023-05-26T11:22:33.444', }, @@ -2199,10 +2208,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => { // 通知処理が想定通りの引数で呼ばれているか確認 expect(notificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${typistUserId}`], + `user_${typistUserId}`, { authorId: 'AUTHOR_ID', filename: 'file', + id: '2', priority: 'High', uploadedAt: '2023-05-26T11:22:33.444', }, @@ -2349,10 +2359,11 @@ describe('タスク作成から自動ルーティング(DB使用)', () => { // 通知処理が想定通りの引数で呼ばれているか確認 expect(notificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${typistUserId}`], + `user_${typistUserId}`, { authorId: 'AUTHOR_ID', filename: 'file', + id: '2', priority: 'High', uploadedAt: '2023-05-26T11:22:33.444', }, diff --git a/dictation_server/src/features/files/files.service.ts b/dictation_server/src/features/files/files.service.ts index b65ed7b..969031b 100644 --- a/dictation_server/src/features/files/files.service.ts +++ b/dictation_server/src/features/files/files.service.ts @@ -273,12 +273,17 @@ export class FilesService { this.logger.log(`[${context.getTrackingId()}] tags: ${tags}`); // タグ対象に通知送信 - await this.notificationhubService.notify(context, tags, { - authorId: authorId, - filename: fileName.replace('.zip', ''), - priority: priority === '00' ? 'Normal' : 'High', - uploadedAt: uploadedDate, - }); + await Promise.all( + tags.map((tag) => { + return this.notificationhubService.notify(context, tag, { + id: tag.split('user_')[1], + authorId: authorId, + filename: fileName.replace('.zip', ''), + priority: priority === '00' ? 'Normal' : 'High', + uploadedAt: uploadedDate, + }); + }), + ); // 追加したタスクのJOBナンバーを返却 return { jobNumber: task.job_number }; diff --git a/dictation_server/src/features/tasks/tasks.controller.ts b/dictation_server/src/features/tasks/tasks.controller.ts index 7855c34..679b2f4 100644 --- a/dictation_server/src/features/tasks/tasks.controller.ts +++ b/dictation_server/src/features/tasks/tasks.controller.ts @@ -831,4 +831,91 @@ export class TasksController { await this.taskService.deleteTask(context, userId, audioFileId); return {}; } + + @Post(':audioFileId/reopen') + @ApiResponse({ + status: HttpStatus.OK, + type: ChangeStatusResponse, + description: '成功時のレスポンス', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: '不正なパラメータ', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: '指定したIDの音声ファイルが存在しない場合', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: '認証エラー', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: '想定外のサーバーエラー', + type: ErrorResponse, + }) + @ApiOperation({ + operationId: 'reopen', + description: + '終了した文字起こしタスクを再開します(ステータスをPendingにします)', + }) + @UseGuards(AuthGuard) + @UseGuards( + RoleGuard.requireds({ + roles: [ADMIN_ROLES.ADMIN, USER_ROLES.TYPIST], + }), + ) + @ApiBearerAuth() + async reopen( + @Req() req: Request, + @Param() params: ChangeStatusRequest, + ): Promise { + const { audioFileId } = params; + // AuthGuardでチェック済みなのでここでのアクセストークンチェックはしない + + const accessToken = retrieveAuthorizationToken(req); + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + + const ip = retrieveIp(req); + if (!ip) { + throw new HttpException( + makeErrorResponse('E000401'), + HttpStatus.UNAUTHORIZED, + ); + } + + const requestId = retrieveRequestId(req); + if (!requestId) { + throw new HttpException( + makeErrorResponse('E000501'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId, role } = decodedAccessToken as AccessToken; + // RoleGuardでroleの文字列に想定外の文字列や重複がないことは担保されているためここでは型変換のみ行う + const roles = role.split(' ') as Roles[]; + + const context = makeContext(userId, requestId); + this.logger.log(`[${context.getTrackingId()}] ip : ${ip}`); + + await this.taskService.reopen(context, audioFileId, userId, roles); + return {}; + } } diff --git a/dictation_server/src/features/tasks/tasks.service.spec.ts b/dictation_server/src/features/tasks/tasks.service.spec.ts index 564eaa3..6284a20 100644 --- a/dictation_server/src/features/tasks/tasks.service.spec.ts +++ b/dictation_server/src/features/tasks/tasks.service.spec.ts @@ -1132,10 +1132,11 @@ describe('changeCheckoutPermission', () => { // 通知処理が想定通りの引数で呼ばれているか確認 expect(NotificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${typistUserId_2}`], + `user_${typistUserId_2}`, { authorId: 'MY_AUTHOR_ID', filename: 'x', + id: '2', priority: 'High', uploadedAt: resultTask?.file?.uploaded_at.toISOString(), }, @@ -1216,10 +1217,11 @@ describe('changeCheckoutPermission', () => { // 通知処理が想定通りの引数で呼ばれているか確認 expect(NotificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${typistUserId_2}`], + `user_${typistUserId_2}`, { authorId: 'MY_AUTHOR_ID', filename: 'x', + id: '2', priority: 'High', uploadedAt: resultTask?.file?.uploaded_at.toISOString(), }, @@ -3529,10 +3531,11 @@ describe('cancel', () => { // 通知処理が想定通りの引数で呼ばれているか確認 expect(NotificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${typistUserId}`], + `user_${typistUserId}`, { authorId: 'AUTHOR_ID', filename: 'x', + id: '1', priority: 'High', uploadedAt: resultTask?.file?.uploaded_at.toISOString(), }, @@ -3639,10 +3642,11 @@ describe('cancel', () => { // 通知処理が想定通りの引数で呼ばれているか確認 expect(NotificationHubService.notify).toHaveBeenCalledWith( makeContext('trackingId', 'requestId'), - [`user_${autoRoutingTypistUserId}`], + `user_${autoRoutingTypistUserId}`, { authorId: 'AUTHOR_ID', filename: 'x', + id: '2', priority: 'High', uploadedAt: resultTask?.file?.uploaded_at.toISOString(), }, @@ -5308,3 +5312,323 @@ describe('deleteTask', () => { } }); }); + +describe('reopen', () => { + 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('API実行者のRoleがTypistの場合、自身が担当する完了済みの文字起こしタスクを再開できる', async () => { + if (!source) fail(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModuleWithNotificaiton( + source, + notificationhubServiceMockValue, + ); + if (!module) fail(); + const { id: accountId } = await makeTestSimpleAccount(source); + const { id: typistUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'typist-user-external-id', + role: 'typist', + }); + const { id: authorUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'MY_AUTHOR_ID', + }); + const { taskId } = await createTask( + source, + accountId, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000001', + 'Finished', + typistUserId, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + + const service = module.get(TasksService); + + await service.reopen( + makeContext('trackingId', 'requestId'), + 1, + 'typist-user-external-id', + ['typist', 'standard'], + ); + const resultTask = await getTask(source, taskId); + + expect(resultTask?.status).toEqual('Pending'); + expect(resultTask?.finished_at).toEqual(null); + }); + + it('API実行者のRoleがAdminの場合、完了済みの文字起こしタスクを再開できる', async () => { + if (!source) fail(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModuleWithNotificaiton( + source, + notificationhubServiceMockValue, + ); + if (!module) fail(); + const { id: accountId } = await makeTestSimpleAccount(source); + const { id: typistUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'typist-user-external-id', + role: 'typist', + }); + const { id: authorUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'MY_AUTHOR_ID', + }); + const { taskId } = await createTask( + source, + accountId, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000001', + 'Finished', + typistUserId, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + const service = module.get(TasksService); + + await service.reopen( + makeContext('trackingId', 'requestId'), + 1, + 'typist-user-external-id', + ['admin', 'standard'], + ); + + const resultTask = await getTask(source, taskId); + + expect(resultTask?.status).toEqual('Pending'); + expect(resultTask?.finished_at).toEqual(null); + }); + + it('タスクのステータスが[Finished]でない時、タスクを再開できない', async () => { + if (!source) fail(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModuleWithNotificaiton( + source, + notificationhubServiceMockValue, + ); + if (!module) fail(); + const { id: accountId } = await makeTestSimpleAccount(source); + const { id: typistUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'typist-user-external-id', + role: 'typist', + }); + const { id: authorUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'MY_AUTHOR_ID', + }); + const { taskId } = await createTask( + source, + accountId, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000001', + 'Uploaded', + typistUserId, + ); + await createCheckoutPermissions(source, taskId, typistUserId); + + const service = module.get(TasksService); + await expect( + service.reopen( + makeContext('trackingId', 'requestId'), + 1, + 'typist-user-external-id', + ['admin', 'author'], + ), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST), + ); + }); + + it('API実行者のRoleがTypistの場合、他人の終了済み文字起こしタスクを再開できない', async () => { + if (!source) fail(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModuleWithNotificaiton( + source, + notificationhubServiceMockValue, + ); + if (!module) fail(); + const { id: accountId } = await makeTestSimpleAccount(source); + await makeTestUser(source, { + account_id: accountId, + external_id: 'typist-user-external-id', + role: 'typist', + }); + const { id: anotherTypistUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'another-typist-user-external-id', + role: 'typist', + }); + const { id: authorUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'MY_AUTHOR_ID', + }); + const { taskId } = await createTask( + source, + accountId, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000001', + 'Finished', + anotherTypistUserId, + ); + await createCheckoutPermissions(source, taskId, anotherTypistUserId); + + const service = module.get(TasksService); + + await expect( + service.reopen( + makeContext('trackingId', 'requestId'), + 1, + 'typist-user-external-id', + ['typist', 'standard'], + ), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST), + ); + }); + + it('API実行者のRoleがAuthorの場合、他人が終了済み文字起こしタスクを再開できない', async () => { + if (!source) fail(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModuleWithNotificaiton( + source, + notificationhubServiceMockValue, + ); + if (!module) fail(); + const { id: accountId } = await makeTestSimpleAccount(source); + await makeTestUser(source, { + account_id: accountId, + external_id: 'typist-user-external-id', + role: 'typist', + }); + const { id: anotherTypistUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'another-typist-user-external-id', + role: 'typist', + }); + const { id: authorUserId } = await makeTestUser(source, { + account_id: accountId, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'MY_AUTHOR_ID', + }); + const { taskId } = await createTask( + source, + accountId, + authorUserId, + 'MY_AUTHOR_ID', + '', + '01', + '00000001', + 'Finished', + anotherTypistUserId, + ); + await createCheckoutPermissions(source, taskId, anotherTypistUserId); + + const service = module.get(TasksService); + + await expect( + service.reopen( + makeContext('trackingId', 'requestId'), + 1, + 'author-user-external-id', + ['author', 'standard'], + ), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010601'), HttpStatus.BAD_REQUEST), + ); + }); + + it('タスクがない時、タスクを再開できない', async () => { + if (!source) fail(); + const notificationhubServiceMockValue = + makeDefaultNotificationhubServiceMockValue(); + const module = await makeTaskTestingModuleWithNotificaiton( + source, + notificationhubServiceMockValue, + ); + if (!module) fail(); + const { id: accountId } = await makeTestSimpleAccount(source); + await makeTestUser(source, { + account_id: accountId, + external_id: 'typist-user-external-id', + role: 'typist', + }); + + await makeTestUser(source, { + account_id: accountId, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'MY_AUTHOR_ID', + }); + + const service = module.get(TasksService); + + await expect( + service.reopen( + makeContext('trackingId', 'requestId'), + 1, + 'typist-user-external-id', + ['typist', 'standard'], + ), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010603'), HttpStatus.NOT_FOUND), + ); + }); +}); diff --git a/dictation_server/src/features/tasks/tasks.service.ts b/dictation_server/src/features/tasks/tasks.service.ts index cb28c91..8d82889 100644 --- a/dictation_server/src/features/tasks/tasks.service.ts +++ b/dictation_server/src/features/tasks/tasks.service.ts @@ -712,6 +712,80 @@ export class TasksService { } } + /** + * 指定した完了済みの音声ファイルに紐づくタスクを再開する(ステータスをPendingに変更する) + * @param audioFileId + * @param externalId + * @param role + * @returns reopen + */ + async reopen( + context: Context, + audioFileId: number, + externalId: string, + role: Roles[], + ): Promise { + this.logger.log( + `[IN] [${context.getTrackingId()}] ${ + this.reopen.name + } | params: { audioFileId: ${audioFileId}, externalId: ${externalId}, role: ${role} };`, + ); + let user: User; + try { + // ユーザー取得 + user = await this.usersRepository.findUserByExternalId( + context, + externalId, + ); + } catch (e) { + this.logger.error(`[${context.getTrackingId()}] error=${e}`); + this.logger.log(`[OUT] [${context.getTrackingId()}] ${this.reopen.name}`); + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + try { + // roleにAdminが含まれていれば、文字起こし担当でなくても再開できるため、ユーザーIDは指定しない + await this.taskRepository.reopen( + context, + audioFileId, + [TASK_STATUS.FINISHED], + user.account_id, + role.includes(ADMIN_ROLES.ADMIN) ? undefined : user.id, + ); + } catch (e) { + this.logger.error(`[${context.getTrackingId()}] error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case TasksNotFoundError: + throw new HttpException( + makeErrorResponse('E010603'), + HttpStatus.NOT_FOUND, + ); + case StatusNotMatchError: + case TypistUserNotMatchError: + throw new HttpException( + makeErrorResponse('E010601'), + 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.reopen.name}`); + } + } + /** * 指定した音声ファイルに紐づくタスクをbackupする * @param context @@ -1037,12 +1111,18 @@ export class TasksService { } // タグ対象に通知送信 - await this.notificationhubService.notify(context, tags, { - authorId: file.author_id, - filename: file.file_name.replace('.zip', ''), - priority: file.priority === '00' ? 'Normal' : 'High', - uploadedAt: file.uploaded_at.toISOString(), - }); + await Promise.all( + tags.map((tag) => { + return this.notificationhubService.notify(context, tag, { + id: tag.split('user_')[1], + authorId: file.author_id, + filename: file.file_name.replace('.zip', ''), + priority: file.priority === '00' ? 'Normal' : 'High', + uploadedAt: file.uploaded_at.toISOString(), + }); + }), + ); + this.logger.log( `[OUT] [${context.getTrackingId()}] ${this.sendNotify.name}`, ); diff --git a/dictation_server/src/gateways/notificationhub/notificationhub.service.ts b/dictation_server/src/gateways/notificationhub/notificationhub.service.ts index fe60909..1505731 100644 --- a/dictation_server/src/gateways/notificationhub/notificationhub.service.ts +++ b/dictation_server/src/gateways/notificationhub/notificationhub.service.ts @@ -9,7 +9,6 @@ import { createAppleInstallation, createWindowsRawNotification, } from '@azure/notification-hubs'; -import { TAG_MAX_COUNT } from '../../constants'; import { PNS } from '../../constants'; import { Context } from '../../common/log'; import { NotificationBody } from '../../common/notify/types/types'; @@ -83,65 +82,57 @@ export class NotificationhubService { /** * 指定したタグのユーザーに通知を送信する * @param context - * @param tags + * @param tag * @param bodyContent * @returns notify */ async notify( context: Context, - tags: string[], + tag: string, bodyContent: NotificationBody, ): Promise { this.logger.log( `[IN] [${context.getTrackingId()}] ${ this.notify.name - } | params: { tags: ${tags}, bodyContent: ${JSON.stringify( - bodyContent, - )} }`, + } | params: { tag: ${tag}, bodyContent: ${JSON.stringify(bodyContent)} }`, ); try { - // OR条件によるtag指定は20個までなので分割して送信する - const chunkTags = splitArrayInChunks(tags, TAG_MAX_COUNT); + const tagExpression = createTagExpression([tag]); - for (let index = 0; index < chunkTags.length; index++) { - const currentTags = chunkTags[index]; - const tagExpression = createTagExpression(currentTags); - - // Windows - try { - const body = { - wns: { - alert: '', - }, - newDictation: bodyContent, - }; - const notification = createWindowsRawNotification({ - body: JSON.stringify(body), - }); - const result = await this.client.sendNotification(notification, { - tagExpression, - }); - this.logger.log(`[${context.getTrackingId()}] ${result}`); - } catch (e) { - this.logger.error(`[${context.getTrackingId()}] error=${e}`); - } - // Apple - try { - const body = createAppleNotificationBody({ - aps: { - alert: '', - }, - newDictation: bodyContent, - }); - const notification = createAppleNotification({ body }); - const result = await this.client.sendNotification(notification, { - tagExpression, - }); - this.logger.log(`[${context.getTrackingId()}] ${result}`); - } catch (e) { - this.logger.error(`[${context.getTrackingId()}] error=${e}`); - } + // Windows + try { + const body = { + wns: { + alert: '', + }, + newDictation: bodyContent, + }; + const notification = createWindowsRawNotification({ + body: JSON.stringify(body), + }); + const result = await this.client.sendNotification(notification, { + tagExpression, + }); + this.logger.log(`[${context.getTrackingId()}] ${result}`); + } catch (e) { + this.logger.error(`[${context.getTrackingId()}] error=${e}`); + } + // Apple + try { + const body = createAppleNotificationBody({ + aps: { + alert: '', + }, + newDictation: bodyContent, + }); + const notification = createAppleNotification({ body }); + const result = await this.client.sendNotification(notification, { + tagExpression, + }); + this.logger.log(`[${context.getTrackingId()}] ${result}`); + } catch (e) { + this.logger.error(`[${context.getTrackingId()}] error=${e}`); } } catch (e) { throw e; @@ -150,11 +141,3 @@ export class NotificationhubService { } } } - -const splitArrayInChunks = (arr: string[], size: number): string[][] => { - const result: string[][] = []; - for (let i = 0; i < arr.length; i += size) { - result.push(arr.slice(i, i + size)); - } - return result; -}; diff --git a/dictation_server/src/repositories/tasks/tasks.repository.service.ts b/dictation_server/src/repositories/tasks/tasks.repository.service.ts index 5272ee2..841f6c5 100644 --- a/dictation_server/src/repositories/tasks/tasks.repository.service.ts +++ b/dictation_server/src/repositories/tasks/tasks.repository.service.ts @@ -506,6 +506,69 @@ export class TasksRepositoryService { }); } + /** + * 音声ファイルIDで指定した完了済みのタスクを再開する(Pendingに変更する) + * @param audio_file_id 再開するタスクの音声ファイルID + * @param permittedSourceStatus 再開可能なステータス + * @param account_id 再開するタスクのアカウントID + * @param user_id 再開するユーザーのID(API実行者がAdminのときは使用しない) + * @returns reopen + */ + async reopen( + context: Context, + audio_file_id: number, + permittedSourceStatus: TaskStatus[], + account_id: number, + user_id?: number | undefined, + ): Promise { + await this.dataSource.transaction(async (entityManager) => { + const taskRepo = entityManager.getRepository(Task); + const task = await taskRepo.findOne({ + where: { + audio_file_id: audio_file_id, + }, + comment: `${context.getTrackingId()}_${new Date().toUTCString()}`, + lock: { mode: 'pessimistic_write' }, + }); + if (!task) { + throw new TasksNotFoundError( + `task not found. audio_file_id:${audio_file_id}`, + ); + } + if (!isTaskStatus(task.status)) { + throw new Error('invalid task status.'); + } + // ステータスチェック + if (!permittedSourceStatus.includes(task.status)) { + throw new StatusNotMatchError( + `Unexpected task status. audio_file_id:${audio_file_id}, status:${task.status}`, + ); + } + if (task.account_id !== account_id) { + throw new AccountNotMatchError( + `task account_id not match. audio_file_id:${audio_file_id}, account_id(Task):${task.account_id}, account_id:${account_id}`, + ); + } + if (user_id && task.typist_user_id !== user_id) { + throw new TypistUserNotMatchError( + `TypistUser not match. audio_file_id:${audio_file_id}, typist_user_id:${task.typist_user_id}, user_id:${user_id}`, + ); + } + + // 対象タスクのステータスをPendingに,文字起こし終了日時をnullに更新 + await updateEntity( + taskRepo, + { audio_file_id: audio_file_id }, + { + status: TASK_STATUS.PENDING, + finished_at: null, + }, + this.isCommentOut, + context, + ); + }); + } + /** * 音声ファイルIDで指定したタスクをbackupする * @param accountId バックアップするタスクのアカウントID @@ -921,7 +984,7 @@ export class TasksRepositoryService { throw new Error(`JobNumber not exists. account_id:${account_id}`); } - let newJobNumber: string = ''; + let newJobNumber = ''; if (currentJobNumberData.job_number === MAX_JOB_NUMBER) { // 末尾なら00000001に戻る newJobNumber = '00000001';