diff --git a/DockerfileServerDictation.dockerfile b/DockerfileServerDictation.dockerfile index aa666ba..2efefee 100644 --- a/DockerfileServerDictation.dockerfile +++ b/DockerfileServerDictation.dockerfile @@ -20,4 +20,6 @@ RUN mkdir build \ COPY --from=build-container app/dictation_server/dist/ dist/ COPY --from=build-container app/dictation_server/.env ./ COPY --from=build-container app/dictation_server/node_modules/ node_modules/ +ARG BUILD_VERSION +ENV BUILD_VERSION=${BUILD_VERSION} CMD ["node", "./dist/main.js" ] diff --git a/azure-pipelines-production.yml b/azure-pipelines-production.yml index 45eba43..1359ec5 100644 --- a/azure-pipelines-production.yml +++ b/azure-pipelines-production.yml @@ -1,5 +1,4 @@ -# Pipeline側でKeyVaultやDocker、AppService等に対する操作権限を持ったServiceConenctionを作成し、 -# 環境変数 AZURE_SERVICE_CONNECTION の値としてServiceConenction名を設定しておくこと +# Pipeline側でKeyVaultやDocker、AppService等に対する操作権限を持ったServiceConenctionを作成しておくこと # また、環境変数 STATIC_DICTATION_DEPLOYMENT_TOKEN の値として静的WebAppsのデプロイトークンを設定しておくこと trigger: tags: @@ -26,43 +25,40 @@ jobs: fi displayName: 'タグが付けられたCommitがmainブランチに存在するか確認' - job: backend_deploy + dependsOn: initialize + condition: succeeded('initialize') displayName: Backend Deploy pool: - name: odms-deploy-pipeline + vmImage: ubuntu-latest steps: - checkout: self clean: true fetchDepth: 1 - - task: AzureRmWebAppDeployment@4 + - task: AzureWebAppContainer@1 inputs: - ConnectionType: 'AzureRM' - azureSubscription: $(AZURE_SERVICE_CONNECTION) - appType: 'webAppContainer' - WebAppName: 'app-odms-dictation-prod' - ResourceGroupName: 'prod-application-rg' - DockerNamespace: 'crodmsregistrymaintenance.azurecr.io' - DockerRepository: '$(Build.Repository.Name)/staging/dictation' - DockerImageTag: '$(Build.SourceVersion)' + azureSubscription: 'omds-service-connection-prod' + appName: 'app-odms-dictation-prod' + deployToSlotOrASE: true + resourceGroupName: 'odms-prod-rg' + slotName: 'staging' + containers: 'crodmsregistrymaintenance.azurecr.io/odmscloud/staging/dictation:$(Build.SourceVersion)' + # TODO: stagingパイプライン実装時、staging用のイメージに変更する - job: frontend_deploy + dependsOn: backend_deploy + condition: succeeded('backend_deploy') displayName: Deploy Frontend Files variables: storageAccountName: saomdspipeline containerName: staging pool: - name: odms-deploy-pipeline + vmImage: ubuntu-latest steps: - checkout: self clean: true fetchDepth: 1 - - task: AzureKeyVault@2 - displayName: 'Azure Key Vault: kv-odms-secret-prod' - inputs: - ConnectedServiceName: $(AZURE_SERVICE_CONNECTION) - KeyVaultName: kv-odms-secret-prod - SecretsFilter: '*' - task: AzureCLI@2 inputs: - azureSubscription: $(AZURE_SERVICE_CONNECTION) + azureSubscription: 'omds-service-connection-prod' scriptType: 'bash' scriptLocation: 'inlineScript' inlineScript: | @@ -88,15 +84,42 @@ jobs: is_static_export: false verbose: false azure_static_web_apps_api_token: $(STATIC_DICTATION_DEPLOYMENT_TOKEN) -- job: migration - condition: succeeded('initialize') - displayName: DB migration - dependsOn: - - initialize - - backend_deploy - - frontend_deploy +- job: smoke_test + dependsOn: frontend_deploy + condition: succeeded('frontend_deploy') + displayName: 'smoke test' pool: - name: db-migrate-pipelines + 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-prod' + inputs: + azureSubscription: 'omds-service-connection-prod' + action: 'Swap Slots' + WebAppName: 'app-odms-dictation-prod' + ResourceGroupName: 'odms-prod-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 @@ -104,7 +127,7 @@ jobs: - task: AzureKeyVault@2 displayName: 'Azure Key Vault: kv-odms-secret-prod' inputs: - ConnectedServiceName: $(AZURE_SERVICE_CONNECTION) + ConnectedServiceName: 'omds-service-connection-prod' KeyVaultName: kv-odms-secret-prod - task: CmdLine@2 displayName: migration diff --git a/azure-pipelines-staging.yml b/azure-pipelines-staging.yml index 78965ab..22dd085 100644 --- a/azure-pipelines-staging.yml +++ b/azure-pipelines-staging.yml @@ -1,5 +1,4 @@ -# Pipeline側でKeyVaultやDocker、AppService等に対する操作権限を持ったServiceConenctionを作成し、 -# 環境変数 AZURE_SERVICE_CONNECTION の値としてServiceConenction名を設定しておくこと +# Pipeline側でKeyVaultやDocker、AppService等に対する操作権限を持ったServiceConenctionを作成しておくこと # また、環境変数 STATIC_DICTATION_DEPLOYMENT_TOKEN の値として静的WebAppsのデプロイトークンを設定しておくこと trigger: branches: @@ -47,7 +46,7 @@ jobs: - task: AzureKeyVault@2 displayName: 'Azure Key Vault: kv-odms-secret-stg' inputs: - ConnectedServiceName: $(AZURE_SERVICE_CONNECTION) + ConnectedServiceName: 'omds-service-connection-stg' KeyVaultName: kv-odms-secret-stg SecretsFilter: '*' - task: Bash@3 @@ -59,6 +58,7 @@ jobs: npm run test env: JWT_PUBLIC_KEY: $(token-public-key) + JWT_PRIVATE_KEY: $(token-private-key) SENDGRID_API_KEY: $(sendgrid-api-key) NOTIFICATION_HUB_NAME: $(notification-hub-name) NOTIFICATION_HUB_CONNECT_STRING: $(notification-hub-connect-string) @@ -74,43 +74,31 @@ jobs: ADB2C_TENANT_ID: $(adb2c-tenant-id) ADB2C_CLIENT_ID: $(adb2c-client-id) ADB2C_CLIENT_SECRET: $(adb2c-client-secret) + MAIL_FROM: xxxxxx + APP_DOMAIN: xxxxxxxxx + EMAIL_CONFIRM_LIFETIME : 0 + TENANT_NAME : xxxxxxxxxxxx + SIGNIN_FLOW_NAME : xxxxxxxxxxxx + STORAGE_TOKEN_EXPIRE_TIME : 0 - task: Docker@0 displayName: build inputs: - azureSubscriptionEndpoint: $(AZURE_SERVICE_CONNECTION) + 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: $(Build.Repository.Name)/staging/dictation:$(Build.SourceVersion) + imageName: odmscloud/staging/dictation:$(Build.SourceVersion) + buildArguments: | + BUILD_VERSION=$(Build.SourceVersion) - task: Docker@0 displayName: push inputs: - azureSubscriptionEndpoint: $(AZURE_SERVICE_CONNECTION) + 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: $(Build.Repository.Name)/staging/dictation:$(Build.SourceVersion) -- job: backend_deploy + imageName: odmscloud/staging/dictation:$(Build.SourceVersion) +- job: frontend_build dependsOn: backend_build condition: succeeded('backend_build') - displayName: Backend Deploy - pool: - name: odms-deploy-pipeline - steps: - - checkout: self - clean: true - fetchDepth: 1 - - task: AzureRmWebAppDeployment@4 - inputs: - ConnectionType: 'AzureRM' - azureSubscription: $(AZURE_SERVICE_CONNECTION) - appType: 'webAppContainer' - WebAppName: 'app-odms-dictation-stg' - ResourceGroupName: 'stg-application-rg' - DockerNamespace: 'crodmsregistrymaintenance.azurecr.io' - DockerRepository: '$(Build.Repository.Name)/staging/dictation' - DockerImageTag: '$(Build.SourceVersion)' -- job: frontend_build - dependsOn: initialize - condition: succeeded('initialize') displayName: Build Frontend Files variables: storageAccountName: saomdspipeline @@ -141,7 +129,7 @@ jobs: replaceExistingArchive: true - task: AzureCLI@2 inputs: - azureSubscription: $(AZURE_SERVICE_CONNECTION) + azureSubscription: 'omds-service-connection-stg' scriptType: 'bash' scriptLocation: 'inlineScript' inlineScript: | @@ -151,10 +139,22 @@ jobs: --container-name $(containerName) \ --name $(Build.SourceVersion).zip \ --type block \ + --overwrite \ --file $(Build.ArtifactStagingDirectory)/$(Build.SourceVersion).zip -- job: frontend_deploy +- job: backend_deploy dependsOn: frontend_build condition: succeeded('frontend_build') + displayName: Backend Deploy + pool: + name: odms-deploy-pipeline + steps: + - checkout: self + clean: true + fetchDepth: 1 + # TODO: Productionと同様にデプロイを行う +- job: frontend_deploy + dependsOn: backend_deploy + condition: succeeded('backend_deploy') displayName: Deploy Frontend Files variables: storageAccountName: saomdspipeline @@ -165,69 +165,15 @@ jobs: - checkout: self clean: true fetchDepth: 1 - - task: AzureKeyVault@2 - displayName: 'Azure Key Vault: kv-odms-secret-stg' - inputs: - ConnectedServiceName: $(AZURE_SERVICE_CONNECTION) - KeyVaultName: kv-odms-secret-stg - SecretsFilter: '*' - - task: AzureCLI@2 - inputs: - azureSubscription: $(AZURE_SERVICE_CONNECTION) - scriptType: 'bash' - scriptLocation: 'inlineScript' - inlineScript: | - az storage blob download \ - --auth-mode login \ - --account-name $(storageAccountName) \ - --container-name $(containerName) \ - --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) + # TODO: Productionと同様にデプロイを行う - job: migration - condition: succeeded('initialize') + dependsOn: frontend_deploy + condition: succeeded('frontend_deploy') displayName: DB migration - dependsOn: - - initialize - - backend_deploy - - frontend_deploy pool: - name: db-migrate-pipelines + name: odms-deploy-pipeline steps: - checkout: self clean: true fetchDepth: 1 - - task: AzureKeyVault@2 - displayName: 'Azure Key Vault: kv-odms-secret-stg' - inputs: - ConnectedServiceName: $(AZURE_SERVICE_CONNECTION) - KeyVaultName: kv-odms-secret-stg - - task: CmdLine@2 - displayName: migration - inputs: - script: >2 - # DB接続情報書き換え - sed -i -e "s/DB_NAME/$(db-name)/g" ./dictation_server/db/dbconfig.yml - sed -i -e "s/DB_PASS/$(db-pass)/g" ./dictation_server/db/dbconfig.yml - sed -i -e "s/DB_USERNAME/$(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 \ No newline at end of file + # TODO: Productionと同様にマイグレーションを行う \ No newline at end of file diff --git a/dictation_client/src/App.tsx b/dictation_client/src/App.tsx index 39bfec6..d9717a9 100644 --- a/dictation_client/src/App.tsx +++ b/dictation_client/src/App.tsx @@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next"; import Snackbar from "components/snackbar"; import { selectSnackber } from "features/ui/selectors"; import { closeSnackbar } from "features/ui/uiSlice"; +import { UNAUTHORIZED_TO_CONTINUE_ERROR_CODES } from "components/auth/constants"; const App = (): JSX.Element => { const dispatch = useDispatch(); @@ -21,8 +22,12 @@ const App = (): JSX.Element => { useEffect(() => { const id = globalAxios.interceptors.response.use( (response: AxiosResponse) => response, - (e: AxiosError) => { - if (e?.response?.status === 401) { + (e: AxiosError<{ code?: string }>) => { + if ( + e?.response?.status === 401 && + e?.response?.data?.code && + !UNAUTHORIZED_TO_CONTINUE_ERROR_CODES.includes(e.response.data.code) + ) { dispatch(clearToken()); instance.logoutRedirect({ postLogoutRedirectUri: "/?logout=true", diff --git a/dictation_client/src/AppRouter.tsx b/dictation_client/src/AppRouter.tsx index cf6e1d3..7abc3ca 100644 --- a/dictation_client/src/AppRouter.tsx +++ b/dictation_client/src/AppRouter.tsx @@ -1,5 +1,6 @@ import { Route, Routes } from "react-router-dom"; import TopPage from "pages/TopPage"; +import AuthPage from "pages/AuthPage"; import LoginPage from "pages/LoginPage"; import SamplePage from "pages/SamplePage"; import { AuthErrorPage } from "pages/ErrorPage"; @@ -20,18 +21,21 @@ import WorkflowPage from "pages/WorkflowPage"; import TypistGroupSettingPage from "pages/TypistGroupSettingPage"; import WorktypeIdSettingPage from "pages/WorkTypeIdSettingPage"; import AccountPage from "pages/AccountPage"; +import AcceptToUsePage from "pages/TermsPage"; import { TemplateFilePage } from "pages/TemplateFilePage"; import { AccountDeleteSuccess } from "pages/AccountPage/accountDeleteSuccess"; const AppRouter: React.FC = () => ( } /> + } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/dictation_client/src/api/api.ts b/dictation_client/src/api/api.ts index 36a1050..680a7e6 100644 --- a/dictation_client/src/api/api.ts +++ b/dictation_client/src/api/api.ts @@ -127,7 +127,7 @@ export interface AllocatableLicenseInfo { * @type {string} * @memberof AllocatableLicenseInfo */ - 'expiryDate': string; + 'expiryDate'?: string; } /** * @@ -442,11 +442,17 @@ export interface CreateAccountRequest { */ 'adminPassword': string; /** - * 同意済み利用規約のバージョン + * 同意済み利用規約のバージョン(EULA) * @type {string} * @memberof CreateAccountRequest */ - 'acceptedTermsVersion': string; + 'acceptedEulaVersion': string; + /** + * 同意済み利用規約のバージョン(DPA) + * @type {string} + * @memberof CreateAccountRequest + */ + 'acceptedDpaVersion': string; /** * reCAPTCHA Token * @type {string} @@ -530,7 +536,7 @@ export interface CreateTypistGroupRequest { */ export interface CreateWorkflowsRequest { /** - * Authornの内部ID + * Authorの内部ID * @type {number} * @memberof CreateWorkflowsRequest */ @@ -643,6 +649,32 @@ export interface ErrorResponse { */ 'code': string; } +/** + * + * @export + * @interface GetAccountInfoMinimalAccessRequest + */ +export interface GetAccountInfoMinimalAccessRequest { + /** + * idトークン + * @type {string} + * @memberof GetAccountInfoMinimalAccessRequest + */ + 'idToken': string; +} +/** + * + * @export + * @interface GetAccountInfoMinimalAccessResponse + */ +export interface GetAccountInfoMinimalAccessResponse { + /** + * 階層 + * @type {number} + * @memberof GetAccountInfoMinimalAccessResponse + */ + 'tier': number; +} /** * * @export @@ -994,6 +1026,19 @@ export interface GetTemplatesResponse { */ 'templates': Array; } +/** + * + * @export + * @interface GetTermsInfoResponse + */ +export interface GetTermsInfoResponse { + /** + * + * @type {Array} + * @memberof GetTermsInfoResponse + */ + 'termsInfo': Array; +} /** * * @export @@ -1784,6 +1829,25 @@ export interface TemplateUploadLocationResponse { */ 'url': string; } +/** + * + * @export + * @interface TermInfo + */ +export interface TermInfo { + /** + * 利用規約種別 + * @type {string} + * @memberof TermInfo + */ + 'documentType': string; + /** + * バージョン + * @type {string} + * @memberof TermInfo + */ + 'version': string; +} /** * * @export @@ -1860,6 +1924,31 @@ export interface TypistGroup { */ 'name': string; } +/** + * + * @export + * @interface UpdateAcceptedVersionRequest + */ +export interface UpdateAcceptedVersionRequest { + /** + * IDトークン + * @type {string} + * @memberof UpdateAcceptedVersionRequest + */ + 'idToken': string; + /** + * 更新バージョン(EULA) + * @type {string} + * @memberof UpdateAcceptedVersionRequest + */ + 'acceptedEULAVersion': string; + /** + * 更新バージョン(DPA) + * @type {string} + * @memberof UpdateAcceptedVersionRequest + */ + 'acceptedDPAVersion'?: string; +} /** * * @export @@ -1923,6 +2012,37 @@ export interface UpdateTypistGroupRequest { */ 'typistIds': Array; } +/** + * + * @export + * @interface UpdateWorkflowRequest + */ +export interface UpdateWorkflowRequest { + /** + * Authorの内部ID + * @type {number} + * @memberof UpdateWorkflowRequest + */ + 'authorId': number; + /** + * Worktypeの内部ID + * @type {number} + * @memberof UpdateWorkflowRequest + */ + 'worktypeId'?: number; + /** + * テンプレートの内部ID + * @type {number} + * @memberof UpdateWorkflowRequest + */ + 'templateId'?: number; + /** + * ルーティング候補のタイピストユーザー/タイピストグループ + * @type {Array} + * @memberof UpdateWorkflowRequest + */ + 'typists': Array; +} /** * * @export @@ -2408,9 +2528,9 @@ export const AccountsApiAxiosParamCreator = function (configuration?: Configurat * @param {*} [options] Override http request option. * @throws {RequiredError} */ - deleteAccount: async (deleteAccountRequest: DeleteAccountRequest, options: AxiosRequestConfig = {}): Promise => { + deleteAccountAndData: async (deleteAccountRequest: DeleteAccountRequest, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'deleteAccountRequest' is not null or undefined - assertParamExists('deleteAccount', 'deleteAccountRequest', deleteAccountRequest) + assertParamExists('deleteAccountAndData', 'deleteAccountRequest', deleteAccountRequest) const localVarPath = `/accounts/delete`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -2441,6 +2561,80 @@ export const AccountsApiAxiosParamCreator = function (configuration?: Configurat options: localVarRequestOptions, }; }, + /** + * + * @summary + * @param {number} id Worktypeの内部ID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteWorktype: async (id: number, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('deleteWorktype', 'id', id) + const localVarPath = `/accounts/worktypes/{id}/delete` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // 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}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary + * @param {GetAccountInfoMinimalAccessRequest} getAccountInfoMinimalAccessRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAccountInfoMinimalAccess: async (getAccountInfoMinimalAccessRequest: GetAccountInfoMinimalAccessRequest, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'getAccountInfoMinimalAccessRequest' is not null or undefined + assertParamExists('getAccountInfoMinimalAccess', 'getAccountInfoMinimalAccessRequest', getAccountInfoMinimalAccessRequest) + const localVarPath = `/accounts/minimal-access`; + // 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; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(getAccountInfoMinimalAccessRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * ログインしているユーザーのアカウント配下のAuthor一覧を取得します * @summary @@ -3180,8 +3374,30 @@ export const AccountsApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async deleteAccount(deleteAccountRequest: DeleteAccountRequest, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAccount(deleteAccountRequest, options); + async deleteAccountAndData(deleteAccountRequest: DeleteAccountRequest, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAccountAndData(deleteAccountRequest, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @summary + * @param {number} id Worktypeの内部ID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteWorktype(id: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteWorktype(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @summary + * @param {GetAccountInfoMinimalAccessRequest} getAccountInfoMinimalAccessRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAccountInfoMinimalAccess(getAccountInfoMinimalAccessRequest: GetAccountInfoMinimalAccessRequest, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAccountInfoMinimalAccess(getAccountInfoMinimalAccessRequest, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -3446,8 +3662,28 @@ export const AccountsApiFactory = function (configuration?: Configuration, baseP * @param {*} [options] Override http request option. * @throws {RequiredError} */ - deleteAccount(deleteAccountRequest: DeleteAccountRequest, options?: any): AxiosPromise { - return localVarFp.deleteAccount(deleteAccountRequest, options).then((request) => request(axios, basePath)); + deleteAccountAndData(deleteAccountRequest: DeleteAccountRequest, options?: any): AxiosPromise { + return localVarFp.deleteAccountAndData(deleteAccountRequest, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary + * @param {number} id Worktypeの内部ID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteWorktype(id: number, options?: any): AxiosPromise { + return localVarFp.deleteWorktype(id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary + * @param {GetAccountInfoMinimalAccessRequest} getAccountInfoMinimalAccessRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAccountInfoMinimalAccess(getAccountInfoMinimalAccessRequest: GetAccountInfoMinimalAccessRequest, options?: any): AxiosPromise { + return localVarFp.getAccountInfoMinimalAccess(getAccountInfoMinimalAccessRequest, options).then((request) => request(axios, basePath)); }, /** * ログインしているユーザーのアカウント配下のAuthor一覧を取得します @@ -3707,8 +3943,32 @@ export class AccountsApi extends BaseAPI { * @throws {RequiredError} * @memberof AccountsApi */ - public deleteAccount(deleteAccountRequest: DeleteAccountRequest, options?: AxiosRequestConfig) { - return AccountsApiFp(this.configuration).deleteAccount(deleteAccountRequest, options).then((request) => request(this.axios, this.basePath)); + public deleteAccountAndData(deleteAccountRequest: DeleteAccountRequest, options?: AxiosRequestConfig) { + return AccountsApiFp(this.configuration).deleteAccountAndData(deleteAccountRequest, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary + * @param {number} id Worktypeの内部ID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AccountsApi + */ + public deleteWorktype(id: number, options?: AxiosRequestConfig) { + return AccountsApiFp(this.configuration).deleteWorktype(id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary + * @param {GetAccountInfoMinimalAccessRequest} getAccountInfoMinimalAccessRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AccountsApi + */ + public getAccountInfoMinimalAccess(getAccountInfoMinimalAccessRequest: GetAccountInfoMinimalAccessRequest, options?: AxiosRequestConfig) { + return AccountsApiFp(this.configuration).getAccountInfoMinimalAccess(getAccountInfoMinimalAccessRequest, options).then((request) => request(this.axios, this.basePath)); } /** @@ -5981,6 +6241,105 @@ export class TemplatesApi extends BaseAPI { +/** + * TermsApi - axios parameter creator + * @export + */ +export const TermsApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getTermsInfo: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/terms`; + // 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: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * TermsApi - functional programming interface + * @export + */ +export const TermsApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = TermsApiAxiosParamCreator(configuration) + return { + /** + * + * @summary + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getTermsInfo(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getTermsInfo(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * TermsApi - factory interface + * @export + */ +export const TermsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = TermsApiFp(configuration) + return { + /** + * + * @summary + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getTermsInfo(options?: any): AxiosPromise { + return localVarFp.getTermsInfo(options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * TermsApi - object-oriented interface + * @export + * @class TermsApi + * @extends {BaseAPI} + */ +export class TermsApi extends BaseAPI { + /** + * + * @summary + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof TermsApi + */ + public getTermsInfo(options?: AxiosRequestConfig) { + return TermsApiFp(this.configuration).getTermsInfo(options).then((request) => request(this.axios, this.basePath)); + } +} + + + /** * UsersApi - axios parameter creator * @export @@ -6281,6 +6640,42 @@ export const UsersApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * 利用規約同意バージョンを更新 + * @summary + * @param {UpdateAcceptedVersionRequest} updateAcceptedVersionRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateAcceptedVersion: async (updateAcceptedVersionRequest: UpdateAcceptedVersionRequest, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'updateAcceptedVersionRequest' is not null or undefined + assertParamExists('updateAcceptedVersion', 'updateAcceptedVersionRequest', updateAcceptedVersionRequest) + const localVarPath = `/users/accepted-version`; + // 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; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(updateAcceptedVersionRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * ログインしているユーザーのタスクソート条件を更新します * @summary @@ -6456,6 +6851,17 @@ export const UsersApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.signup(signupRequest, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * 利用規約同意バージョンを更新 + * @summary + * @param {UpdateAcceptedVersionRequest} updateAcceptedVersionRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateAcceptedVersion(updateAcceptedVersionRequest: UpdateAcceptedVersionRequest, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateAcceptedVersion(updateAcceptedVersionRequest, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * ログインしているユーザーのタスクソート条件を更新します * @summary @@ -6565,6 +6971,16 @@ export const UsersApiFactory = function (configuration?: Configuration, basePath signup(signupRequest: SignupRequest, options?: any): AxiosPromise { return localVarFp.signup(signupRequest, options).then((request) => request(axios, basePath)); }, + /** + * 利用規約同意バージョンを更新 + * @summary + * @param {UpdateAcceptedVersionRequest} updateAcceptedVersionRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateAcceptedVersion(updateAcceptedVersionRequest: UpdateAcceptedVersionRequest, options?: any): AxiosPromise { + return localVarFp.updateAcceptedVersion(updateAcceptedVersionRequest, options).then((request) => request(axios, basePath)); + }, /** * ログインしているユーザーのタスクソート条件を更新します * @summary @@ -6688,6 +7104,18 @@ export class UsersApi extends BaseAPI { return UsersApiFp(this.configuration).signup(signupRequest, options).then((request) => request(this.axios, this.basePath)); } + /** + * 利用規約同意バージョンを更新 + * @summary + * @param {UpdateAcceptedVersionRequest} updateAcceptedVersionRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof UsersApi + */ + public updateAcceptedVersion(updateAcceptedVersionRequest: UpdateAcceptedVersionRequest, options?: AxiosRequestConfig) { + return UsersApiFp(this.configuration).updateAcceptedVersion(updateAcceptedVersionRequest, options).then((request) => request(this.axios, this.basePath)); + } + /** * ログインしているユーザーのタスクソート条件を更新します * @summary @@ -6761,6 +7189,44 @@ export const WorkflowsApiAxiosParamCreator = function (configuration?: Configura options: localVarRequestOptions, }; }, + /** + * アカウント内のワークフローを削除します + * @summary + * @param {number} workflowId ワークフローの内部ID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteWorkflow: async (workflowId: number, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'workflowId' is not null or undefined + assertParamExists('deleteWorkflow', 'workflowId', workflowId) + const localVarPath = `/workflows/{workflowId}/delete` + .replace(`{${"workflowId"}}`, encodeURIComponent(String(workflowId))); + // 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}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * アカウント内のワークフローの一覧を取得します * @summary @@ -6790,6 +7256,50 @@ export const WorkflowsApiAxiosParamCreator = function (configuration?: Configura let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * アカウント内のワークフローを編集します + * @summary + * @param {number} workflowId ワークフローの内部ID + * @param {UpdateWorkflowRequest} updateWorkflowRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateWorkflow: async (workflowId: number, updateWorkflowRequest: UpdateWorkflowRequest, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'workflowId' is not null or undefined + assertParamExists('updateWorkflow', 'workflowId', workflowId) + // verify required parameter 'updateWorkflowRequest' is not null or undefined + assertParamExists('updateWorkflow', 'updateWorkflowRequest', updateWorkflowRequest) + const localVarPath = `/workflows/{workflowId}` + .replace(`{${"workflowId"}}`, encodeURIComponent(String(workflowId))); + // 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) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(updateWorkflowRequest, localVarRequestOptions, configuration) + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -6816,6 +7326,17 @@ export const WorkflowsApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.createWorkflows(createWorkflowsRequest, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * アカウント内のワークフローを削除します + * @summary + * @param {number} workflowId ワークフローの内部ID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteWorkflow(workflowId: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteWorkflow(workflowId, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * アカウント内のワークフローの一覧を取得します * @summary @@ -6826,6 +7347,18 @@ export const WorkflowsApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getWorkflows(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * アカウント内のワークフローを編集します + * @summary + * @param {number} workflowId ワークフローの内部ID + * @param {UpdateWorkflowRequest} updateWorkflowRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateWorkflow(workflowId: number, updateWorkflowRequest: UpdateWorkflowRequest, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateWorkflow(workflowId, updateWorkflowRequest, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, } }; @@ -6846,6 +7379,16 @@ export const WorkflowsApiFactory = function (configuration?: Configuration, base createWorkflows(createWorkflowsRequest: CreateWorkflowsRequest, options?: any): AxiosPromise { return localVarFp.createWorkflows(createWorkflowsRequest, options).then((request) => request(axios, basePath)); }, + /** + * アカウント内のワークフローを削除します + * @summary + * @param {number} workflowId ワークフローの内部ID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteWorkflow(workflowId: number, options?: any): AxiosPromise { + return localVarFp.deleteWorkflow(workflowId, options).then((request) => request(axios, basePath)); + }, /** * アカウント内のワークフローの一覧を取得します * @summary @@ -6855,6 +7398,17 @@ export const WorkflowsApiFactory = function (configuration?: Configuration, base getWorkflows(options?: any): AxiosPromise { return localVarFp.getWorkflows(options).then((request) => request(axios, basePath)); }, + /** + * アカウント内のワークフローを編集します + * @summary + * @param {number} workflowId ワークフローの内部ID + * @param {UpdateWorkflowRequest} updateWorkflowRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateWorkflow(workflowId: number, updateWorkflowRequest: UpdateWorkflowRequest, options?: any): AxiosPromise { + return localVarFp.updateWorkflow(workflowId, updateWorkflowRequest, options).then((request) => request(axios, basePath)); + }, }; }; @@ -6877,6 +7431,18 @@ export class WorkflowsApi extends BaseAPI { return WorkflowsApiFp(this.configuration).createWorkflows(createWorkflowsRequest, options).then((request) => request(this.axios, this.basePath)); } + /** + * アカウント内のワークフローを削除します + * @summary + * @param {number} workflowId ワークフローの内部ID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof WorkflowsApi + */ + public deleteWorkflow(workflowId: number, options?: AxiosRequestConfig) { + return WorkflowsApiFp(this.configuration).deleteWorkflow(workflowId, options).then((request) => request(this.axios, this.basePath)); + } + /** * アカウント内のワークフローの一覧を取得します * @summary @@ -6887,6 +7453,19 @@ export class WorkflowsApi extends BaseAPI { public getWorkflows(options?: AxiosRequestConfig) { return WorkflowsApiFp(this.configuration).getWorkflows(options).then((request) => request(this.axios, this.basePath)); } + + /** + * アカウント内のワークフローを編集します + * @summary + * @param {number} workflowId ワークフローの内部ID + * @param {UpdateWorkflowRequest} updateWorkflowRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof WorkflowsApi + */ + public updateWorkflow(workflowId: number, updateWorkflowRequest: UpdateWorkflowRequest, options?: AxiosRequestConfig) { + return WorkflowsApiFp(this.configuration).updateWorkflow(workflowId, updateWorkflowRequest, options).then((request) => request(this.axios, this.basePath)); + } } diff --git a/dictation_client/src/app/store.ts b/dictation_client/src/app/store.ts index e48a4ba..fe711ab 100644 --- a/dictation_client/src/app/store.ts +++ b/dictation_client/src/app/store.ts @@ -18,6 +18,7 @@ import worktype from "features/workflow/worktype/worktypeSlice"; import account from "features/account/accountSlice"; import template from "features/workflow/template/templateSlice"; import workflow from "features/workflow/workflowSlice"; +import terms from "features/terms/termsSlice"; export const store = configureStore({ reducer: { @@ -40,6 +41,7 @@ export const store = configureStore({ account, template, workflow, + terms, }, }); diff --git a/dictation_client/src/common/errors/code.ts b/dictation_client/src/common/errors/code.ts index 41368a5..559a39e 100644 --- a/dictation_client/src/common/errors/code.ts +++ b/dictation_client/src/common/errors/code.ts @@ -32,6 +32,7 @@ export const errorCodes = [ "E010206", // DBのTierが想定外の値エラー "E010207", // ユーザーのRole変更不可エラー "E010208", // ユーザーの暗号化パスワード不足エラー + "E010209", // ユーザーの同意済み利用規約バージョンが最新でないエラー "E010301", // メールアドレス登録済みエラー "E010302", // authorId重複エラー "E010401", // PONumber重複エラー @@ -55,5 +56,7 @@ export const errorCodes = [ "E011001", // ワークタイプ重複エラー "E011002", // ワークタイプ登録上限超過エラー "E011003", // ワークタイプ不在エラー + "E011004", // ワークタイプ使用中エラー "E013001", // ワークフローのAuthorIDとWorktypeIDのペア重複エラー + "E013002", // ワークフロー不在エラー ] as const; diff --git a/dictation_client/src/common/errors/utils.ts b/dictation_client/src/common/errors/utils.ts index 8f756ca..3dd2410 100644 --- a/dictation_client/src/common/errors/utils.ts +++ b/dictation_client/src/common/errors/utils.ts @@ -81,3 +81,21 @@ const isErrorResponse = (error: unknown): error is ErrorResponse => { const isErrorCode = (errorCode: string): errorCode is ErrorCodeType => errorCodes.includes(errorCode as ErrorCodeType); + +export const isErrorObject = ( + data: unknown +): data is { error: ErrorObject } => { + if ( + data && + typeof data === "object" && + "error" in data && + typeof (data as { error: ErrorObject }).error === "object" && + typeof (data as { error: ErrorObject }).error.message === "string" && + typeof (data as { error: ErrorObject }).error.code === "string" && + (typeof (data as { error: ErrorObject }).error.statusCode === "number" || + (data as { error: ErrorObject }).error.statusCode === undefined) + ) { + return true; + } + return false; +}; diff --git a/dictation_client/src/common/msalConfig.ts b/dictation_client/src/common/msalConfig.ts index e90e744..6a28910 100644 --- a/dictation_client/src/common/msalConfig.ts +++ b/dictation_client/src/common/msalConfig.ts @@ -5,7 +5,7 @@ export const msalConfig: Configuration = { clientId: import.meta.env.VITE_B2C_CLIENTID, authority: import.meta.env.VITE_B2C_AUTHORITY, knownAuthorities: [import.meta.env.VITE_B2C_KNOWNAUTHORITIES], - redirectUri: `${globalThis.location.origin}/login`, + redirectUri: `${globalThis.location.origin}/auth`, navigateToLoginRequestUrl: false, }, cache: { diff --git a/dictation_client/src/common/token.ts b/dictation_client/src/common/token.ts index 587a0ea..c41442f 100644 --- a/dictation_client/src/common/token.ts +++ b/dictation_client/src/common/token.ts @@ -62,3 +62,16 @@ export const isIdToken = (arg: any): arg is IdToken => { return true; }; + +export const getIdTokenFromLocalStorage = ( + localStorageKeyforIdToken: string +): string | null => { + const idTokenString = localStorage.getItem(localStorageKeyforIdToken); + if (idTokenString) { + const idTokenObject = JSON.parse(idTokenString); + if (isIdToken(idTokenObject)) { + return idTokenObject.secret; + } + } + return null; +}; diff --git a/dictation_client/src/components/auth/constants.ts b/dictation_client/src/components/auth/constants.ts index d000d87..ee22851 100644 --- a/dictation_client/src/components/auth/constants.ts +++ b/dictation_client/src/components/auth/constants.ts @@ -28,3 +28,9 @@ export const TIERS = { TIER4: "4", TIER5: "5", } as const; + +/** + * 401エラー時にログアウトさせずに処理を継続するエラーコード + * @const {string[]} + */ +export const UNAUTHORIZED_TO_CONTINUE_ERROR_CODES = ["E010209"]; diff --git a/dictation_client/src/features/account/operations.ts b/dictation_client/src/features/account/operations.ts index 03b242f..292680b 100644 --- a/dictation_client/src/features/account/operations.ts +++ b/dictation_client/src/features/account/operations.ts @@ -130,7 +130,7 @@ export const deleteAccountAsync = createAsyncThunk< const accountApi = new AccountsApi(config); try { - await accountApi.deleteAccount(deleteAccounRequest, { + await accountApi.deleteAccountAndData(deleteAccounRequest, { headers: { authorization: `Bearer ${accessToken}` }, }); diff --git a/dictation_client/src/features/login/loginSlice.ts b/dictation_client/src/features/login/loginSlice.ts index 322a3ce..312bae1 100644 --- a/dictation_client/src/features/login/loginSlice.ts +++ b/dictation_client/src/features/login/loginSlice.ts @@ -1,17 +1,26 @@ -import { createSlice } from "@reduxjs/toolkit"; +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { LoginState } from "./state"; import { loginAsync } from "./operations"; const initialState: LoginState = { apps: { LoginApiCallStatus: "none", + localStorageKeyforIdToken: null, }, }; export const loginSlice = createSlice({ name: "login", initialState, - reducers: {}, + reducers: { + changeLocalStorageKeyforIdToken: ( + state, + action: PayloadAction<{ localStorageKeyforIdToken: string }> + ) => { + const { localStorageKeyforIdToken } = action.payload; + state.apps.localStorageKeyforIdToken = localStorageKeyforIdToken; + }, + }, extraReducers: (builder) => { builder.addCase(loginAsync.pending, (state) => { state.apps.LoginApiCallStatus = "pending"; @@ -25,4 +34,5 @@ export const loginSlice = createSlice({ }, }); +export const { changeLocalStorageKeyforIdToken } = loginSlice.actions; export default loginSlice.reducer; diff --git a/dictation_client/src/features/login/operations.ts b/dictation_client/src/features/login/operations.ts index 61ddece..0ac9edd 100644 --- a/dictation_client/src/features/login/operations.ts +++ b/dictation_client/src/features/login/operations.ts @@ -3,6 +3,7 @@ import type { RootState } from "app/store"; import { setToken } from "features/auth/authSlice"; import { AuthApi } from "../../api/api"; import { Configuration } from "../../api/configuration"; +import { ErrorObject, createErrorObject } from "../../common/errors"; export const loginAsync = createAsyncThunk< { @@ -14,7 +15,7 @@ export const loginAsync = createAsyncThunk< { // rejectした時の返却値の型 rejectValue: { - /* Empty Object */ + error: ErrorObject; }; } >("login/loginAsync", async (args, thunkApi) => { @@ -41,6 +42,8 @@ export const loginAsync = createAsyncThunk< return {}; } catch (e) { - return thunkApi.rejectWithValue({}); + // e ⇒ errorObjectに変換" + const error = createErrorObject(e); + return thunkApi.rejectWithValue({ error }); } }); diff --git a/dictation_client/src/features/login/selectors.ts b/dictation_client/src/features/login/selectors.ts index 9615b35..d0ded8e 100644 --- a/dictation_client/src/features/login/selectors.ts +++ b/dictation_client/src/features/login/selectors.ts @@ -4,3 +4,7 @@ export const selectLoginApiCallStatus = ( state: RootState ): "fulfilled" | "rejected" | "none" | "pending" => state.login.apps.LoginApiCallStatus; + +export const selectLocalStorageKeyforIdToken = ( + state: RootState +): string | null => state.login.apps.localStorageKeyforIdToken; diff --git a/dictation_client/src/features/login/state.ts b/dictation_client/src/features/login/state.ts index 98a61ad..98fa599 100644 --- a/dictation_client/src/features/login/state.ts +++ b/dictation_client/src/features/login/state.ts @@ -4,4 +4,5 @@ export interface LoginState { export interface Apps { LoginApiCallStatus: "fulfilled" | "rejected" | "none" | "pending"; + localStorageKeyforIdToken: string | null; } diff --git a/dictation_client/src/features/signup/operations.ts b/dictation_client/src/features/signup/operations.ts index 67e505e..2557b0d 100644 --- a/dictation_client/src/features/signup/operations.ts +++ b/dictation_client/src/features/signup/operations.ts @@ -3,10 +3,12 @@ import type { RootState } from "app/store"; import { ErrorObject, createErrorObject } from "common/errors"; import { getTranslationID } from "translation"; import { closeSnackbar, openSnackbar } from "features/ui/uiSlice"; +import { TERMS_DOCUMENT_TYPE } from "features/terms/constants"; import { AccountsApi, CreateAccountRequest, GetDealersResponse, + TermsApi, } from "../../api/api"; import { Configuration } from "../../api/configuration"; @@ -93,3 +95,42 @@ export const getDealersAsync = createAsyncThunk< return thunkApi.rejectWithValue({ error }); } }); + +export const getLatestEulaVersionAsync = createAsyncThunk< + string, + void, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("login/getLatestEulaVersionAsync", async (args, thunkApi) => { + // apiのConfigurationを取得する + const { getState } = thunkApi; + const state = getState() as RootState; + const { configuration } = state.auth; + const config = new Configuration(configuration); + const termsApi = new TermsApi(config); + + try { + const termsInfo = await termsApi.getTermsInfo(); + const latestEulaVersion = termsInfo.data.termsInfo.find( + (val) => val.documentType === TERMS_DOCUMENT_TYPE.EULA + ); + if (!latestEulaVersion) { + throw new Error("EULA info is not found"); + } + return latestEulaVersion.version; + } catch (e) { + const error = createErrorObject(e); + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("common.message.internalServerError"), + }) + ); + + return thunkApi.rejectWithValue({ error }); + } +}); diff --git a/dictation_client/src/features/signup/selectors.ts b/dictation_client/src/features/signup/selectors.ts index 87c3118..823347f 100644 --- a/dictation_client/src/features/signup/selectors.ts +++ b/dictation_client/src/features/signup/selectors.ts @@ -72,3 +72,6 @@ export const selectSelectedDealer = (state: RootState) => { const { dealer } = state.signup.apps; return dealers.find((x: Dealer) => x.id === dealer); }; + +export const selectEulaVersion = (state: RootState) => + state.signup.domain.eulaVersion; diff --git a/dictation_client/src/features/signup/signupSlice.ts b/dictation_client/src/features/signup/signupSlice.ts index 5b03e63..0f6bd83 100644 --- a/dictation_client/src/features/signup/signupSlice.ts +++ b/dictation_client/src/features/signup/signupSlice.ts @@ -1,6 +1,10 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { SignupState } from "./state"; -import { getDealersAsync, signupAsync } from "./operations"; +import { + getDealersAsync, + getLatestEulaVersionAsync, + signupAsync, +} from "./operations"; const initialState: SignupState = { apps: { @@ -15,6 +19,7 @@ const initialState: SignupState = { }, domain: { dealers: [], + eulaVersion: "", }, }; @@ -74,6 +79,15 @@ export const signupSlice = createSlice({ builder.addCase(getDealersAsync.rejected, () => { // }); + builder.addCase(getLatestEulaVersionAsync.pending, () => { + // + }); + builder.addCase(getLatestEulaVersionAsync.fulfilled, (state, action) => { + state.domain.eulaVersion = action.payload; + }); + builder.addCase(getLatestEulaVersionAsync.rejected, () => { + // + }); }, }); export const { diff --git a/dictation_client/src/features/signup/state.ts b/dictation_client/src/features/signup/state.ts index 164ab04..36850ad 100644 --- a/dictation_client/src/features/signup/state.ts +++ b/dictation_client/src/features/signup/state.ts @@ -18,4 +18,5 @@ export interface Apps { export interface Domain { dealers: Dealer[]; + eulaVersion: string; } diff --git a/dictation_client/src/features/terms/constants.ts b/dictation_client/src/features/terms/constants.ts new file mode 100644 index 0000000..dd78e43 --- /dev/null +++ b/dictation_client/src/features/terms/constants.ts @@ -0,0 +1,8 @@ +/** + * 利用規約の種類 + * @const {string[]} + */ +export const TERMS_DOCUMENT_TYPE = { + DPA: "DPA", + EULA: "EULA", +} as const; diff --git a/dictation_client/src/features/terms/index.ts b/dictation_client/src/features/terms/index.ts new file mode 100644 index 0000000..8692ec6 --- /dev/null +++ b/dictation_client/src/features/terms/index.ts @@ -0,0 +1,4 @@ +export * from "./termsSlice"; +export * from "./state"; +export * from "./operations"; +export * from "./selectors"; diff --git a/dictation_client/src/features/terms/operations.ts b/dictation_client/src/features/terms/operations.ts new file mode 100644 index 0000000..511dfe9 --- /dev/null +++ b/dictation_client/src/features/terms/operations.ts @@ -0,0 +1,158 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; +import type { RootState } from "app/store"; +import { ErrorObject, createErrorObject } from "common/errors"; +import { getTranslationID } from "translation"; +import { openSnackbar } from "features/ui/uiSlice"; +import { getIdTokenFromLocalStorage } from "common/token"; +import { TIERS } from "components/auth/constants"; +import { + UsersApi, + GetAccountInfoMinimalAccessResponse, + AccountsApi, + TermsApi, + GetTermsInfoResponse, +} from "../../api/api"; +import { Configuration } from "../../api/configuration"; + +export const getAccountInfoMinimalAccessAsync = createAsyncThunk< + GetAccountInfoMinimalAccessResponse, + { + localStorageKeyforIdToken: string; + }, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("accept/getAccountInfoMinimalAccessAsync", async (args, thunkApi) => { + const { localStorageKeyforIdToken } = args; + // apiのConfigurationを取得する + const { getState } = thunkApi; + const state = getState() as RootState; + const { configuration, accessToken } = state.auth; + const config = new Configuration(configuration); + const accountApi = new AccountsApi(config); + + try { + // IDトークンの取得 + const idToken = getIdTokenFromLocalStorage(localStorageKeyforIdToken); + + // IDトークンが取得できない場合エラーとする + if (!idToken) { + throw new Error("Unable to retrieve the ID token."); + } + const res = await accountApi.getAccountInfoMinimalAccess( + { idToken }, + { + headers: { authorization: `Bearer ${accessToken}` }, + } + ); + return res.data; + } catch (e) { + const error = createErrorObject(e); + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("common.message.internalServerError"), + }) + ); + return thunkApi.rejectWithValue({ error }); + } +}); + +export const getTermsInfoAsync = createAsyncThunk< + GetTermsInfoResponse, + void, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("accept/getTermsInfoAsync", async (_args, thunkApi) => { + // apiのConfigurationを取得する + const { getState } = thunkApi; + const state = getState() as RootState; + const { configuration, accessToken } = state.auth; + const config = new Configuration(configuration); + const termsApi = new TermsApi(config); + + try { + const termsInfo = await termsApi.getTermsInfo({ + headers: { authorization: `Bearer ${accessToken}` }, + }); + + return termsInfo.data; + } catch (e) { + // e ⇒ errorObjectに変換" + const error = createErrorObject(e); + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("common.message.internalServerError"), + }) + ); + return thunkApi.rejectWithValue({ error }); + } +}); + +export const updateAcceptedVersionAsync = createAsyncThunk< + { + /* Empty Object */ + }, + { + tier: number; + localStorageKeyforIdToken: string; + updateAccceptVersions: { + acceptedVerDPA: string; + acceptedVerEULA: string; + }; + }, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("accept/UpdateAcceptedVersionAsync", async (args, thunkApi) => { + const { tier, localStorageKeyforIdToken, updateAccceptVersions } = args; + // apiのConfigurationを取得する + const { getState } = thunkApi; + const state = getState() as RootState; + const { configuration, accessToken } = state.auth; + const config = new Configuration(configuration); + const userApi = new UsersApi(config); + + try { + // IDトークンの取得 + const idToken = getIdTokenFromLocalStorage(localStorageKeyforIdToken); + + // IDトークンが取得できない場合エラーとする + if (!idToken) { + throw new Error("Unable to retrieve the ID token."); + } + await userApi.updateAcceptedVersion( + { + idToken, + acceptedEULAVersion: updateAccceptVersions.acceptedVerEULA, + acceptedDPAVersion: !(TIERS.TIER5 === tier.toString()) + ? updateAccceptVersions.acceptedVerDPA + : undefined, + }, + { + headers: { authorization: `Bearer ${accessToken}` }, + } + ); + return {}; + } catch (e) { + const error = createErrorObject(e); + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("common.message.internalServerError"), + }) + ); + return thunkApi.rejectWithValue({ error }); + } +}); diff --git a/dictation_client/src/features/terms/selectors.ts b/dictation_client/src/features/terms/selectors.ts new file mode 100644 index 0000000..5cd00f7 --- /dev/null +++ b/dictation_client/src/features/terms/selectors.ts @@ -0,0 +1,20 @@ +import { RootState } from "app/store"; +import { TERMS_DOCUMENT_TYPE } from "features/terms/constants"; + +export const selectTermVersions = (state: RootState) => { + const { termsInfo } = state.terms.domain; + + const acceptedVerDPA = + termsInfo.find( + (termInfo) => termInfo.documentType === TERMS_DOCUMENT_TYPE.DPA + )?.version || ""; + + const acceptedVerEULA = + termsInfo.find( + (termInfo) => termInfo.documentType === TERMS_DOCUMENT_TYPE.EULA + )?.version || ""; + + return { acceptedVerDPA, acceptedVerEULA }; +}; + +export const selectTier = (state: RootState) => state.terms.domain.tier; diff --git a/dictation_client/src/features/terms/state.ts b/dictation_client/src/features/terms/state.ts new file mode 100644 index 0000000..0c724dc --- /dev/null +++ b/dictation_client/src/features/terms/state.ts @@ -0,0 +1,15 @@ +import { TermInfo } from "../../api/api"; + +export interface AcceptState { + domain: Domain; + apps: Apps; +} + +export interface Domain { + tier: number; + termsInfo: TermInfo[]; +} + +export interface Apps { + isLoading: boolean; +} diff --git a/dictation_client/src/features/terms/termsSlice.ts b/dictation_client/src/features/terms/termsSlice.ts new file mode 100644 index 0000000..7f88271 --- /dev/null +++ b/dictation_client/src/features/terms/termsSlice.ts @@ -0,0 +1,64 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { AcceptState } from "./state"; +import { + getAccountInfoMinimalAccessAsync, + getTermsInfoAsync, + updateAcceptedVersionAsync, +} from "./operations"; + +const initialState: AcceptState = { + domain: { + tier: 0, + termsInfo: [ + { + documentType: "", + version: "", + }, + ], + }, + apps: { + isLoading: false, + }, +}; + +export const termsSlice = createSlice({ + name: "terms", + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(getAccountInfoMinimalAccessAsync.pending, (state) => { + state.apps.isLoading = true; + }); + builder.addCase( + getAccountInfoMinimalAccessAsync.fulfilled, + (state, actions) => { + state.apps.isLoading = false; + state.domain.tier = actions.payload.tier; + } + ); + builder.addCase(getAccountInfoMinimalAccessAsync.rejected, (state) => { + state.apps.isLoading = false; + }); + builder.addCase(getTermsInfoAsync.pending, (state) => { + state.apps.isLoading = true; + }); + builder.addCase(getTermsInfoAsync.fulfilled, (state, actions) => { + state.apps.isLoading = false; + state.domain.termsInfo = actions.payload.termsInfo; + }); + builder.addCase(getTermsInfoAsync.rejected, (state) => { + state.apps.isLoading = false; + }); + builder.addCase(updateAcceptedVersionAsync.pending, (state) => { + state.apps.isLoading = true; + }); + builder.addCase(updateAcceptedVersionAsync.fulfilled, (state) => { + state.apps.isLoading = false; + }); + builder.addCase(updateAcceptedVersionAsync.rejected, (state) => { + state.apps.isLoading = false; + }); + }, +}); + +export default termsSlice.reducer; diff --git a/dictation_client/src/features/user/userSlice.ts b/dictation_client/src/features/user/userSlice.ts index 6d64137..52ffbc4 100644 --- a/dictation_client/src/features/user/userSlice.ts +++ b/dictation_client/src/features/user/userSlice.ts @@ -87,7 +87,7 @@ export const userSlice = createSlice({ action: PayloadAction<{ authorId: string | undefined }> ) => { const { authorId } = action.payload; - state.apps.addUser.authorId = authorId; + state.apps.addUser.authorId = authorId?.toUpperCase(); }, changeAutoRenew: (state, action: PayloadAction<{ autoRenew: boolean }>) => { const { autoRenew } = action.payload; @@ -144,7 +144,7 @@ export const userSlice = createSlice({ state.apps.updateUser.name = user.name; state.apps.updateUser.email = user.email; state.apps.updateUser.role = user.role as RoleType; - state.apps.updateUser.authorId = user.authorId; + state.apps.updateUser.authorId = user.authorId?.toUpperCase(); state.apps.updateUser.encryption = user.encryption; state.apps.updateUser.encryptionPassword = undefined; state.apps.updateUser.prompt = user.prompt; @@ -156,7 +156,7 @@ export const userSlice = createSlice({ state.apps.selectedUser.name = user.name; state.apps.selectedUser.email = user.email; state.apps.selectedUser.role = user.role as RoleType; - state.apps.selectedUser.authorId = user.authorId; + state.apps.selectedUser.authorId = user.authorId?.toUpperCase(); state.apps.selectedUser.encryption = user.encryption; state.apps.selectedUser.encryptionPassword = undefined; state.apps.selectedUser.prompt = user.prompt; @@ -175,7 +175,7 @@ export const userSlice = createSlice({ action: PayloadAction<{ authorId: string }> ) => { const { authorId } = action.payload; - state.apps.updateUser.authorId = authorId; + state.apps.updateUser.authorId = authorId.toUpperCase(); }, changeUpdateEncryption: ( state, @@ -243,7 +243,8 @@ export const userSlice = createSlice({ state.apps.licenseAllocateUser.id = selectedUser.id; state.apps.licenseAllocateUser.name = selectedUser.name; state.apps.licenseAllocateUser.email = selectedUser.email; - state.apps.licenseAllocateUser.authorId = selectedUser.authorId; + state.apps.licenseAllocateUser.authorId = + selectedUser.authorId.toUpperCase(); state.apps.licenseAllocateUser.licenseStatus = selectedUser.licenseStatus; state.apps.licenseAllocateUser.expiration = selectedUser.expiration; state.apps.licenseAllocateUser.remaining = selectedUser.remaining; diff --git a/dictation_client/src/features/workflow/operations.ts b/dictation_client/src/features/workflow/operations.ts index 76e84a5..01a9815 100644 --- a/dictation_client/src/features/workflow/operations.ts +++ b/dictation_client/src/features/workflow/operations.ts @@ -16,7 +16,6 @@ import type { RootState } from "app/store"; import { ErrorObject, createErrorObject } from "common/errors"; import { openSnackbar } from "features/ui/uiSlice"; import { getTranslationID } from "translation"; -import { WorkflowRelations } from "./state"; export const listWorkflowAsync = createAsyncThunk< GetWorkflowsResponse, @@ -141,6 +140,105 @@ export const createWorkflowAsync = createAsyncThunk< } }); +export const updateWorkflowAsync = createAsyncThunk< + { + /* Empty Object */ + }, + void, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("workflow/updateWorkflowAsync", async (args, thunkApi) => { + // apiのConfigurationを取得する + const { getState } = thunkApi; + const state = getState() as RootState; + const { configuration, accessToken } = state.auth; + const config = new Configuration(configuration); + const workflowsApi = new WorkflowsApi(config); + const { + selectedWorkflow, + selectedAssignees, + authorId, + templateId, + worktypeId, + } = state.workflow.apps; + + try { + if (selectedWorkflow === undefined) { + throw new Error("selectedWorkflow is not found"); + } + + if (authorId === undefined) { + throw new Error("authorId is not found"); + } + // 選択されたタイピストを取得し、リクエスト用の型に変換する + const typists = selectedAssignees.map( + (item): WorkflowTypist => ({ + typistId: item.typistUserId, + typistGroupId: item.typistGroupId, + }) + ); + await workflowsApi.updateWorkflow( + selectedWorkflow.id, + { + authorId, + typists, + templateId, + worktypeId, + }, + { + headers: { authorization: `Bearer ${accessToken}` }, + } + ); + thunkApi.dispatch( + openSnackbar({ + level: "info", + message: getTranslationID("common.message.success"), + }) + ); + return {}; + } catch (e) { + // e ⇒ errorObjectに変換" + const error = createErrorObject(e); + const { code, statusCode } = error; + + if (statusCode === 400) { + // AuthorIDとWorktypeIDが一致するものが既に存在する場合 + if (code === "E013001") { + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID( + "workflowPage.message.workflowConflictError" + ), + }) + ); + return thunkApi.rejectWithValue({ error }); + } + + // パラメータが存在しない場合 + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("workflowPage.message.saveFailedError"), + }) + ); + return thunkApi.rejectWithValue({ error }); + } + // その他のエラー + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("common.message.internalServerError"), + }) + ); + return thunkApi.rejectWithValue({ error }); + } +}); + export const getworkflowRelationsAsync = createAsyncThunk< { authors: Author[]; @@ -211,3 +309,60 @@ export const getworkflowRelationsAsync = createAsyncThunk< return thunkApi.rejectWithValue({ error }); } }); + +export const deleteWorkflowAsync = createAsyncThunk< + { + /* Empty Object */ + }, + { workflowId: number }, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("workflow/deleteWorkflowAsync", async (args, thunkApi) => { + const { workflowId } = args; + // apiのConfigurationを取得する + const { getState } = thunkApi; + const state = getState() as RootState; + const { configuration, accessToken } = state.auth; + const config = new Configuration(configuration); + const workflowsApi = new WorkflowsApi(config); + + try { + await workflowsApi.deleteWorkflow(workflowId, { + headers: { authorization: `Bearer ${accessToken}` }, + }); + + thunkApi.dispatch( + openSnackbar({ + level: "info", + message: getTranslationID("common.message.success"), + }) + ); + return {}; + } catch (e) { + // e ⇒ errorObjectに変換" + const error = createErrorObject(e); + + // ワークフローが削除済みの場合は成功扱いとする + if (error.code === "E013002") { + thunkApi.dispatch( + openSnackbar({ + level: "info", + message: getTranslationID("common.message.success"), + }) + ); + return {}; + } + + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("common.message.internalServerError"), + }) + ); + return thunkApi.rejectWithValue({ error }); + } +}); diff --git a/dictation_client/src/features/workflow/selectors.ts b/dictation_client/src/features/workflow/selectors.ts index a71ba4a..685d81e 100644 --- a/dictation_client/src/features/workflow/selectors.ts +++ b/dictation_client/src/features/workflow/selectors.ts @@ -36,6 +36,15 @@ export const selectWorkflowAssinee = (state: RootState) => { ), }; }; +export const selectSelectedWorkflow = (state: RootState) => + state.workflow.apps.selectedWorkflow; +export const selectAuthorId = (state: RootState) => + state.workflow.apps.authorId; +export const selectWorktypeId = (state: RootState) => + state.workflow.apps.worktypeId; +export const selectTemplateId = (state: RootState) => + state.workflow.apps.templateId; + export const selectIsAddLoading = (state: RootState) => state.workflow.apps.isAddLoading; diff --git a/dictation_client/src/features/workflow/state.ts b/dictation_client/src/features/workflow/state.ts index e99a186..6bb892a 100644 --- a/dictation_client/src/features/workflow/state.ts +++ b/dictation_client/src/features/workflow/state.ts @@ -12,6 +12,7 @@ export interface Apps { authorId?: number; worktypeId?: number; templateId?: number; + selectedWorkflow?: Workflow; } export interface Domain { diff --git a/dictation_client/src/features/workflow/workflowSlice.ts b/dictation_client/src/features/workflow/workflowSlice.ts index bb6aa9f..737f5cf 100644 --- a/dictation_client/src/features/workflow/workflowSlice.ts +++ b/dictation_client/src/features/workflow/workflowSlice.ts @@ -3,7 +3,9 @@ import { Assignee } from "api"; import { createWorkflowAsync, getworkflowRelationsAsync, + deleteWorkflowAsync, listWorkflowAsync, + updateWorkflowAsync, } from "./operations"; import { WorkflowState } from "./state"; @@ -21,12 +23,17 @@ export const workflowSlice = createSlice({ initialState, reducers: { clearWorkflow: (state) => { + state.apps.selectedWorkflow = undefined; state.apps.selectedAssignees = []; state.apps.authorId = undefined; state.apps.worktypeId = undefined; state.apps.templateId = undefined; state.domain.workflowRelations = undefined; }, + setAssignees: (state, action: PayloadAction<{ assignees: Assignee[] }>) => { + const { assignees } = action.payload; + state.apps.selectedAssignees = assignees; + }, addAssignee: (state, action: PayloadAction<{ assignee: Assignee }>) => { const { assignee } = action.payload; const { selectedAssignees } = state.apps; @@ -55,18 +62,35 @@ export const workflowSlice = createSlice({ x.typistGroupId !== assignee.typistGroupId ); }, - changeAuthor: (state, action: PayloadAction<{ authorId: number }>) => { + changeAuthor: ( + state, + action: PayloadAction<{ authorId?: number | undefined }> + ) => { const { authorId } = action.payload; state.apps.authorId = authorId; }, - changeWorktype: (state, action: PayloadAction<{ worktypeId?: number }>) => { + changeWorktype: ( + state, + action: PayloadAction<{ worktypeId?: number | undefined }> + ) => { const { worktypeId } = action.payload; state.apps.worktypeId = worktypeId; }, - changeTemplate: (state, action: PayloadAction<{ templateId?: number }>) => { + changeTemplate: ( + state, + action: PayloadAction<{ templateId?: number | undefined }> + ) => { const { templateId } = action.payload; state.apps.templateId = templateId; }, + changeSelectedWorkflow: ( + state, + action: PayloadAction<{ workflowId: number }> + ) => { + const { workflowId } = action.payload; + const workflow = state.domain.workflows?.find((x) => x.id === workflowId); + state.apps.selectedWorkflow = workflow; + }, }, extraReducers: (builder) => { builder.addCase(listWorkflowAsync.pending, (state) => { @@ -127,15 +151,35 @@ export const workflowSlice = createSlice({ builder.addCase(createWorkflowAsync.rejected, (state) => { state.apps.isAddLoading = false; }); + builder.addCase(updateWorkflowAsync.pending, (state) => { + state.apps.isAddLoading = true; + }); + builder.addCase(updateWorkflowAsync.fulfilled, (state) => { + state.apps.isAddLoading = false; + }); + builder.addCase(updateWorkflowAsync.rejected, (state) => { + state.apps.isAddLoading = false; + }); + builder.addCase(deleteWorkflowAsync.pending, (state) => { + state.apps.isLoading = true; + }); + builder.addCase(deleteWorkflowAsync.fulfilled, (state) => { + state.apps.isLoading = false; + }); + builder.addCase(deleteWorkflowAsync.rejected, (state) => { + state.apps.isLoading = false; + }); }, }); export const { + setAssignees, addAssignee, removeAssignee, changeAuthor, changeWorktype, changeTemplate, + changeSelectedWorkflow, clearWorkflow, } = workflowSlice.actions; diff --git a/dictation_client/src/features/workflow/worktype/operations.ts b/dictation_client/src/features/workflow/worktype/operations.ts index 0e45d33..fa173c1 100644 --- a/dictation_client/src/features/workflow/worktype/operations.ts +++ b/dictation_client/src/features/workflow/worktype/operations.ts @@ -342,3 +342,75 @@ export const updateActiveWorktypeAsync = createAsyncThunk< return thunkApi.rejectWithValue({ error }); } }); + +export const deleteWorktypeAsync = createAsyncThunk< + { + /* Empty Object */ + }, + { worktypeId: number }, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("workflow/deleteWorktypeAsync", async (args, thunkApi) => { + const { worktypeId } = args; + // apiのConfigurationを取得する + const { getState } = thunkApi; + const state = getState() as RootState; + const { configuration, accessToken } = state.auth; + const config = new Configuration(configuration); + const accountsApi = new AccountsApi(config); + + try { + await accountsApi.deleteWorktype(worktypeId, { + headers: { authorization: `Bearer ${accessToken}` }, + }); + + thunkApi.dispatch( + openSnackbar({ + level: "info", + message: getTranslationID("common.message.success"), + }) + ); + return {}; + } catch (e) { + // e ⇒ errorObjectに変換" + const error = createErrorObject(e); + + if (error.statusCode === 400) { + if (error.code === "E011003") { + // ワークタイプが削除済みの場合は成功扱いとする + thunkApi.dispatch( + openSnackbar({ + level: "info", + message: getTranslationID("common.message.success"), + }) + ); + return {}; + } + + if (error.code === "E011004") { + // ワークタイプがワークフローで使用中の場合は削除できない + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID( + "worktypeIdSetting.message.worktypeInUseError" + ), + }) + ); + return {}; + } + } + + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("common.message.internalServerError"), + }) + ); + return thunkApi.rejectWithValue({ error }); + } +}); diff --git a/dictation_client/src/pages/AccountPage/index.tsx b/dictation_client/src/pages/AccountPage/index.tsx index d40d452..d136510 100644 --- a/dictation_client/src/pages/AccountPage/index.tsx +++ b/dictation_client/src/pages/AccountPage/index.tsx @@ -149,7 +149,8 @@ const AccountPage: React.FC = (): JSX.Element => { {isTier5 && !viewInfo.account.parentAccountName && (
{ dispatch( changePrimaryAdministrator({ @@ -303,8 +304,8 @@ const AccountPage: React.FC = (): JSX.Element => {
{ + const [t] = useTranslation(); + const dispatch: AppDispatch = useDispatch(); + const navigate = useNavigate(); + const updateAccceptVersions = useSelector(selectTermVersions); + const localStorageKeyforIdToken = useSelector( + selectLocalStorageKeyforIdToken + ); + const tier = useSelector(selectTier); + + const [isCheckedEula, setIsCheckedEula] = useState(false); + const [isCheckedDpa, setIsCheckedDpa] = useState(false); + + const [isClickedEulaLink, setIsClickedEulaLink] = useState(false); + const [isClickedDpaLink, setIsClickedDpaLink] = useState(false); + + // 画面起動時 + useEffect(() => { + dispatch(getTermsInfoAsync()); + if (localStorageKeyforIdToken) { + dispatch(getAccountInfoMinimalAccessAsync({ localStorageKeyforIdToken })); + } else { + // ログイン画面を経由していないため、トップページに遷移する + navigate("/"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ユーザーが第5階層であるかどうかを判定する(アクセストークンから自分の階層を取得できないので自前で作成) + const isTier5 = () => TIERS.TIER5.includes(tier.toString()); + + // ボタン押下可否判定ロジック + const canClickButton = () => { + if (isTier5()) { + return isCheckedEula; + } + return isCheckedEula && isCheckedDpa; + }; + + // ボタン押下時処理 + const onAcceptTermsOfUse = useCallback(async () => { + if ( + localStorageKeyforIdToken && + updateAccceptVersions.acceptedVerDPA !== "" && + updateAccceptVersions.acceptedVerEULA !== "" + ) { + const { meta } = await dispatch( + updateAcceptedVersionAsync({ + tier, + localStorageKeyforIdToken, + updateAccceptVersions, + }) + ); + + // 同意済バージョンが更新できたら、再度トークン生成を行う + if (meta.requestStatus === "fulfilled") { + navigate("/login"); + } + } + }, [ + navigate, + localStorageKeyforIdToken, + updateAccceptVersions, + tier, + dispatch, + ]); + + return ( +
+
+ +
+
+
+

+ {t(getTranslationID("termsPage.label.title"))} +

+
+
+
+
+
+ +
+

+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} + setIsClickedEulaLink(true)} + > + {t(getTranslationID("termsPage.label.linkOfEula"))} + + {` ${t(getTranslationID("termsPage.label.forOdds"))}`} +

+

+ +

+
+ {/* 第五階層以外の場合はEulaのリンクをあわせて表示する */} + {!isTier5() && ( +
+

+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} + setIsClickedDpaLink(true)} + > + {t(getTranslationID("termsPage.label.linkOfDpa"))} + + {` ${t(getTranslationID("termsPage.label.forOdds"))}`} +

+

+ +

+
+ )} +
+

+ +

+
+
+
+
+
+
+
+
+ ); +}; + +export default TermsPage; diff --git a/dictation_client/src/pages/UserListPage/popup.tsx b/dictation_client/src/pages/UserListPage/popup.tsx index 35c8f0f..50ed98f 100644 --- a/dictation_client/src/pages/UserListPage/popup.tsx +++ b/dictation_client/src/pages/UserListPage/popup.tsx @@ -200,7 +200,11 @@ export const UserAddPopup: React.FC = (props) => { className={styles.formInput} value={addUser.authorId ?? undefined} onChange={(e) => { - dispatch(changeAuthorId({ authorId: e.target.value })); + dispatch( + changeAuthorId({ + authorId: e.target.value.toUpperCase(), + }) + ); }} /> {isPushCreateButton && hasErrorEmptyAuthorId && ( diff --git a/dictation_client/src/pages/UserListPage/updatePopup.tsx b/dictation_client/src/pages/UserListPage/updatePopup.tsx index 00ce988..fa5944e 100644 --- a/dictation_client/src/pages/UserListPage/updatePopup.tsx +++ b/dictation_client/src/pages/UserListPage/updatePopup.tsx @@ -184,7 +184,9 @@ export const UserUpdatePopup: React.FC = (props) => { className={styles.formInput} onChange={(e) => { dispatch( - changeUpdateAuthorId({ authorId: e.target.value }) + changeUpdateAuthorId({ + authorId: e.target.value.toUpperCase(), + }) ); }} /> diff --git a/dictation_client/src/pages/WorkTypeIdSettingPage/index.tsx b/dictation_client/src/pages/WorkTypeIdSettingPage/index.tsx index 2532f5a..0f4c03f 100644 --- a/dictation_client/src/pages/WorkTypeIdSettingPage/index.tsx +++ b/dictation_client/src/pages/WorkTypeIdSettingPage/index.tsx @@ -1,7 +1,7 @@ import { UpdateTokenTimer } from "components/auth/updateTokenTimer"; import Footer from "components/footer"; import Header from "components/header"; -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { getTranslationID } from "translation"; import styles from "styles/app.module.scss"; import undo from "assets/images/undo.svg"; @@ -18,6 +18,7 @@ import { selectIsLoading, selectWorktypes, selectActiveWorktypeId, + deleteWorktypeAsync, } from "features/workflow/worktype"; import { AppDispatch } from "app/store"; import { AddWorktypeIdPopup } from "./addWorktypeIdPopup"; @@ -86,6 +87,23 @@ const WorktypeIdSettingPage: React.FC = (): JSX.Element => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedActiveWorktypeId]); + // 削除ボタン押下時の処理 + const onDeleteWoktype = useCallback( + async (worktypeId: number) => { + if ( + /* eslint-disable-next-line no-alert */ + !window.confirm(t(getTranslationID("common.message.dialogConfirm"))) + ) { + return; + } + const { meta } = await dispatch(deleteWorktypeAsync({ worktypeId })); + if (meta.requestStatus === "fulfilled") { + dispatch(listWorktypesAsync()); + } + }, + [dispatch, t] + ); + return ( <> {
  • + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} onDeleteWoktype(worktype.id)} > {t(getTranslationID("common.label.delete"))} diff --git a/dictation_client/src/pages/WorkflowPage/editworkflowPopup.tsx b/dictation_client/src/pages/WorkflowPage/editworkflowPopup.tsx new file mode 100644 index 0000000..47f7054 --- /dev/null +++ b/dictation_client/src/pages/WorkflowPage/editworkflowPopup.tsx @@ -0,0 +1,313 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { AppDispatch } from "app/store"; +import progress_activit from "assets/images/progress_activit.svg"; +import { + addAssignee, + removeAssignee, + changeAuthor, + changeTemplate, + changeWorktype, + clearWorkflow, + selectIsAddLoading, + selectWorkflowAssinee, + selectWorkflowError, + selectWorkflowRelations, + selectSelectedWorkflow, + selectAuthorId, + selectWorktypeId, + setAssignees, + selectTemplateId, +} from "features/workflow"; +import { + getworkflowRelationsAsync, + listWorkflowAsync, + updateWorkflowAsync, +} from "features/workflow/operations"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import styles from "styles/app.module.scss"; +import { getTranslationID } from "translation"; +import close from "../../assets/images/close.svg"; + +interface EditWorkflowPopupProps { + onClose: () => void; +} + +export const EditWorkflowPopup: React.FC = ( + props +): JSX.Element => { + const { onClose } = props; + const dispatch: AppDispatch = useDispatch(); + const [t] = useTranslation(); + // 保存ボタンを押したかどうか + const [isPushEditButton, setIsPushEditButton] = useState(false); + + const workflow = useSelector(selectSelectedWorkflow); + const authorId = useSelector(selectAuthorId); + const worktypeId = useSelector(selectWorktypeId); + const templateId = useSelector(selectTemplateId); + + const workflowRelations = useSelector(selectWorkflowRelations); + const { poolAssignees, selectedAssignees } = useSelector( + selectWorkflowAssinee + ); + const isLoading = useSelector(selectIsAddLoading); + const { hasAuthorIdEmptyError, hasSelectedWorkflowAssineeEmptyError } = + useSelector(selectWorkflowError); + useEffect(() => { + dispatch(getworkflowRelationsAsync()); + // ポップアップのアンマウント時に初期化を行う + return () => { + dispatch(clearWorkflow()); + setIsPushEditButton(false); + }; + }, [dispatch]); + + useEffect(() => { + dispatch(changeAuthor({ authorId: workflow?.author?.id })); + dispatch(changeWorktype({ worktypeId: workflow?.worktype?.id })); + dispatch(changeTemplate({ templateId: workflow?.template?.id })); + dispatch(setAssignees({ assignees: workflow?.typists ?? [] })); + }, [dispatch, workflow]); + + const changeWorktypeId = useCallback( + (target: string) => { + // 空文字の場合はundefinedをdispatchする + if (target === "") { + dispatch(changeWorktype({ worktypeId: undefined })); + } else if (!Number.isNaN(Number(target))) { + dispatch(changeWorktype({ worktypeId: Number(target) })); + } + }, + [dispatch] + ); + + const changeTemplateId = useCallback( + (target: string) => { + // 空文字の場合はundefinedをdispatchする + if (target === "") { + dispatch(changeTemplate({ templateId: undefined })); + } else if (!Number.isNaN(Number(target))) { + dispatch(changeTemplate({ templateId: Number(target) })); + } + }, + [dispatch] + ); + + const changeAuthorId = useCallback( + (target: string) => { + if (!Number.isNaN(target)) { + dispatch(changeAuthor({ authorId: Number(target) })); + } + }, + [dispatch] + ); + + // 保存ボタン押下時の処理 + const handleSave = useCallback(async () => { + setIsPushEditButton(true); + // エラーチェック + if (hasAuthorIdEmptyError || hasSelectedWorkflowAssineeEmptyError) { + return; + } + const { meta } = await dispatch(updateWorkflowAsync()); + if (meta.requestStatus === "fulfilled") { + onClose(); + dispatch(listWorkflowAsync()); + } + }, [ + dispatch, + hasAuthorIdEmptyError, + hasSelectedWorkflowAssineeEmptyError, + onClose, + ]); + + return ( +
    +
    +

    + {t(getTranslationID("workflowPage.label.editRoutingRule"))} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */} + close +

    +
    +
    +
    +
    {t(getTranslationID("workflowPage.label.authorID"))}
    +
    + + {isPushEditButton && hasAuthorIdEmptyError && ( + + {t(getTranslationID("workflowPage.message.inputEmptyError"))} + + )} +
    +
    + {t(getTranslationID("workflowPage.label.worktypeOptional"))} +
    +
    + +
    +
    + {t(getTranslationID("typistGroupSetting.label.transcriptionist"))} +
    +
    +
      +
    • + {t(getTranslationID("workflowPage.label.selected"))} +
    • + {selectedAssignees?.map((x) => { + const key = `${x.typistName}_${ + x.typistUserId ?? x.typistGroupId + }`; + return ( +
    • + { + dispatch(removeAssignee({ assignee: x })); + }} + /> + +
    • + ); + })} +
    +

    +

      +
    • + {t(getTranslationID("workflowPage.label.pool"))} +
    • + {poolAssignees?.map((x) => { + const key = `${x.typistName}_${ + x.typistUserId ?? x.typistGroupId + }`; + return ( +
    • + dispatch(addAssignee({ assignee: x }))} + /> + +
    • + ); + })} +
    + {isPushEditButton && hasSelectedWorkflowAssineeEmptyError && ( + + {t( + getTranslationID( + "workflowPage.message.selectedTypistEmptyError" + ) + )} + + )} +
    +
    + {t(getTranslationID("workflowPage.label.templateOptional"))} +
    +
    + +
    +
    + + {isLoading && ( + Loading + )} +
    +
    +
    +
    +
    + ); +}; diff --git a/dictation_client/src/pages/WorkflowPage/index.tsx b/dictation_client/src/pages/WorkflowPage/index.tsx index 0ea3e51..16f5c49 100644 --- a/dictation_client/src/pages/WorkflowPage/index.tsx +++ b/dictation_client/src/pages/WorkflowPage/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import Header from "components/header"; import Footer from "components/footer"; import styles from "styles/app.module.scss"; @@ -10,23 +10,50 @@ import groupSettingImg from "assets/images/group_setting.svg"; import { AppDispatch } from "app/store"; import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; -import { listWorkflowAsync } from "features/workflow/operations"; -import { selectIsLoading, selectWorkflows } from "features/workflow"; +import { + deleteWorkflowAsync, + listWorkflowAsync, + changeSelectedWorkflow, + selectIsLoading, + selectWorkflows, +} from "features/workflow"; import progress_activit from "assets/images/progress_activit.svg"; import { getTranslationID } from "translation"; import { AddWorkflowPopup } from "./addworkflowPopup"; +import { EditWorkflowPopup } from "./editworkflowPopup"; const WorkflowPage: React.FC = (): JSX.Element => { const dispatch: AppDispatch = useDispatch(); const [t] = useTranslation(); // 追加Popupの表示制御 const [isShowAddPopup, setIsShowAddPopup] = useState(false); + // 編集Popupの表示制御 + const [isShowEditPopup, setIsShowEditPopup] = useState(false); + const workflows = useSelector(selectWorkflows); const isLoading = useSelector(selectIsLoading); useEffect(() => { dispatch(listWorkflowAsync()); }, [dispatch]); + + // ワークフロー削除 + const onDeleteWorkflow = useCallback( + async (workflowId: number) => { + if ( + /* eslint-disable-next-line no-alert */ + !window.confirm(t(getTranslationID("common.message.dialogConfirm"))) + ) { + return; + } + const { meta } = await dispatch(deleteWorkflowAsync({ workflowId })); + if (meta.requestStatus === "fulfilled") { + dispatch(listWorkflowAsync()); + } + }, + [dispatch, t] + ); + return ( <> {isShowAddPopup && ( @@ -36,6 +63,13 @@ const WorkflowPage: React.FC = (): JSX.Element => { }} /> )} + {isShowEditPopup && ( + { + setIsShowEditPopup(false); + }} + /> + )}
    @@ -136,14 +170,29 @@ const WorkflowPage: React.FC = (): JSX.Element => {
    • - + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} + { + dispatch( + changeSelectedWorkflow({ + workflowId: workflow.id, + }) + ); + setIsShowEditPopup(true); + }} + > {t( getTranslationID("workflowPage.label.editRule") )}
    • - + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} + { + onDeleteWorkflow(workflow.id); + }} + > {t(getTranslationID("common.label.delete"))}
    • diff --git a/dictation_client/src/styles/app.module.scss b/dictation_client/src/styles/app.module.scss index 89d6d34..b6728b0 100644 --- a/dictation_client/src/styles/app.module.scss +++ b/dictation_client/src/styles/app.module.scss @@ -293,6 +293,31 @@ h5 { width: 1.4rem; vertical-align: top; } +.accountSignout { + display: inline-block; + margin-left: 1rem; + color: #999999; + font-size: 0.8rem; + cursor: pointer; + -moz-transition: all 0.3s ease-out; + -ms-transition: all 0.3s ease-out; + -webkit-transition: all 0.3s ease-out; + transition: all 0.3s ease-out; +} +.accountSignout .accountIcon { + opacity: 0.5; + margin-right: 0.2rem; + -moz-transition: all 0.3s ease-out; + -ms-transition: all 0.3s ease-out; + -webkit-transition: all 0.3s ease-out; + transition: all 0.3s ease-out; +} +.accountSignout:hover { + color: #333333; +} +.accountSignout:hover .accountIcon { + opacity: 1; +} _:-ms-lang(x)::-ms-backdrop, .header { @@ -647,6 +672,7 @@ h3 + .brCrumb .tlIcon { padding: 0.6rem 0; margin-right: 1rem; cursor: pointer; + white-space: pre-line; } .form label:has(input:disabled) { cursor: default; @@ -870,6 +896,24 @@ h3 + .brCrumb .tlIcon { text-align: right; word-break: break-all; } +.listDocument { + margin-bottom: 3rem; +} +.listDocument li { + padding: 0 0 0 1.5rem; + margin-bottom: 1rem; + position: relative; +} +.listDocument li::before { + content: ""; + width: 0.5rem; + height: 0.5rem; + background: #333333; + border-radius: 50%; + position: absolute; + top: 0.2rem; + left: 0; +} .boxFlex { display: flex; @@ -1428,7 +1472,8 @@ _:-ms-lang(x)::-ms-backdrop, .license > div, .dictation > div, .partners > div, -.workflow > div { +.workflow > div, +.support > div { padding: 0 2rem; position: relative; } @@ -1437,7 +1482,8 @@ _:-ms-lang(x)::-ms-backdrop, .license > div .icLoading, .dictation > div .icLoading, .partners > div .icLoading, -.workflow > div .icLoading { +.workflow > div .icLoading, +.support > div .icLoading { top: 5.5rem; left: calc(50% - 25px); } @@ -1446,7 +1492,8 @@ _:-ms-lang(x)::-ms-backdrop, .license .table tr.tableHeader th.clm0, .dictation .table tr.tableHeader th.clm0, .partners .table tr.tableHeader th.clm0, -.workflow .table tr.tableHeader th.clm0 { +.workflow .table tr.tableHeader th.clm0, +.support .table tr.tableHeader th.clm0 { width: 0px; padding: 0 0; } @@ -1455,7 +1502,8 @@ _:-ms-lang(x)::-ms-backdrop, .license .table tr:not(.tableHeader), .dictation .table tr:not(.tableHeader), .partners .table tr:not(.tableHeader), -.workflow .table tr:not(.tableHeader) { +.workflow .table tr:not(.tableHeader), +.support .table tr:not(.tableHeader) { position: relative; } .account .table tr:not(.tableHeader):hover .menuInTable, @@ -1463,7 +1511,8 @@ _:-ms-lang(x)::-ms-backdrop, .license .table tr:not(.tableHeader):hover .menuInTable, .dictation .table tr:not(.tableHeader):hover .menuInTable, .partners .table tr:not(.tableHeader):hover .menuInTable, -.workflow .table tr:not(.tableHeader):hover .menuInTable { +.workflow .table tr:not(.tableHeader):hover .menuInTable, +.support .table tr:not(.tableHeader):hover .menuInTable { opacity: 1; } .account .table tr:not(.tableHeader).isSelected, @@ -1471,7 +1520,8 @@ _:-ms-lang(x)::-ms-backdrop, .license .table tr:not(.tableHeader).isSelected, .dictation .table tr:not(.tableHeader).isSelected, .partners .table tr:not(.tableHeader).isSelected, -.workflow .table tr:not(.tableHeader).isSelected { +.workflow .table tr:not(.tableHeader).isSelected, +.support .table tr:not(.tableHeader).isSelected { background: #0084b2; color: #ffffff; } @@ -1480,7 +1530,8 @@ _:-ms-lang(x)::-ms-backdrop, .license .table tr:not(.tableHeader).isSelected:hover, .dictation .table tr:not(.tableHeader).isSelected:hover, .partners .table tr:not(.tableHeader).isSelected:hover, -.workflow .table tr:not(.tableHeader).isSelected:hover { +.workflow .table tr:not(.tableHeader).isSelected:hover, +.support .table tr:not(.tableHeader).isSelected:hover { color: #ffffff; } .account .table tr:not(.tableHeader).isSelected .menuInTable, @@ -1488,7 +1539,8 @@ _:-ms-lang(x)::-ms-backdrop, .license .table tr:not(.tableHeader).isSelected .menuInTable, .dictation .table tr:not(.tableHeader).isSelected .menuInTable, .partners .table tr:not(.tableHeader).isSelected .menuInTable, -.workflow .table tr:not(.tableHeader).isSelected .menuInTable { +.workflow .table tr:not(.tableHeader).isSelected .menuInTable, +.support .table tr:not(.tableHeader).isSelected .menuInTable { display: block; } .account .table td, @@ -1496,7 +1548,8 @@ _:-ms-lang(x)::-ms-backdrop, .license .table td, .dictation .table td, .partners .table td, -.workflow .table td { +.workflow .table td, +.support .table td { max-width: 300px; overflow: hidden; text-overflow: ellipsis; @@ -1508,7 +1561,8 @@ _:-ms-lang(x)::-ms-backdrop, .license .table td.clm0, .dictation .table td.clm0, .partners .table td.clm0, -.workflow .table td.clm0 { +.workflow .table td.clm0, +.support .table td.clm0 { width: 0px; padding: 0 0; overflow: visible; @@ -1521,7 +1575,8 @@ _:-ms-lang(x)::-ms-backdrop, .license .table.user, .dictation .table.user, .partners .table.user, -.workflow .table.user { +.workflow .table.user, +.support .table.user { margin-bottom: 5rem; } .account .table.user th::after, @@ -1529,7 +1584,8 @@ _:-ms-lang(x)::-ms-backdrop, .license .table.user th::after, .dictation .table.user th::after, .partners .table.user th::after, -.workflow .table.user th::after { +.workflow .table.user th::after, +.support .table.user th::after { display: none; } .account .table.user tr:not(.tableHeader) td, @@ -1537,7 +1593,8 @@ _:-ms-lang(x)::-ms-backdrop, .license .table.user tr:not(.tableHeader) td, .dictation .table.user tr:not(.tableHeader) td, .partners .table.user tr:not(.tableHeader) td, -.workflow .table.user tr:not(.tableHeader) td { +.workflow .table.user tr:not(.tableHeader) td, +.support .table.user tr:not(.tableHeader) td { padding-bottom: 2rem; vertical-align: top; } @@ -2500,6 +2557,9 @@ tr.isSelected .menuInTable li a.isDisable { padding: 0 3rem; } +.txContents { + padding: 3rem; +} .txNormal { font-size: 16px; line-height: 1.7; diff --git a/dictation_client/src/styles/app.module.scss.d.ts b/dictation_client/src/styles/app.module.scss.d.ts index 7bb6792..6c4bd76 100644 --- a/dictation_client/src/styles/app.module.scss.d.ts +++ b/dictation_client/src/styles/app.module.scss.d.ts @@ -7,6 +7,7 @@ declare const classNames: { readonly isActive: "isActive"; readonly accountInfo: "accountInfo"; readonly accountIcon: "accountIcon"; + readonly accountSignout: "accountSignout"; readonly main: "main"; readonly mainSmall: "mainSmall"; readonly mainLogin: "mainLogin"; @@ -45,10 +46,10 @@ declare const classNames: { readonly formBack: "formBack"; readonly formButtonTx: "formButtonTx"; readonly formDone: "formDone"; - readonly formDelete: "formDelete"; readonly formTrash: "formTrash"; readonly listVertical: "listVertical"; readonly listHeader: "listHeader"; + readonly listDocument: "listDocument"; readonly boxFlex: "boxFlex"; readonly aru: "aru"; readonly btw: "btw"; @@ -101,11 +102,11 @@ declare const classNames: { readonly dictation: "dictation"; readonly partners: "partners"; readonly workflow: "workflow"; + readonly support: "support"; readonly clm0: "clm0"; readonly menuInTable: "menuInTable"; readonly isSelected: "isSelected"; - readonly formCheckToggle: "formCheckToggle"; - readonly toggleBase: "toggleBase"; + readonly alignRight: "alignRight"; readonly menuAction: "menuAction"; readonly inTable: "inTable"; readonly menuLink: "menuLink"; @@ -197,7 +198,6 @@ declare const classNames: { readonly worktype: "worktype"; readonly selectMenu: "selectMenu"; readonly alignLeft: "alignLeft"; - readonly alignRight: "alignRight"; readonly floatNone: "floatNone"; readonly floatLeft: "floatLeft"; readonly floatRight: "floatRight"; @@ -217,8 +217,8 @@ declare const classNames: { readonly paddSide1: "paddSide1"; readonly paddSide2: "paddSide2"; readonly paddSide3: "paddSide3"; + readonly txContents: "txContents"; readonly txIcon: "txIcon"; readonly txWswrap: "txWswrap"; - readonly required: "required"; }; export = classNames; diff --git a/dictation_client/src/translation/de.json b/dictation_client/src/translation/de.json index 35d433e..b757f77 100644 --- a/dictation_client/src/translation/de.json +++ b/dictation_client/src/translation/de.json @@ -358,6 +358,7 @@ "label": { "title": "Arbeitsablauf", "addRoutingRule": "(de)Add Routing Rule", + "editRoutingRule": "(de)Edit Routing Rule", "templateSetting": "(de)Template Setting", "worktypeIdSetting": "(de)WorktypeID Setting", "typistGroupSetting": "(de)Transcriptionist Group Setting", @@ -428,7 +429,8 @@ "optionItemInvalidError": "(de)Default valueがDefaultに設定されている場合、Initial valueは入力が必須です。", "optionItemSaveFailedError": "(de)オプションアイテムの保存に失敗しました。画面を更新し、再度実行してください", "optionItemIncorrectError": "(de)入力されたItem labelまたはInitial valueがルールを満たしていません。下記のルールを満たす値を入力してください", - "updateActiveWorktypeFailedError": "(de)Active WorktypeIDの保存に失敗しました。画面を更新し、再度実行してください" + "updateActiveWorktypeFailedError": "(de)Active WorktypeIDの保存に失敗しました。画面を更新し、再度実行してください", + "worktypeInUseError": "(de)このWorktype IDはルーティングルールで使用されているため削除できません。" } }, "templateFilePage": { @@ -497,5 +499,15 @@ "message": "(de)Your account has been deleted. Thank you for using our services.", "backToTopPageLink": "(de)Back to TOP Page" } + }, + "termsPage": { + "label": { + "title": "(de)Terms of Use has updated. Please confirm again.", + "linkOfEula": "(de)Click here to read the terms of use.", + "linkOfDpa": "(de)Click here to read the terms of use.", + "checkBoxForConsent": "(de)Yes, I agree to the terms of use.", + "forOdds": "(de)for ODDS.", + "button": "(de)Continue" + } } } diff --git a/dictation_client/src/translation/en.json b/dictation_client/src/translation/en.json index 6e08976..a58f19d 100644 --- a/dictation_client/src/translation/en.json +++ b/dictation_client/src/translation/en.json @@ -358,6 +358,7 @@ "label": { "title": "Workflow", "addRoutingRule": "Add Routing Rule", + "editRoutingRule": "Edit Routing Rule", "templateSetting": "Template Setting", "worktypeIdSetting": "WorktypeID Setting", "typistGroupSetting": "Transcriptionist Group Setting", @@ -428,7 +429,8 @@ "optionItemInvalidError": "Default valueがDefaultに設定されている場合、Initial valueは入力が必須です。", "optionItemSaveFailedError": "オプションアイテムの保存に失敗しました。画面を更新し、再度実行してください", "optionItemIncorrectError": "入力されたItem labelまたはInitial valueがルールを満たしていません。下記のルールを満たす値を入力してください", - "updateActiveWorktypeFailedError": "Active WorktypeIDの保存に失敗しました。画面を更新し、再度実行してください" + "updateActiveWorktypeFailedError": "Active WorktypeIDの保存に失敗しました。画面を更新し、再度実行してください", + "worktypeInUseError": "このWorktype IDはルーティングルールで使用されているため削除できません。" } }, "templateFilePage": { @@ -497,5 +499,15 @@ "message": "Your account has been deleted. Thank you for using our services.", "backToTopPageLink": "Back to TOP Page" } + }, + "termsPage": { + "label": { + "title": "Terms of Use has updated. Please confirm again.", + "linkOfEula": "Click here to read the terms of use.", + "linkOfDpa": "Click here to read the terms of use.", + "checkBoxForConsent": "Yes, I agree to the terms of use.", + "forOdds": "for ODDS.", + "button": "Continue" + } } } diff --git a/dictation_client/src/translation/es.json b/dictation_client/src/translation/es.json index 0bb32b5..9ba29ad 100644 --- a/dictation_client/src/translation/es.json +++ b/dictation_client/src/translation/es.json @@ -358,6 +358,7 @@ "label": { "title": "flujo de trabajo", "addRoutingRule": "(es)Add Routing Rule", + "editRoutingRule": "(es)Edit Routing Rule", "templateSetting": "(es)Template Setting", "worktypeIdSetting": "(es)WorktypeID Setting", "typistGroupSetting": "(es)Transcriptionist Group Setting", @@ -428,7 +429,8 @@ "optionItemInvalidError": "(es)Default valueがDefaultに設定されている場合、Initial valueは入力が必須です。", "optionItemSaveFailedError": "(es)オプションアイテムの保存に失敗しました。画面を更新し、再度実行してください", "optionItemIncorrectError": "(es)入力されたItem labelまたはInitial valueがルールを満たしていません。下記のルールを満たす値を入力してください", - "updateActiveWorktypeFailedError": "(es)Active WorktypeIDの保存に失敗しました。画面を更新し、再度実行してください" + "updateActiveWorktypeFailedError": "(es)Active WorktypeIDの保存に失敗しました。画面を更新し、再度実行してください", + "worktypeInUseError": "(es)このWorktype IDはルーティングルールで使用されているため削除できません。" } }, "templateFilePage": { @@ -497,5 +499,15 @@ "message": "(es)Your account has been deleted. Thank you for using our services.", "backToTopPageLink": "(es)Back to TOP Page" } + }, + "termsPage": { + "label": { + "title": "(es)Terms of Use has updated. Please confirm again.", + "linkOfEula": "(es)Click here to read the terms of use.", + "linkOfDpa": "(es)Click here to read the terms of use.", + "checkBoxForConsent": "(es)Yes, I agree to the terms of use.", + "forOdds": "(es)for ODDS.", + "button": "(es)Continue" + } } } diff --git a/dictation_client/src/translation/fr.json b/dictation_client/src/translation/fr.json index f5bf23d..5a7731e 100644 --- a/dictation_client/src/translation/fr.json +++ b/dictation_client/src/translation/fr.json @@ -358,6 +358,7 @@ "label": { "title": "Flux de travail", "addRoutingRule": "(fr)Add Routing Rule", + "editRoutingRule": "(fr)Edit Routing Rule", "templateSetting": "(fr)Template Setting", "worktypeIdSetting": "(fr)WorktypeID Setting", "typistGroupSetting": "(fr)Transcriptionist Group Setting", @@ -428,7 +429,8 @@ "optionItemInvalidError": "(fr)Default valueがDefaultに設定されている場合、Initial valueは入力が必須です。", "optionItemSaveFailedError": "(fr)オプションアイテムの保存に失敗しました。画面を更新し、再度実行してください", "optionItemIncorrectError": "(fr)入力されたItem labelまたはInitial valueがルールを満たしていません。下記のルールを満たす値を入力してください", - "updateActiveWorktypeFailedError": "(fr)Active WorktypeIDの保存に失敗しました。画面を更新し、再度実行してください" + "updateActiveWorktypeFailedError": "(fr)Active WorktypeIDの保存に失敗しました。画面を更新し、再度実行してください", + "worktypeInUseError": "(fr)このWorktype IDはルーティングルールで使用されているため削除できません。" } }, "templateFilePage": { @@ -497,5 +499,15 @@ "message": "(fr)Your account has been deleted. Thank you for using our services.", "backToTopPageLink": "(fr)Back to TOP Page" } + }, + "termsPage": { + "label": { + "title": "(fr)Terms of Use has updated. Please confirm again.", + "linkOfEula": "(fr)Click here to read the terms of use.", + "linkOfDpa": "(fr)Click here to read the terms of use.", + "checkBoxForConsent": "(fr)Yes, I agree to the terms of use.", + "forOdds": "(fr)for ODDS.", + "button": "(fr)Continue" + } } } diff --git a/dictation_server/db/migrations/045-delete-foreign-key-for-account-delete.sql b/dictation_server/db/migrations/045-delete-foreign-key-for-account-delete.sql new file mode 100644 index 0000000..ccb3995 --- /dev/null +++ b/dictation_server/db/migrations/045-delete-foreign-key-for-account-delete.sql @@ -0,0 +1,33 @@ +-- +migrate Up +ALTER TABLE `checkout_permission` DROP FOREIGN KEY `checkout_permission_fk_task_id`; +ALTER TABLE `tasks` DROP FOREIGN KEY `tasks_fk_account_id`; +ALTER TABLE `template_files` DROP FOREIGN KEY `template_files_fk_account_id`; +ALTER TABLE `option_items` DROP FOREIGN KEY `option_items_fk_worktype_id`; +ALTER TABLE `worktypes` DROP FOREIGN KEY `worktypes_fk_account_id`; +ALTER TABLE `audio_option_items` DROP FOREIGN KEY `audio_option_items_fk_audio_file_id`; +ALTER TABLE `audio_files` DROP FOREIGN KEY `audio_files_fk_account_id`; +ALTER TABLE `user_group_member` DROP FOREIGN KEY `user_group_member_fk_user_group_id`; +ALTER TABLE `user_group` DROP FOREIGN KEY `user_group_fk_account_id`; +ALTER TABLE `license_allocation_history` DROP FOREIGN KEY `license_allocation_history_fk_account_id`; +ALTER TABLE `card_licenses` DROP FOREIGN KEY `card_licenses_fk_license_id`; +ALTER TABLE `licenses` DROP FOREIGN KEY `licenses_fk_account_id`; +ALTER TABLE `license_orders` DROP FOREIGN KEY `license_orders_fk_from_account_id`; +ALTER TABLE `sort_criteria` DROP FOREIGN KEY `sort_criteria_fk_user_id`; +ALTER TABLE `users` DROP FOREIGN KEY `users_fk_account_id`; + +-- +migrate Down +ALTER TABLE `checkout_permission` ADD CONSTRAINT `checkout_permission_fk_task_id` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `tasks` ADD CONSTRAINT `tasks_fk_account_id` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `template_files` ADD CONSTRAINT `template_files_fk_account_id` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `option_items` ADD CONSTRAINT `option_items_fk_worktype_id` FOREIGN KEY (`worktype_id`) REFERENCES `worktypes` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `worktypes` ADD CONSTRAINT `worktypes_fk_account_id` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `audio_option_items` ADD CONSTRAINT `audio_option_items_fk_audio_file_id` FOREIGN KEY (`audio_file_id`) REFERENCES `audio_files` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `audio_files` ADD CONSTRAINT `audio_files_fk_account_id` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `user_group_member` ADD CONSTRAINT `user_group_member_fk_user_group_id` FOREIGN KEY (`user_group_id`) REFERENCES `user_group` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `user_group` ADD CONSTRAINT `user_group_fk_account_id` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `license_allocation_history` ADD CONSTRAINT `license_allocation_history_fk_account_id` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `card_licenses` ADD CONSTRAINT `card_licenses_fk_license_id` FOREIGN KEY (`license_id`) REFERENCES `licenses` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `licenses` ADD CONSTRAINT `licenses_fk_account_id` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `license_orders` ADD CONSTRAINT `license_orders_fk_from_account_id` FOREIGN KEY (`from_account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `sort_criteria` ADD CONSTRAINT `sort_criteria_fk_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `users` ADD CONSTRAINT `users_fk_account_id` FOREIGN KEY (`account_id`) REFERENCES `accounts` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/dictation_server/db/migrations/046-insert_initial_data_terms.sql b/dictation_server/db/migrations/046-insert_initial_data_terms.sql new file mode 100644 index 0000000..faa2e3d --- /dev/null +++ b/dictation_server/db/migrations/046-insert_initial_data_terms.sql @@ -0,0 +1,9 @@ +-- +migrate Up +insert into terms(terms.document_type, terms.version) values('EURA', 'V0.1'); +insert into terms(terms.document_type, terms.version) values('DPA', 'V0.1'); +commit; + +-- +migrate Down +delete from terms where terms.document_type = 'EURA' and terms.version = 'V0.1'; +delete from terms where terms.document_type = 'DPA' and terms.version = 'V0.1'; +commit; diff --git a/dictation_server/src/api/odms/openapi.json b/dictation_server/src/api/odms/openapi.json index 7a56d16..993efb4 100644 --- a/dictation_server/src/api/odms/openapi.json +++ b/dictation_server/src/api/odms/openapi.json @@ -990,6 +990,59 @@ "security": [{ "bearer": [] }] } }, + "/accounts/worktypes/{id}/delete": { + "post": { + "operationId": "deleteWorktype", + "summary": "", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "description": "Worktypeの内部ID", + "schema": { "type": "number" } + } + ], + "responses": { + "200": { + "description": "成功時のレスポンス", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteWorktypeResponse" + } + } + } + }, + "400": { + "description": "指定WorktypeIDが削除済み / 指定WorktypeIDがWorkflowで使用中", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + }, + "401": { + "description": "認証エラー", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + }, + "500": { + "description": "想定外のサーバーエラー", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + } + }, + "tags": ["accounts"], + "security": [{ "bearer": [] }] + } + }, "/accounts/worktypes/{id}/option-items": { "get": { "operationId": "getOptionItems", @@ -3156,6 +3209,14 @@ } } }, + "400": { + "description": "パラメータ不正エラー", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + }, "401": { "description": "認証エラー", "content": { @@ -3229,7 +3290,7 @@ } }, "/terms": { - "post": { + "get": { "operationId": "getTermsInfo", "summary": "", "parameters": [], @@ -3698,6 +3759,7 @@ "required": ["worktypeId"] }, "UpdateWorktypeResponse": { "type": "object", "properties": {} }, + "DeleteWorktypeResponse": { "type": "object", "properties": {} }, "GetWorktypeOptionItem": { "type": "object", "properties": { @@ -4403,7 +4465,7 @@ "licenseId": { "type": "number" }, "expiryDate": { "format": "date-time", "type": "string" } }, - "required": ["licenseId", "expiryDate"] + "required": ["licenseId"] }, "GetAllocatableLicensesResponse": { "type": "object", @@ -4561,7 +4623,24 @@ "required": ["pns", "handler"] }, "RegisterResponse": { "type": "object", "properties": {} }, - "GetTermsInfoResponse": { "type": "object", "properties": {} } + "TermInfo": { + "type": "object", + "properties": { + "documentType": { "type": "string", "description": "利用規約種別" }, + "version": { "type": "string", "description": "バージョン" } + }, + "required": ["documentType", "version"] + }, + "GetTermsInfoResponse": { + "type": "object", + "properties": { + "termsInfo": { + "type": "array", + "items": { "$ref": "#/components/schemas/TermInfo" } + } + }, + "required": ["termsInfo"] + } } } } diff --git a/dictation_server/src/common/error/code.ts b/dictation_server/src/common/error/code.ts index 448aa82..9b271e8 100644 --- a/dictation_server/src/common/error/code.ts +++ b/dictation_server/src/common/error/code.ts @@ -32,6 +32,7 @@ export const ErrorCodes = [ 'E010206', // DBのTierが想定外の値エラー 'E010207', // ユーザーのRole変更不可エラー 'E010208', // ユーザーの暗号化パスワード不足エラー + 'E010209', // ユーザーの同意済み利用規約バージョンが最新でないエラー 'E010301', // メールアドレス登録済みエラー 'E010302', // authorId重複エラー 'E010401', // PONumber重複エラー @@ -56,6 +57,7 @@ export const ErrorCodes = [ 'E011001', // ワークタイプ重複エラー 'E011002', // ワークタイプ登録上限超過エラー 'E011003', // ワークタイプ不在エラー + 'E011004', // ワークタイプ使用中エラー 'E012001', // テンプレートファイル不在エラー 'E013001', // ワークフローのAuthorIDとWorktypeIDのペア重複エラー 'E013002', // ワークフロー不在エラー diff --git a/dictation_server/src/common/error/message.ts b/dictation_server/src/common/error/message.ts index eeee5b3..11b0c1d 100644 --- a/dictation_server/src/common/error/message.ts +++ b/dictation_server/src/common/error/message.ts @@ -21,6 +21,7 @@ export const errors: Errors = { E010206: 'Tier from DB is unexpected value Error.', E010207: 'User role change not allowed Error.', E010208: 'User encryption password not found Error.', + E010209: 'Accepted term not latest Error.', E010301: 'This email user already created Error', E010302: 'This AuthorId already used Error', E010401: 'This PoNumber already used Error', @@ -45,6 +46,7 @@ export const errors: Errors = { E011001: 'This WorkTypeID already used Error', E011002: 'WorkTypeID create limit exceeded Error', E011003: 'WorkTypeID not found Error', + E011004: 'WorkTypeID is in use Error', E012001: 'Template file not found Error', E013001: 'AuthorId and WorktypeId pair already exists Error', E013002: 'Workflow not found Error', diff --git a/dictation_server/src/common/guards/role/roleguards.ts b/dictation_server/src/common/guards/role/roleguards.ts index c3ba25d..6670bed 100644 --- a/dictation_server/src/common/guards/role/roleguards.ts +++ b/dictation_server/src/common/guards/role/roleguards.ts @@ -92,13 +92,15 @@ export class RoleGuard implements CanActivate { * @returns true/false */ checkRole(role: string): boolean { - const { roles } = this.settings; - + const settings = this.settings; + if (!settings || !settings.roles) { + return true; + } const userRoles = role.split(' '); // Role毎にAccessTokenの権限チェックを行う - for (let i = 0; i < roles.length; i++) { - const role = roles[i]; + for (let i = 0; i < settings.roles.length; i++) { + const role = settings.roles[i]; let isValid = false; if (Array.isArray(role)) { isValid = role.every((x) => userRoles.includes(x)); @@ -172,9 +174,12 @@ export class RoleGuard implements CanActivate { * @returns true/false */ checkTier(tier: number): boolean { - const { tiers } = this.settings; + const settings = this.settings; + if (!settings || !settings.tiers) { + return true; + } // 宣言された階層中にパラメータの内容が含まれていればtrue - return tiers.includes(tier as (typeof TIERS)[keyof typeof TIERS]); + return settings.tiers.includes(tier as (typeof TIERS)[keyof typeof TIERS]); } } diff --git a/dictation_server/src/common/jwt/jwt.ts b/dictation_server/src/common/jwt/jwt.ts index f50f01d..2e97368 100644 --- a/dictation_server/src/common/jwt/jwt.ts +++ b/dictation_server/src/common/jwt/jwt.ts @@ -132,7 +132,7 @@ export const getPrivateKey = (configService: ConfigService): string => { return ( // 開発環境用に改行コードを置換する // 本番環境では\\nが含まれないため、置換が行われない想定 - configService.get('JWT_PRIVATE_KEY')?.replace(/\\n/g, '\n') ?? '' + configService.getOrThrow('JWT_PRIVATE_KEY').replace(/\\n/g, '\n') ); }; @@ -140,6 +140,6 @@ export const getPublicKey = (configService: ConfigService): string => { return ( // 開発環境用に改行コードを置換する // 本番環境では\\nが含まれないため、置換が行われない想定 - configService.get('JWT_PUBLIC_KEY')?.replace(/\\n/g, '\n') ?? '' + configService.getOrThrow('JWT_PUBLIC_KEY').replace(/\\n/g, '\n') ); }; diff --git a/dictation_server/src/common/password/password.ts b/dictation_server/src/common/password/password.ts index 76265e2..f68bb3c 100644 --- a/dictation_server/src/common/password/password.ts +++ b/dictation_server/src/common/password/password.ts @@ -15,10 +15,9 @@ export const makePassword = (): string => { // autoGeneratedPasswordが以上の条件を満たせばvalidがtrueになる let valid = false; - let autoGeneratedPassword: string; + let autoGeneratedPassword: string = ''; while (!valid) { - autoGeneratedPassword = ''; // パスワードをランダムに決定 while (autoGeneratedPassword.length < passLength) { // 上で決定したcharsの中からランダムに1文字ずつ追加 diff --git a/dictation_server/src/common/test/modules.ts b/dictation_server/src/common/test/modules.ts index 05f8e77..aec0d61 100644 --- a/dictation_server/src/common/test/modules.ts +++ b/dictation_server/src/common/test/modules.ts @@ -34,10 +34,13 @@ import { TemplatesService } from '../../features/templates/templates.service'; import { TemplatesModule } from '../../features/templates/templates.module'; import { WorkflowsService } from '../../features/workflows/workflows.service'; import { WorkflowsModule } from '../../features/workflows/workflows.module'; +import { TermsService } from '../../features/terms/terms.service'; +import { TermsRepositoryModule } from '../../repositories/terms/terms.repository.module'; +import { TermsModule } from '../../features/terms/terms.module'; export const makeTestingModule = async ( datasource: DataSource, -): Promise => { +): Promise => { try { const module: TestingModule = await Test.createTestingModule({ imports: [ @@ -56,6 +59,7 @@ export const makeTestingModule = async ( LicensesModule, TemplatesModule, WorkflowsModule, + TermsModule, AccountsRepositoryModule, UsersRepositoryModule, LicensesRepositoryModule, @@ -71,6 +75,7 @@ export const makeTestingModule = async ( AuthGuardsModule, SortCriteriaRepositoryModule, WorktypesRepositoryModule, + TermsRepositoryModule, ], providers: [ AuthService, @@ -82,6 +87,7 @@ export const makeTestingModule = async ( LicensesService, TemplatesService, WorkflowsService, + TermsService, ], }) .useMocker(async (token) => { diff --git a/dictation_server/src/common/test/utility.ts b/dictation_server/src/common/test/utility.ts index 4722e9f..00b19d7 100644 --- a/dictation_server/src/common/test/utility.ts +++ b/dictation_server/src/common/test/utility.ts @@ -3,6 +3,7 @@ import { DataSource } from 'typeorm'; import { User, UserArchive } from '../../repositories/users/entity/user.entity'; import { Account } from '../../repositories/accounts/entity/account.entity'; import { ADMIN_ROLES, USER_ROLES } from '../../constants'; +import { License } from '../../repositories/licenses/entity/license.entity'; type InitialTestDBState = { tier1Accounts: { account: Account; users: User[] }[]; @@ -57,11 +58,11 @@ export const makeHierarchicalAccounts = async ( } // 第2階層を作成 { - const { account: tier1 } = state.tier1Accounts.slice().shift(); + const tier1 = state.tier1Accounts.slice().shift(); { const { account, admin } = await makeTestAccount(datasource, { tier: 2, - parent_account_id: tier1.id, + parent_account_id: tier1?.account.id, company_name: 'OMDS_US', }); state.tier2Accounts.push({ @@ -72,7 +73,7 @@ export const makeHierarchicalAccounts = async ( { const { account, admin } = await makeTestAccount(datasource, { tier: 2, - parent_account_id: tier1.id, + parent_account_id: tier1?.account.id, company_name: 'OMDS_EU', }); state.tier2Accounts.push({ @@ -201,7 +202,7 @@ export const makeTestAccount = async ( } // Accountの管理者を設定する - let secondaryAdminUserId = null; + let secondaryAdminUserId: number | null = null; if (isPrimaryAdminNotExist && !isSecondaryAdminNotExist) { secondaryAdminUserId = userId; } @@ -224,6 +225,9 @@ export const makeTestAccount = async ( id: userId, }, }); + if (!account || !admin) { + throw new Error('Unexpected null'); + } return { account: account, @@ -263,7 +267,9 @@ export const makeTestSimpleAccount = async ( id: result.id, }, }); - + if (!account) { + throw new Error('Unexpected null'); + } return account; }; @@ -299,11 +305,15 @@ export const makeTestUser = async ( }); const result = identifiers.pop() as User; - return await datasource.getRepository(User).findOne({ + const user = await datasource.getRepository(User).findOne({ where: { id: result.id, }, }); + if (!user) { + throw new Error('Unexpected null'); + } + return user; }; /** @@ -312,7 +322,10 @@ export const makeTestUser = async ( * @param id アカウントID * @returns 該当アカウント */ -export const getAccount = async (dataSource: DataSource, id: number) => { +export const getAccount = async ( + dataSource: DataSource, + id: number, +): Promise => { return await dataSource.getRepository(Account).findOne({ where: { id: id }, }); @@ -353,7 +366,7 @@ export const getUserFromExternalId = async ( export const getUser = async ( datasource: DataSource, id: number, -): Promise => { +): Promise => { const user = await datasource.getRepository(User).findOne({ where: { id: id, @@ -381,3 +394,14 @@ export const getUserArchive = async ( ): Promise => { return await dataSource.getRepository(UserArchive).find(); }; +export const getLicenses = async ( + datasource: DataSource, + account_id: number, +): Promise => { + const licenses = await datasource.getRepository(License).find({ + where: { + account_id: account_id, + }, + }); + return licenses; +}; diff --git a/dictation_server/src/constants/index.ts b/dictation_server/src/constants/index.ts index 8e527f4..1f5ffed 100644 --- a/dictation_server/src/constants/index.ts +++ b/dictation_server/src/constants/index.ts @@ -244,7 +244,7 @@ export const OPTION_ITEM_VALUE_TYPE = { * @const {string[]} */ export const ADB2C_SIGN_IN_TYPE = { - EAMILADDRESS: 'emailAddress', + EMAILADDRESS: 'emailAddress', } as const; /** @@ -252,3 +252,12 @@ export const ADB2C_SIGN_IN_TYPE = { * @const {string} */ export const MANUAL_RECOVERY_REQUIRED = '[MANUAL_RECOVERY_REQUIRED]'; + +/** + * 利用規約種別 + * @const {string[]} + */ +export const TERM_TYPE = { + EULA: 'EULA', + DPA: 'DPA', +} as const; diff --git a/dictation_server/src/features/accounts/accounts.controller.spec.ts b/dictation_server/src/features/accounts/accounts.controller.spec.ts index b2bbf73..2761b0e 100644 --- a/dictation_server/src/features/accounts/accounts.controller.spec.ts +++ b/dictation_server/src/features/accounts/accounts.controller.spec.ts @@ -2,10 +2,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AccountsController } from './accounts.controller'; import { AccountsService } from './accounts.service'; import { ConfigModule } from '@nestjs/config'; +import { AuthService } from '../auth/auth.service'; describe('AccountsController', () => { let controller: AccountsController; const mockAccountService = {}; + const mockAuthService = {}; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -16,10 +18,12 @@ describe('AccountsController', () => { }), ], controllers: [AccountsController], - providers: [AccountsService], + providers: [AccountsService, AuthService], }) .overrideProvider(AccountsService) .useValue(mockAccountService) + .overrideProvider(AuthService) + .useValue(mockAuthService) .compile(); controller = module.get(AccountsController); diff --git a/dictation_server/src/features/accounts/accounts.controller.ts b/dictation_server/src/features/accounts/accounts.controller.ts index 92206af..cba3bb2 100644 --- a/dictation_server/src/features/accounts/accounts.controller.ts +++ b/dictation_server/src/features/accounts/accounts.controller.ts @@ -8,6 +8,7 @@ import { UseGuards, Param, Query, + HttpException, } from '@nestjs/common'; import { ApiOperation, @@ -65,6 +66,8 @@ import { GetAuthorsResponse, GetAccountInfoMinimalAccessRequest, GetAccountInfoMinimalAccessResponse, + DeleteWorktypeRequestParam, + DeleteWorktypeResponse, } from './types/types'; import { USER_ROLES, ADMIN_ROLES, TIERS } from '../../constants'; import { AuthGuard } from '../../common/guards/auth/authguards'; @@ -74,12 +77,15 @@ import { AccessToken } from '../../common/token'; import jwt from 'jsonwebtoken'; import { makeContext } from '../../common/log'; import { v4 as uuidv4 } from 'uuid'; +import { AuthService } from '../auth/auth.service'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; @ApiTags('accounts') @Controller('accounts') export class AccountsController { constructor( private readonly accountService: AccountsService, //private readonly cryptoService: CryptoService, + private readonly authService: AuthService, ) {} @Post() @@ -194,14 +200,26 @@ export class AccountsController { @UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] })) @Get('me') async getMyAccount(@Req() req: Request): Promise { - // アクセストークン取得 - const accessToken = retrieveAuthorizationToken(req); - const payload = jwt.decode(accessToken, { json: true }) as AccessToken; - const context = makeContext(payload.userId); + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; + const context = makeContext(userId); //アカウントID取得処理 const accountInfo = await this.accountService.getAccountInfo( context, - payload.userId, + userId, ); return accountInfo; } @@ -231,8 +249,21 @@ export class AccountsController { @UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] })) @Get('authors') async getAuthors(@Req() req: Request): Promise { - const accessToken = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(accessToken, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); const authors = await this.accountService.getAuthors(context, userId); @@ -264,10 +295,23 @@ export class AccountsController { @UseGuards(AuthGuard) @Get('typists') async getTypists(@Req() req: Request): Promise { - const accessToken = retrieveAuthorizationToken(req); - const payload = jwt.decode(accessToken, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; - const typists = await this.accountService.getTypists(payload.userId); + const typists = await this.accountService.getTypists(userId); return { typists }; } @@ -296,12 +340,23 @@ export class AccountsController { @UseGuards(AuthGuard) @Get('typist-groups') async getTypistGroups(@Req() req: Request): Promise { - const accessToken = retrieveAuthorizationToken(req); - const payload = jwt.decode(accessToken, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; - const typistGroups = await this.accountService.getTypistGroups( - payload.userId, - ); + const typistGroups = await this.accountService.getTypistGroups(userId); return { typistGroups }; } @@ -342,8 +397,22 @@ export class AccountsController { const { typistGroupId } = param; // アクセストークン取得 - const accessToken = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(accessToken, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); @@ -391,8 +460,22 @@ export class AccountsController { ): Promise { const { typistGroupName, typistIds } = body; // アクセストークン取得 - const accessToken = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(accessToken, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); await this.accountService.createTypistGroup( context, @@ -441,8 +524,22 @@ export class AccountsController { const { typistGroupId } = param; // アクセストークン取得 - const accessToken = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(accessToken, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); @@ -492,10 +589,24 @@ export class AccountsController { @Body() body: CreatePartnerAccountRequest, ): Promise { const { companyName, country, email, adminName } = body; - const accessToken = retrieveAuthorizationToken(req); - const payload = jwt.decode(accessToken, { json: true }) as AccessToken; - const context = makeContext(payload.userId); + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId, tier } = decodedAccessToken as AccessToken; + + const context = makeContext(userId); await this.accountService.createPartnerAccount( context, @@ -503,8 +614,8 @@ export class AccountsController { country, email, adminName, - payload.userId, - payload.tier, + userId, + tier, ); return {}; @@ -620,15 +731,28 @@ export class AccountsController { ): Promise { const { orderedAccountId, poNumber } = body; - const token = retrieveAuthorizationToken(req); - const accessToken = jwt.decode(token, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId, tier } = decodedAccessToken as AccessToken; - const context = makeContext(accessToken.userId); + const context = makeContext(userId); await this.accountService.issueLicense( context, orderedAccountId, - accessToken.userId, - accessToken.tier, + userId, + tier, poNumber, ); return {}; @@ -688,14 +812,27 @@ export class AccountsController { @Req() req: Request, @Body() body: CancelIssueRequest, ): Promise { - const token = retrieveAuthorizationToken(req); - const payload = jwt.decode(token, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; - const context = makeContext(payload.userId); + const context = makeContext(userId); await this.accountService.cancelIssue( context, - payload.userId, + userId, body.poNumber, body.orderedAccountId, ); @@ -723,8 +860,21 @@ export class AccountsController { @UseGuards(AuthGuard) @UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] })) async getWorktypes(@Req() req: Request): Promise { - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); const worktypes = await this.accountService.getWorktypes(context, userId); @@ -762,8 +912,22 @@ export class AccountsController { @Body() body: CreateWorktypesRequest, ): Promise { const { worktypeId, description } = body; - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); await this.accountService.createWorktype( @@ -808,8 +972,22 @@ export class AccountsController { ): Promise { const { worktypeId, description } = body; const { id } = param; - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); @@ -824,6 +1002,59 @@ export class AccountsController { return {}; } + @Post('/worktypes/:id/delete') + @ApiResponse({ + status: HttpStatus.OK, + type: DeleteWorktypeResponse, + description: '成功時のレスポンス', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: '指定WorktypeIDが削除済み / 指定WorktypeIDがWorkflowで使用中', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: '認証エラー', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: '想定外のサーバーエラー', + type: ErrorResponse, + }) + @ApiOperation({ operationId: 'deleteWorktype' }) + @ApiBearerAuth() + @UseGuards(AuthGuard) + @UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] })) + async deleteWorktype( + @Req() req: Request, + @Param() param: DeleteWorktypeRequestParam, + ): Promise { + const { id } = param; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; + + const context = makeContext(userId); + + await this.accountService.deleteWorktype(context, userId, id); + return {}; + } + @Get('/worktypes/:id/option-items') @ApiResponse({ status: HttpStatus.OK, @@ -854,8 +1085,22 @@ export class AccountsController { @Param() param: GetOptionItemsRequestParam, ): Promise { const { id } = param; - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); @@ -900,8 +1145,22 @@ export class AccountsController { ): Promise { const { optionItems } = body; const { id } = param; - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); @@ -945,8 +1204,22 @@ export class AccountsController { @Body() body: PostActiveWorktypeRequest, ): Promise { const { id } = body; - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); @@ -989,8 +1262,22 @@ export class AccountsController { @Query() query: GetPartnersRequest, ): Promise { const { limit, offset } = query; - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); const response = await this.accountService.getPartners( @@ -1042,8 +1329,22 @@ export class AccountsController { primaryAdminUserId, secondryAdminUserId, } = body; - const token = retrieveAuthorizationToken(req); - const { userId, tier } = jwt.decode(token, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId, tier } = decodedAccessToken as AccessToken; const context = makeContext(userId); await this.accountService.updateAccountInfo( @@ -1056,7 +1357,7 @@ export class AccountsController { secondryAdminUserId, ); - return; + return {}; } @Post('/delete') @@ -1088,12 +1389,26 @@ export class AccountsController { @Body() body: DeleteAccountRequest, ): Promise { const { accountId } = body; - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); await this.accountService.deleteAccountAndData(context, userId, accountId); - return; + return {}; } @Post('/minimal-access') @@ -1116,11 +1431,22 @@ export class AccountsController { async getAccountInfoMinimalAccess( @Body() body: GetAccountInfoMinimalAccessRequest, ): Promise { - const context = makeContext(uuidv4()); + // IDトークンの検証 + const idToken = await this.authService.getVerifiedIdToken(body.idToken); + const isVerified = await this.authService.isVerifiedUser(idToken); + if (!isVerified) { + throw new HttpException( + makeErrorResponse('E010201'), + HttpStatus.BAD_REQUEST, + ); + } - // TODO 仮実装。API実装タスクで本実装する。 - // const idToken = await this.authService.getVerifiedIdToken(body.idToken); - // await this.accountService.getAccountInfoMinimalAccess(context, idToken); - return; + const context = makeContext(idToken.sub); + + const tier = await this.accountService.getAccountInfoMinimalAccess( + context, + idToken.sub, + ); + return { tier }; } } diff --git a/dictation_server/src/features/accounts/accounts.module.ts b/dictation_server/src/features/accounts/accounts.module.ts index 4b43415..23cf65e 100644 --- a/dictation_server/src/features/accounts/accounts.module.ts +++ b/dictation_server/src/features/accounts/accounts.module.ts @@ -9,6 +9,7 @@ import { AdB2cModule } from '../../gateways/adb2c/adb2c.module'; import { UserGroupsRepositoryModule } from '../../repositories/user_groups/user_groups.repository.module'; import { BlobstorageModule } from '../../gateways/blobstorage/blobstorage.module'; import { WorktypesRepositoryModule } from '../../repositories/worktypes/worktypes.repository.module'; +import { AuthService } from '../auth/auth.service'; @Module({ imports: [ @@ -22,6 +23,6 @@ import { WorktypesRepositoryModule } from '../../repositories/worktypes/worktype BlobstorageModule, ], controllers: [AccountsController], - providers: [AccountsService], + providers: [AccountsService, AuthService], }) export class AccountsModule {} diff --git a/dictation_server/src/features/accounts/accounts.service.spec.ts b/dictation_server/src/features/accounts/accounts.service.spec.ts index c7786d5..3742cad 100644 --- a/dictation_server/src/features/accounts/accounts.service.spec.ts +++ b/dictation_server/src/features/accounts/accounts.service.spec.ts @@ -35,6 +35,7 @@ import { makeTestUser, makeHierarchicalAccounts, getUser, + getLicenses, getUserArchive, } from '../../common/test/utility'; import { AccountsService } from './accounts.service'; @@ -49,7 +50,11 @@ import { USER_ROLES, WORKTYPE_MAX_COUNT, } from '../../constants'; -import { License } from '../../repositories/licenses/entity/license.entity'; +import { + License, + LicenseAllocationHistory, + LicenseOrder, +} from '../../repositories/licenses/entity/license.entity'; import { overrideAccountsRepositoryService, overrideAdB2cService, @@ -60,7 +65,6 @@ import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service'; import { - createLicenseAllocationHistory, createOrder, getLicenseArchive, getLicenseAllocationHistoryArchive, @@ -72,9 +76,11 @@ import { AdB2cUser } from '../../gateways/adb2c/types/types'; import { Worktype } from '../../repositories/worktypes/entity/worktype.entity'; import { AccountsRepositoryService } from '../../repositories/accounts/accounts.repository.service'; import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; +import { createWorkflow, getWorkflows } from '../workflows/test/utility'; +import { UsersService } from '../users/users.service'; describe('createAccount', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -87,12 +93,14 @@ describe('createAccount', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); - it('アカウントを作成できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const externalId = 'test_external_id'; @@ -146,20 +154,22 @@ describe('createAccount', () => { // DB内が想定通りになっているか確認 const account = await getAccount(source, accountId); const user = await getUserFromExternalId(source, externalUserId); - expect(account.company_name).toBe(companyName); - expect(account.country).toBe(country); - expect(account.parent_account_id).toBe(dealerAccountId); - expect(account.tier).toBe(TIERS.TIER5); - expect(account.primary_admin_user_id).toBe(user.id); - expect(account.secondary_admin_user_id).toBe(null); - expect(user.accepted_eula_version).toBe(acceptedEulaVersion); - expect(user.accepted_dpa_version).toBe(acceptedDpaVersion); - expect(user.account_id).toBe(accountId); - expect(user.role).toBe(role); + expect(account?.company_name).toBe(companyName); + expect(account?.country).toBe(country); + expect(account?.parent_account_id).toBe(dealerAccountId); + expect(account?.tier).toBe(TIERS.TIER5); + expect(account?.primary_admin_user_id).toBe(user?.id); + expect(account?.secondary_admin_user_id).toBe(null); + expect(user?.accepted_eula_version).toBe(acceptedEulaVersion); + expect(user?.accepted_dpa_version).toBe(acceptedDpaVersion); + expect(user?.account_id).toBe(accountId); + expect(user?.role).toBe(role); }); it('アカウントを作成がAzure AD B2Cへの通信失敗によって失敗すると500エラーが発生する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); overrideAdB2cService(service, { @@ -225,7 +235,9 @@ describe('createAccount', () => { }); it('アカウントを作成がメールアドレス重複によって失敗すると400エラーが発生する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); overrideAdB2cService(service, { @@ -292,7 +304,9 @@ describe('createAccount', () => { expect(users.length).toBe(0); }); it('アカウントを作成がDBへの通信失敗によって500エラーが発生した場合、リカバリ処理としてADB2Cユーザーを削除され、500エラーが返却される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const b2cService = module.get(AdB2cService); const externalId = 'test_external_id'; @@ -356,7 +370,9 @@ describe('createAccount', () => { ); }); it('アカウントを作成がDBへの通信失敗によって500エラーが発生した場合、リカバリ処理が実行されるが、ADB2Cユーザー削除で失敗した場合、500エラーが返却される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const b2cService = module.get(AdB2cService); const externalId = 'test_external_id'; @@ -421,7 +437,9 @@ describe('createAccount', () => { }); it('アカウントを作成がBlobStorageへの通信失敗によって500エラーが発生した場合、リカバリ処理としてADB2C,DB上のデータが削除され、500エラーが返却される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const b2cService = module.get(AdB2cService); b2cService.deleteUser = jest.fn(); // リカバリ処理の確認のため、deleteUserをモック化 @@ -488,7 +506,9 @@ describe('createAccount', () => { }); it('アカウントを作成がBlobStorageへの通信失敗によって500エラーが発生した場合、リカバリ処理が実行されるが、そのリカバリ処理に失敗した場合、500エラーが返却される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const b2cService = module.get(AdB2cService); const externalId = 'test_external_id'; @@ -557,7 +577,9 @@ describe('createAccount', () => { }); it('アカウントを作成がSendGridへの通信失敗によって500エラーが発生した場合、リカバリ処理としてADB2C,DB上のデータとBlobストレージのコンテナが削除され、500エラーが返却される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const b2cService = module.get(AdB2cService); const blobstorageService = @@ -651,7 +673,9 @@ describe('createAccount', () => { }); it('アカウントを作成がSendGridへの通信失敗によって500エラーが発生した場合、リカバリ処理が実行されるが、そのリカバリ処理に失敗した場合、500エラーが返却される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const b2cService = module.get(AdB2cService); const blobstorageService = @@ -743,7 +767,7 @@ describe('createAccount', () => { }); describe('createPartnerAccount', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -756,12 +780,15 @@ describe('createPartnerAccount', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('パートナーを追加できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const adminExternalId = 'ADMIN0001'; @@ -826,17 +853,19 @@ describe('createPartnerAccount', () => { pertnerExternalId, ); const createdAccount = await getAccount(source, accountId); - expect(createdAccount.company_name).toBe(companyName); - expect(createdAccount.country).toBe(country); - expect(createdAccount.parent_account_id).toBe(parent.id); - expect(createdAccount.tier).toBe(2); - expect(createdAccount.primary_admin_user_id).toBe(createdUser.id); - expect(createdAccount.secondary_admin_user_id).toBe(null); + expect(createdAccount?.company_name).toBe(companyName); + expect(createdAccount?.country).toBe(country); + expect(createdAccount?.parent_account_id).toBe(parent.id); + expect(createdAccount?.tier).toBe(2); + expect(createdAccount?.primary_admin_user_id).toBe(createdUser?.id); + expect(createdAccount?.secondary_admin_user_id).toBe(null); } }); it('Azure AD B2Cへの接続に失敗した結果アカウントの追加に失敗した場合、エラーとなる(500エラー)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const adminExternalId = 'ADMIN0001'; @@ -911,7 +940,9 @@ describe('createPartnerAccount', () => { }); it('DBへの通信失敗によって500エラーが発生した場合、リカバリ処理としてADB2Cユーザーを削除され、500エラーが返却される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const b2cService = module.get(AdB2cService); const adminExternalId = 'ADMIN0001'; @@ -998,7 +1029,9 @@ describe('createPartnerAccount', () => { }); it('DBへの通信失敗によって500エラーが発生した場合、リカバリ処理が実行されるが、ADB2Cユーザー削除で失敗した場合、500エラーが返却される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const b2cService = module.get(AdB2cService); const adminExternalId = 'ADMIN0001'; @@ -1085,7 +1118,9 @@ describe('createPartnerAccount', () => { }); it('BlobStorageへの通信失敗が原因でアカウントの追加に失敗した場合、リカバリ処理としてADB2C,DB上のデータが削除され、500エラーが返却される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const b2cService = module.get(AdB2cService); @@ -1165,7 +1200,9 @@ describe('createPartnerAccount', () => { }); it('BlobStorageへの通信失敗が原因でアカウントの追加に失敗した場合、リカバリ処理が実行されるが、そのリカバリ処理に失敗した場合、500エラーが返却される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const b2cService = module.get(AdB2cService); @@ -1253,7 +1290,9 @@ describe('createPartnerAccount', () => { }); it('SendGridへの通信失敗によって500エラーが発生した場合、リカバリ処理としてADB2C,DB上のデータ,コンテナが削除され、500エラーが返却される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const b2cService = module.get(AdB2cService); const blobstorageService = @@ -1343,7 +1382,9 @@ describe('createPartnerAccount', () => { }); it('SendGridへの通信失敗によって500エラーが発生した場合、リカバリ処理が実行されるが、そのリカバリ処理に失敗した場合、500エラーが返却される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const b2cService = module.get(AdB2cService); const blobstorageService = @@ -1440,7 +1481,9 @@ describe('createPartnerAccount', () => { }); it('既に登録済みのメールアドレスが原因でアカウントの追加に失敗した場合、エラーとなる(400エラー)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const adminExternalId = 'ADMIN0001'; @@ -1557,7 +1600,7 @@ describe('AccountsService', () => { const adb2cParam = makeDefaultAdB2cMockValue(); const accountsRepositoryMockValue = makeDefaultAccountsRepositoryMockValue(); - accountsRepositoryMockValue.getLicenseSummaryInfo = null; + accountsRepositoryMockValue.getLicenseSummaryInfo = new Error(); const configMockValue = makeDefaultConfigValue(); const sendGridMockValue = makeDefaultSendGridlValue(); const blobStorageMockValue = makeBlobStorageServiceMockValue(); @@ -1804,7 +1847,7 @@ const expectedAccountLisenceCounts = { }; describe('getPartnerAccount', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -1817,12 +1860,15 @@ describe('getPartnerAccount', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('パラメータのアカウント自身と子アカウントに紐つくライセンス情報を取得する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 親アカウントと子アカウント2つ作成 const { id: parentAccountId } = ( @@ -1983,7 +2029,7 @@ describe('getPartnerAccount', () => { }); describe('getPartnerAccount', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -1996,12 +2042,15 @@ describe('getPartnerAccount', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('パラメータのアカウント自身と子アカウントに紐つくライセンス情報を取得する(第五のshortage確認)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 親アカウントと子アカウント2つ作成 const { id: parentAccountId } = ( @@ -2065,6 +2114,7 @@ describe('getPartnerAccount', () => { // 有効期限が迫っていないライセンスを追加(子1:各ステータスのライセンスを1つずつ、計4つ) const status = ['Unallocated', 'Allocated', 'Reusable', 'Deleted']; status.forEach(async (element) => { + if (!source) fail(); await createLicenseSetExpiryDateAndStatus( source, childAccountId1, @@ -2115,7 +2165,7 @@ describe('getPartnerAccount', () => { }); describe('getOrderHistories', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -2128,12 +2178,15 @@ describe('getOrderHistories', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('指定したアカウントIDの注文履歴情報を取得できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const targetAccountId = 10; const targetParentAccountId = 14; @@ -2237,7 +2290,7 @@ describe('getOrderHistories', () => { }); describe('issueLicense', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -2250,12 +2303,15 @@ describe('issueLicense', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('指定した注文を発行済みにする', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const now = new Date(); // 親と子アカウントを作成する @@ -2274,7 +2330,7 @@ describe('issueLicense', () => { }) ).account; // 親と子のユーザーを作成する - const { external_id: externalId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: parentAccountId, external_id: 'userId-parent', role: 'admin', @@ -2333,7 +2389,7 @@ describe('issueLicense', () => { await service.issueLicense( context, childAccountId, - externalId, + user?.external_id ?? '', 2, 'TEST001', ); @@ -2348,7 +2404,9 @@ describe('issueLicense', () => { expect(issuedLicenses.length).toEqual(2); }); it('既に注文が発行済みの場合、エラーとなる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const now = new Date(); // 親と子アカウントを作成する @@ -2367,7 +2425,7 @@ describe('issueLicense', () => { }) ).account; // 親と子のユーザーを作成する - const { external_id: externalId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: parentAccountId, external_id: 'userId-parent', role: 'admin', @@ -2425,20 +2483,28 @@ describe('issueLicense', () => { await service.issueLicense( context, childAccountId, - externalId, + user?.external_id ?? '', 2, 'TEST001', ); //再度同じ処理を行う await expect( - service.issueLicense(context, childAccountId, externalId, 2, 'TEST001'), + service.issueLicense( + context, + childAccountId, + user?.external_id ?? '', + 2, + 'TEST001', + ), ).rejects.toEqual( new HttpException(makeErrorResponse('E010803'), HttpStatus.BAD_REQUEST), ); }); it('ライセンスが不足している場合、エラーとなる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const now = new Date(); // 親と子アカウントを作成する @@ -2457,7 +2523,7 @@ describe('issueLicense', () => { }) ).account; // 親と子のユーザーを作成する - const { external_id: externalId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: parentAccountId, external_id: 'userId-parent', role: 'admin', @@ -2514,7 +2580,13 @@ describe('issueLicense', () => { // 注文を発行済みにする await expect( - service.issueLicense(context, childAccountId, externalId, 2, 'TEST001'), + service.issueLicense( + context, + childAccountId, + user?.external_id ?? '', + 2, + 'TEST001', + ), ).rejects.toEqual( new HttpException(makeErrorResponse('E010804'), HttpStatus.BAD_REQUEST), ); @@ -2522,7 +2594,7 @@ describe('issueLicense', () => { }); describe('getDealers', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -2535,11 +2607,14 @@ describe('getDealers', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('Dealerを取得できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId_1 } = ( await makeTestAccount(source, { parent_account_id: 1, @@ -2587,7 +2662,9 @@ describe('getDealers', () => { }); }); it('0件でもDealerを取得できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); @@ -2598,7 +2675,7 @@ describe('getDealers', () => { }); describe('createTypistGroup', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -2611,11 +2688,14 @@ describe('createTypistGroup', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('TypistGroupを作成できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const adminExternalId = 'admin-external-id'; // 第五階層のアカウント作成 const { id: accountId } = ( @@ -2633,12 +2713,12 @@ describe('createTypistGroup', () => { ]; const userIds: number[] = []; for (const typiptUserExternalId of typiptUserExternalIds) { - const { id: userId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: accountId, external_id: typiptUserExternalId, role: 'typist', }); - userIds.push(userId); + userIds.push(user?.id ?? 0); } //作成したデータを確認 { @@ -2674,7 +2754,9 @@ describe('createTypistGroup', () => { }); it('typistIdsにRole:typist以外のユーザーが含まれていた場合、400エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const adminExternalId = 'admin-external-id'; // 第五階層のアカウント作成 const { id: accountId } = ( @@ -2692,7 +2774,7 @@ describe('createTypistGroup', () => { ]; const userIds: number[] = []; for (const typiptUserExternalId of typiptUserExternalIds) { - const { id: userId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: accountId, external_id: typiptUserExternalId, role: @@ -2700,7 +2782,7 @@ describe('createTypistGroup', () => { ? 'none' : 'typist', //typist-user-external-id3のみRole:none, }); - userIds.push(userId); + userIds.push(user?.id ?? 0); } //作成したデータを確認 { @@ -2727,7 +2809,9 @@ describe('createTypistGroup', () => { ); }); it('typistIdsに存在しないユーザーが含まれていた場合、400エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const adminExternalId = 'admin-external-id'; // 第五階層のアカウント作成 const { id: accountId } = ( @@ -2745,12 +2829,12 @@ describe('createTypistGroup', () => { ]; const userIds: number[] = []; for (const typiptUserExternalId of typiptUserExternalIds) { - const { id: userId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: accountId, external_id: typiptUserExternalId, role: 'typist', }); - userIds.push(userId); + userIds.push(user?.id ?? 0); } //作成したデータを確認 { @@ -2776,7 +2860,9 @@ describe('createTypistGroup', () => { ); }); it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const adminExternalId = 'admin-external-id'; // 第五階層のアカウント作成 const { id: accountId } = ( @@ -2794,12 +2880,12 @@ describe('createTypistGroup', () => { ]; const userIds: number[] = []; for (const typiptUserExternalId of typiptUserExternalIds) { - const { id: userId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: accountId, external_id: typiptUserExternalId, role: 'typist', }); - userIds.push(userId); + userIds.push(user?.id ?? 0); } //作成したデータを確認 { @@ -2838,7 +2924,7 @@ describe('createTypistGroup', () => { }); describe('getTypistGroup', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -2851,11 +2937,14 @@ describe('getTypistGroup', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('指定したIDのTypistGroupを取得できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -2867,12 +2956,12 @@ describe('getTypistGroup', () => { ]; const userIds: number[] = []; for (const typiptUserExternalId of typiptUserExternalIds) { - const { id: userId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: account.id, external_id: typiptUserExternalId, role: USER_ROLES.TYPIST, }); - userIds.push(userId); + userIds.push(user?.id ?? 0); } // アカウントにタイピストグループを作成する @@ -2915,7 +3004,9 @@ describe('getTypistGroup', () => { }); it('指定したタイピストグループIDのタイピストグループが存在しない場合、400エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); // 作成したアカウントにユーザーを3名追加する @@ -2926,12 +3017,12 @@ describe('getTypistGroup', () => { ]; const userIds: number[] = []; for (const typiptUserExternalId of typiptUserExternalIds) { - const { id: userId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: account.id, external_id: typiptUserExternalId, role: USER_ROLES.TYPIST, }); - userIds.push(userId); + userIds.push(user?.id ?? 0); } // アカウントにタイピストグループを作成する @@ -2968,7 +3059,9 @@ describe('getTypistGroup', () => { }); it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); // 作成したアカウントにユーザーを3名追加する @@ -2979,12 +3072,12 @@ describe('getTypistGroup', () => { ]; const userIds: number[] = []; for (const typiptUserExternalId of typiptUserExternalIds) { - const { id: userId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: account.id, external_id: typiptUserExternalId, role: USER_ROLES.TYPIST, }); - userIds.push(userId); + userIds.push(user?.id ?? 0); } // アカウントにタイピストグループを作成する @@ -3031,7 +3124,7 @@ describe('getTypistGroup', () => { }); describe('updateTypistGroup', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -3044,11 +3137,14 @@ describe('updateTypistGroup', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('TypistGroupを更新できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); // 作成したアカウントにユーザーを3名追加する @@ -3059,12 +3155,12 @@ describe('updateTypistGroup', () => { ]; const userIds: number[] = []; for (const typiptUserExternalId of typiptUserExternalIds) { - const { id: userId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: account.id, external_id: typiptUserExternalId, role: USER_ROLES.TYPIST, }); - userIds.push(userId); + userIds.push(user?.id ?? 0); } const service = module.get(AccountsService); @@ -3114,7 +3210,9 @@ describe('updateTypistGroup', () => { } }); it('typistIdsにRole:typist以外のユーザーが含まれていた場合、400エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); // 作成したアカウントにユーザーを3名追加する @@ -3125,7 +3223,7 @@ describe('updateTypistGroup', () => { ]; const userIds: number[] = []; for (const typiptUserExternalId of typiptUserExternalIds) { - const { id: userId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: account.id, external_id: typiptUserExternalId, role: @@ -3133,7 +3231,7 @@ describe('updateTypistGroup', () => { ? USER_ROLES.NONE : USER_ROLES.TYPIST, //typist-user-external-id3のみRole:none }); - userIds.push(userId); + userIds.push(user?.id ?? 0); } const typistGroupName = 'typist-group-name'; @@ -3179,7 +3277,9 @@ describe('updateTypistGroup', () => { } }); it('typistIdsに存在しないユーザーが含まれていた場合、400エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); // 作成したアカウントにユーザーを3名追加する @@ -3190,12 +3290,12 @@ describe('updateTypistGroup', () => { ]; const userIds: number[] = []; for (const typiptUserExternalId of typiptUserExternalIds) { - const { id: userId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: account.id, external_id: typiptUserExternalId, role: USER_ROLES.TYPIST, }); - userIds.push(userId); + userIds.push(user?.id ?? 0); } const typistGroupName = 'typist-group-name'; @@ -3239,7 +3339,9 @@ describe('updateTypistGroup', () => { } }); it('タイピストグループが存在しない場合、400エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -3251,12 +3353,12 @@ describe('updateTypistGroup', () => { ]; const userIds: number[] = []; for (const typiptUserExternalId of typiptUserExternalIds) { - const { id: userId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: account.id, external_id: typiptUserExternalId, role: USER_ROLES.TYPIST, }); - userIds.push(userId); + userIds.push(user?.id ?? 0); } const typistGroupName = 'typist-group-name'; @@ -3301,7 +3403,9 @@ describe('updateTypistGroup', () => { } }); it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); // 作成したアカウントにユーザーを3名追加する @@ -3312,12 +3416,12 @@ describe('updateTypistGroup', () => { ]; const userIds: number[] = []; for (const typiptUserExternalId of typiptUserExternalIds) { - const { id: userId } = await makeTestUser(source, { + const user = await makeTestUser(source, { account_id: account.id, external_id: typiptUserExternalId, role: USER_ROLES.TYPIST, }); - userIds.push(userId); + userIds.push(user?.id ?? 0); } const typistGroupName = 'typist-group-name'; @@ -3373,7 +3477,7 @@ describe('updateTypistGroup', () => { }); describe('getWorktypes', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -3386,12 +3490,15 @@ describe('getWorktypes', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('アカウント内のWorktypeを取得できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -3430,7 +3537,9 @@ describe('getWorktypes', () => { }); it('アカウント内のWorktypeを取得できる(0件)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { admin } = await makeTestAccount(source, { tier: 5 }); @@ -3446,7 +3555,9 @@ describe('getWorktypes', () => { }); it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -3486,7 +3597,7 @@ describe('getWorktypes', () => { }); describe('createWorktype', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -3499,12 +3610,15 @@ describe('createWorktype', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('Worktypeを作成できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -3543,7 +3657,9 @@ describe('createWorktype', () => { }); it('WorktypeIDが登録済みのWorktypeIDと重複した場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -3572,7 +3688,9 @@ describe('createWorktype', () => { }); it('WorktypeIDがすでに最大登録数(20件)まで登録されている場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -3603,7 +3721,9 @@ describe('createWorktype', () => { }); it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { admin } = await makeTestAccount(source, { tier: 5 }); @@ -3630,7 +3750,7 @@ describe('createWorktype', () => { }); describe('updateWorktype', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -3643,12 +3763,15 @@ describe('updateWorktype', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('Worktypeを更新できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -3695,7 +3818,9 @@ describe('updateWorktype', () => { }); it('指定したIDが登録されていない場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -3740,7 +3865,9 @@ describe('updateWorktype', () => { }); it('WorktypeIDが登録済みのWorktypeIDと重複した場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -3800,7 +3927,9 @@ describe('updateWorktype', () => { }); it('WorktypeIDが登録済みの指定IDのWorktypeIDと重複した場合でも更新できること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -3846,7 +3975,9 @@ describe('updateWorktype', () => { }); it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -3897,8 +4028,8 @@ describe('updateWorktype', () => { }); }); -describe('getOptionItems', () => { - let source: DataSource = null; +describe('deleteWorktype', () => { + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -3911,12 +4042,263 @@ describe('getOptionItems', () => { }); afterEach(async () => { + if (!source) return; + await source.destroy(); + source = null; + }); + + it('WorktypeIDを削除できること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + + const service = module.get(AccountsService); + const context = makeContext(admin.external_id); + + const { id: worktypeId1 } = await createWorktype( + source, + account.id, + 'worktype1', + ); + const { id: worktypeId2 } = await createWorktype( + source, + account.id, + 'worktype2', + ); + await createOptionItems(source, worktypeId1); + await createOptionItems(source, worktypeId2); + + // 作成したデータを確認 + { + const worktypes = await getWorktypes(source, account.id); + const optionItems = await getOptionItems(source); + expect(worktypes.length).toBe(2); + expect(worktypes[0].id).toBe(worktypeId1); + expect(worktypes[0].custom_worktype_id).toBe('worktype1'); + expect(worktypes[1].id).toBe(worktypeId2); + expect(worktypes[1].custom_worktype_id).toBe('worktype2'); + expect(optionItems.length).toBe(20); + } + + await service.deleteWorktype(context, admin.external_id, worktypeId1); + + //実行結果を確認 + { + const worktypes = await getWorktypes(source, account.id); + const optionItems = await getOptionItems(source); + expect(worktypes.length).toBe(1); + expect(worktypes[0].id).toBe(worktypeId2); + expect(worktypes[0].custom_worktype_id).toBe('worktype2'); + expect(optionItems.length).toBe(10); + } + }); + + it('指定されたWorktypeIDがアカウントのActiveWorktypeIDの場合、削除できること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + + const service = module.get(AccountsService); + const context = makeContext(admin.external_id); + + const { id: worktypeId1 } = await createWorktype( + source, + account.id, + 'worktype1', + 'description1', + true, + ); + await createOptionItems(source, worktypeId1); + + // 作成したデータを確認 + { + const worktypes = await getWorktypes(source, account.id); + const optionItems = await getOptionItems(source); + const accounts = await getAccounts(source); + + expect(worktypes.length).toBe(1); + expect(worktypes[0].id).toBe(worktypeId1); + expect(worktypes[0].custom_worktype_id).toBe('worktype1'); + expect(optionItems.length).toBe(10); + expect(accounts.length).toBe(1); + expect(accounts[0].active_worktype_id).toBe(worktypeId1); + } + + await service.deleteWorktype(context, admin.external_id, worktypeId1); + + //実行結果を確認 + { + const worktypes = await getWorktypes(source, account.id); + const optionItems = await getOptionItems(source); + const accounts = await getAccounts(source); + expect(worktypes.length).toBe(0); + expect(optionItems.length).toBe(0); + expect(accounts.length).toBe(1); + expect(accounts[0].active_worktype_id).toBe(null); + } + }); + + it('指定したWorktypeIDが登録されていない場合、400エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + + const service = module.get(AccountsService); + const context = makeContext(admin.external_id); + + const { id: worktypeId1 } = await createWorktype( + source, + account.id, + 'worktype1', + ); + await createOptionItems(source, worktypeId1); + + // 作成したデータを確認 + { + const worktypes = await getWorktypes(source, account.id); + const optionItems = await getOptionItems(source); + expect(worktypes.length).toBe(1); + expect(worktypes[0].id).toBe(worktypeId1); + expect(worktypes[0].custom_worktype_id).toBe('worktype1'); + expect(optionItems.length).toBe(10); + } + + try { + await service.deleteWorktype(context, admin.external_id, 9999); + fail(); // 例外が発生しない場合はテスト失敗 + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E011003')); + } else { + fail(); + } + } + }); + + it('指定したIDがWorkflowで使用されている場合、400エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const author = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + + const service = module.get(AccountsService); + const context = makeContext(admin.external_id); + + const { id: worktypeId1 } = await createWorktype( + source, + account.id, + 'worktype1', + ); + await createOptionItems(source, worktypeId1); + await createWorkflow(source, account.id, author?.id ?? 0, worktypeId1); + + // 作成したデータを確認 + { + const worktypes = await getWorktypes(source, account.id); + const optionItems = await getOptionItems(source); + const workflows = await getWorkflows(source, account.id); + expect(worktypes.length).toBe(1); + expect(worktypes[0].id).toBe(worktypeId1); + expect(worktypes[0].custom_worktype_id).toBe('worktype1'); + expect(optionItems.length).toBe(10); + expect(workflows.length).toBe(1); + expect(workflows[0].worktype_id).toBe(worktypeId1); + } + + try { + await service.deleteWorktype(context, admin.external_id, worktypeId1); + fail(); // 例外が発生しない場合はテスト失敗 + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E011004')); + } else { + fail(); + } + } + }); + + it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + + const service = module.get(AccountsService); + const context = makeContext(admin.external_id); + + const { id: worktypeId1 } = await createWorktype( + source, + account.id, + 'worktype1', + ); + await createOptionItems(source, worktypeId1); + + // 作成したデータを確認 + { + const worktypes = await getWorktypes(source, account.id); + const optionItems = await getOptionItems(source); + expect(worktypes.length).toBe(1); + expect(worktypes[0].id).toBe(worktypeId1); + expect(worktypes[0].custom_worktype_id).toBe('worktype1'); + expect(optionItems.length).toBe(10); + } + //DBアクセスに失敗するようにする + const worktypeService = module.get( + WorktypesRepositoryService, + ); + worktypeService.deleteWorktype = jest.fn().mockRejectedValue('DB failed'); + + try { + await service.deleteWorktype(context, admin.external_id, worktypeId1); + fail(); // 例外が発生しない場合はテスト失敗 + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + }); +}); + +describe('getOptionItems', () => { + 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(); + }); + + afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('指定WorktypeIDに紐づいたOptionItemを取得できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -3957,7 +4339,9 @@ describe('getOptionItems', () => { }); it('WorktypeIDが存在しない場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -3990,7 +4374,9 @@ describe('getOptionItems', () => { }); it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -4030,7 +4416,7 @@ describe('getOptionItems', () => { }); describe('updateOptionItems', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -4043,12 +4429,15 @@ describe('updateOptionItems', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('指定WorktypeIDに紐づいたOptionItemを更新できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -4172,7 +4561,9 @@ describe('updateOptionItems', () => { }); it('WorktypeIDが存在しない場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -4256,7 +4647,9 @@ describe('updateOptionItems', () => { }); it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -4349,7 +4742,7 @@ describe('updateOptionItems', () => { }); describe('updateActiveWorktype', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -4362,12 +4755,15 @@ describe('updateActiveWorktype', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('アカウントのActiveWorktypeIDを指定WorktypeIDに更新できる(NULL⇒ID設定)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -4379,20 +4775,22 @@ describe('updateActiveWorktype', () => { //作成したデータを確認 { const beforeAccount = await getAccount(source, account.id); - expect(beforeAccount.active_worktype_id).toBe(null); + expect(beforeAccount?.active_worktype_id).toBe(null); } await service.updateActiveWorktype(context, admin.external_id, worktype.id); //実行結果を確認 { - const { active_worktype_id } = await getAccount(source, account.id); - expect(active_worktype_id).toBe(worktype.id); + const resultsAccount = await getAccount(source, account.id); + expect(resultsAccount?.active_worktype_id).toBe(worktype.id); } }); it('アカウントのActiveWorktypeIDを指定WorktypeIDに更新できる(別のWorkTypeIDを設定)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -4411,7 +4809,7 @@ describe('updateActiveWorktype', () => { //作成したデータを確認 { const beforeAccount = await getAccount(source, account.id); - expect(beforeAccount.active_worktype_id).toBe(worktype1.id); + expect(beforeAccount?.active_worktype_id).toBe(worktype1.id); } await service.updateActiveWorktype( @@ -4422,13 +4820,15 @@ describe('updateActiveWorktype', () => { //実行結果を確認 { - const { active_worktype_id } = await getAccount(source, account.id); - expect(active_worktype_id).toBe(worktype2.id); + const resultsAccount = await getAccount(source, account.id); + expect(resultsAccount?.active_worktype_id).toBe(worktype2.id); } }); it('アカウントのActiveWorktypeIDをNULLに更新できる(WorkTypeID⇒NULL)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -4446,20 +4846,22 @@ describe('updateActiveWorktype', () => { //作成したデータを確認 { const beforeAccount = await getAccount(source, account.id); - expect(beforeAccount.active_worktype_id).toBe(worktype1.id); + expect(beforeAccount?.active_worktype_id).toBe(worktype1.id); } await service.updateActiveWorktype(context, admin.external_id, undefined); //実行結果を確認 { - const { active_worktype_id } = await getAccount(source, account.id); - expect(active_worktype_id).toBe(null); + const resultsAccount = await getAccount(source, account.id); + expect(resultsAccount?.active_worktype_id).toBe(null); } }); it('自アカウント内に指定されたIDのWorktypeIDが存在しない場合、400エラーとなること(WorkTypeIDが存在しない場合)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -4471,7 +4873,7 @@ describe('updateActiveWorktype', () => { //作成したデータを確認 { const beforeAccount = await getAccount(source, account.id); - expect(beforeAccount.active_worktype_id).toBe(null); + expect(beforeAccount?.active_worktype_id).toBe(null); } try { @@ -4487,7 +4889,9 @@ describe('updateActiveWorktype', () => { }); it('自アカウント内に指定されたIDのWorktypeIDが存在しない場合、400エラーとなること(WorkTypeIDが別アカウントの場合)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { account: otherAccount } = await makeTestAccount(source, { @@ -4505,7 +4909,7 @@ describe('updateActiveWorktype', () => { const beforeAccount = await getAccount(source, account.id); const worktype1 = await getWorktypes(source, account.id); const worktype2 = await getWorktypes(source, otherAccount.id); - expect(beforeAccount.active_worktype_id).toBe(null); + expect(beforeAccount?.active_worktype_id).toBe(null); expect(worktype1.length).toBe(1); expect(worktype1[0].custom_worktype_id).toBe('worktype1'); @@ -4527,7 +4931,9 @@ describe('updateActiveWorktype', () => { }); it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -4539,7 +4945,7 @@ describe('updateActiveWorktype', () => { //作成したデータを確認 { const beforeAccount = await getAccount(source, account.id); - expect(beforeAccount.active_worktype_id).toBe(null); + expect(beforeAccount?.active_worktype_id).toBe(null); } //DBアクセスに失敗するようにする @@ -4564,7 +4970,7 @@ describe('updateActiveWorktype', () => { }); describe('ライセンス発行キャンセル', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -4577,11 +4983,14 @@ describe('ライセンス発行キャンセル', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('ライセンス発行のキャンセルが完了する(第一階層で実行)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { tier1Accounts: tier1Accounts, tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts(source); const tier5Accounts = await makeTestAccount(source, { @@ -4595,7 +5004,7 @@ describe('ライセンス発行キャンセル', () => { source, poNumber, tier5Accounts.account.id, - tier5Accounts.account.parent_account_id, + tier5Accounts.account?.parent_account_id ?? 0, date, 1, LICENSE_ISSUE_STATUS.ISSUED, @@ -4629,20 +5038,22 @@ describe('ライセンス発行キャンセル', () => { tier5Accounts.account.id, poNumber, ); - expect(orderRecord.orderLicense.issued_at).toBe(null); - expect(orderRecord.orderLicense.status).toBe( + expect(orderRecord.orderLicense?.issued_at).toBe(null); + expect(orderRecord.orderLicense?.status).toBe( LICENSE_ISSUE_STATUS.ISSUE_REQUESTING, ); // 未割当に戻したライセンスの状態確認 const licenseRecord = await selectLicense(source, 1); - expect(licenseRecord.license.status).toBe( + expect(licenseRecord.license?.status).toBe( LICENSE_ALLOCATED_STATUS.UNALLOCATED, ); - expect(licenseRecord.license.delete_order_id).toBe(null); - expect(licenseRecord.license.deleted_at).toBe(null); + expect(licenseRecord.license?.delete_order_id).toBe(null); + expect(licenseRecord.license?.deleted_at).toBe(null); }); it('ライセンス発行のキャンセルが完了する(第二階層で実行)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { tier2Accounts: tier2Accounts, tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts(source); const tier5Accounts = await makeTestAccount(source, { @@ -4656,7 +5067,7 @@ describe('ライセンス発行キャンセル', () => { source, poNumber, tier5Accounts.account.id, - tier5Accounts.account.parent_account_id, + tier5Accounts.account?.parent_account_id ?? 0, date, 1, LICENSE_ISSUE_STATUS.ISSUED, @@ -4690,20 +5101,22 @@ describe('ライセンス発行キャンセル', () => { tier5Accounts.account.id, poNumber, ); - expect(orderRecord.orderLicense.issued_at).toBe(null); - expect(orderRecord.orderLicense.status).toBe( + expect(orderRecord.orderLicense?.issued_at).toBe(null); + expect(orderRecord.orderLicense?.status).toBe( LICENSE_ISSUE_STATUS.ISSUE_REQUESTING, ); // 未割当に戻したライセンスの状態確認 const licenseRecord = await selectLicense(source, 1); - expect(licenseRecord.license.status).toBe( + expect(licenseRecord.license?.status).toBe( LICENSE_ALLOCATED_STATUS.UNALLOCATED, ); - expect(licenseRecord.license.delete_order_id).toBe(null); - expect(licenseRecord.license.deleted_at).toBe(null); + expect(licenseRecord.license?.delete_order_id).toBe(null); + expect(licenseRecord.license?.deleted_at).toBe(null); }); it('キャンセル対象の発行が存在しない場合エラー', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { tier1Accounts: tier1Accounts, tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts(source); const tier5Accounts = await makeTestAccount(source, { @@ -4724,7 +5137,9 @@ describe('ライセンス発行キャンセル', () => { ); }); it('キャンセル対象の発行が14日より経過していた場合エラー', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { tier1Accounts: tier1Accounts, tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts(source); const tier5Accounts = await makeTestAccount(source, { @@ -4738,7 +5153,7 @@ describe('ライセンス発行キャンセル', () => { source, poNumber, tier5Accounts.account.id, - tier5Accounts.account.parent_account_id, + tier5Accounts.account?.parent_account_id ?? 0, date, 1, LICENSE_ISSUE_STATUS.ISSUED, @@ -4768,7 +5183,9 @@ describe('ライセンス発行キャンセル', () => { ); }); it('キャンセル対象の発行のライセンスが使われていた場合エラー', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { tier1Accounts: tier1Accounts, tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts(source); const tier5Accounts = await makeTestAccount(source, { @@ -4782,7 +5199,7 @@ describe('ライセンス発行キャンセル', () => { source, poNumber, tier5Accounts.account.id, - tier5Accounts.account.parent_account_id, + tier5Accounts.account?.parent_account_id ?? 0, date, 1, LICENSE_ISSUE_STATUS.ISSUED, @@ -4812,7 +5229,9 @@ describe('ライセンス発行キャンセル', () => { ); }); it('自身のパートナー以外の発行をキャンセルしようとした場合、エラー', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { tier1Accounts: tier1Accounts } = await makeHierarchicalAccounts( source, ); @@ -4827,7 +5246,7 @@ describe('ライセンス発行キャンセル', () => { source, poNumber, tier5Accounts.account.id, - tier5Accounts.account.parent_account_id, + tier5Accounts.account?.parent_account_id ?? 0, date, 1, LICENSE_ISSUE_STATUS.ISSUED, @@ -4859,7 +5278,7 @@ describe('ライセンス発行キャンセル', () => { }); describe('パートナー一覧取得', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -4872,13 +5291,16 @@ describe('パートナー一覧取得', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('パートナー一覧を取得する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); - const { tier1Accounts: tier1Accounts, tier2Accounts: tier2Accounts } = + const { tier1Accounts, tier2Accounts, tier3Accounts, tier4Accounts } = await makeHierarchicalAccounts(source); const tier1Difference = await makeTestAccount(source, { tier: 1, @@ -4900,7 +5322,7 @@ describe('パートナー一覧取得', () => { tier: 2, }, {}, - true, + false, true, ); @@ -4915,7 +5337,7 @@ describe('パートナー一覧取得', () => { displayName: 'partner1', identities: [ { - signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS, + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, issuer: 'issuer', issuerAssignedId: 'partner1@example.com', }, @@ -4926,12 +5348,34 @@ describe('パートナー一覧取得', () => { displayName: 'partner2', identities: [ { - signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS, + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, issuer: 'issuer', issuerAssignedId: 'partner2@example.com', }, ], }, + { + id: tier2_3.admin.external_id, + displayName: 'partner3', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'partner3@example.com', + }, + ], + }, + { + id: tier2_4.admin.external_id, + displayName: 'partner3', + identities: [ + { + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + issuer: 'issuer', + issuerAssignedId: 'partner3@example.com', + }, + ], + }, ] as AdB2cUser[]; overrideAdB2cService(service, { @@ -4973,7 +5417,9 @@ describe('パートナー一覧取得', () => { ); }); it('パートナー一覧を取得する(パートナーが0件の場合)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const account = await makeTestAccount(source, { tier: 1, @@ -5000,7 +5446,7 @@ describe('パートナー一覧取得', () => { }); describe('アカウント情報更新', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -5013,11 +5459,14 @@ describe('アカウント情報更新', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('アカウント情報を更新する(第五階層が実行/セカンダリ管理者ユーザがnull)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts( source, @@ -5038,13 +5487,15 @@ describe('アカウント情報更新', () => { // DB内が想定通りになっているか確認 const account = await getAccount(source, tier5Accounts.account.id); - expect(account.parent_account_id).toBe(tier4Accounts[0].account.id); - expect(account.delegation_permission).toBe(true); - expect(account.primary_admin_user_id).toBe(tier5Accounts.admin.id); - expect(account.secondary_admin_user_id).toBe(null); + expect(account?.parent_account_id).toBe(tier4Accounts[0].account.id); + expect(account?.delegation_permission).toBe(true); + expect(account?.primary_admin_user_id).toBe(tier5Accounts.admin.id); + expect(account?.secondary_admin_user_id).toBe(null); }); it('アカウント情報を更新する(第五階層以外が実行)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const { tier3Accounts: tier3Accounts, tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts(source); @@ -5060,21 +5511,24 @@ describe('アカウント情報更新', () => { false, tier4Accounts[0].users[0].id, tier3Accounts[0].account.id, - adduser.id, + adduser?.id, ); // DB内が想定通りになっているか確認 const account = await getAccount(source, tier4Accounts[0].account.id); - expect(account.parent_account_id).toBe(tier3Accounts[0].account.id); - expect(account.delegation_permission).toBe(false); - expect(account.primary_admin_user_id).toBe(tier4Accounts[0].users[0].id); - expect(account.secondary_admin_user_id).toBe(adduser.id); + expect(account?.parent_account_id).toBe(tier3Accounts[0].account.id); + expect(account?.delegation_permission).toBe(false); + expect(account?.primary_admin_user_id).toBe(tier4Accounts[0].users[0].id); + expect(account?.secondary_admin_user_id).toBe(adduser?.id); }); it('アカウント情報を更新する(ディーラーアカウントが未入力)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); - const { tier3Accounts: tier3Accounts, tier4Accounts: tier4Accounts } = - await makeHierarchicalAccounts(source); + const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts( + source, + ); const adduser = await makeTestUser(source, { account_id: tier4Accounts[0].account.id, external_id: 'typist-user-external-id', @@ -5087,18 +5541,20 @@ describe('アカウント情報更新', () => { false, tier4Accounts[0].users[0].id, undefined, - adduser.id, + adduser?.id, ); // DB内が想定通りになっているか確認 const account = await getAccount(source, tier4Accounts[0].account.id); - expect(account.parent_account_id).toBe(null); - expect(account.delegation_permission).toBe(false); - expect(account.primary_admin_user_id).toBe(tier4Accounts[0].users[0].id); - expect(account.secondary_admin_user_id).toBe(adduser.id); + expect(account?.parent_account_id).toBe(null); + expect(account?.delegation_permission).toBe(false); + expect(account?.primary_admin_user_id).toBe(tier4Accounts[0].users[0].id); + expect(account?.secondary_admin_user_id).toBe(adduser?.id); }); it('アカウント情報の更新に失敗する(ディーラー未存在)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts( source, @@ -5116,14 +5572,16 @@ describe('アカウント情報更新', () => { false, tier4Accounts[0].users[0].id, 123, - adduser.id, + adduser?.id, ), ).rejects.toEqual( new HttpException(makeErrorResponse('E010502'), HttpStatus.BAD_REQUEST), ); }); it('アカウント情報の更新に失敗する(プライマリ管理者ユーザ未存在)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts( source, @@ -5147,7 +5605,9 @@ describe('アカウント情報更新', () => { ); }); it('アカウント情報の更新に失敗する(セカンダリ管理者ユーザ未存在)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts( source, @@ -5173,7 +5633,7 @@ describe('アカウント情報更新', () => { }); describe('getAccountInfo', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -5186,11 +5646,14 @@ describe('getAccountInfo', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('パラメータのユーザに対応するアカウント情報を取得できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts( source, ); @@ -5230,7 +5693,7 @@ describe('getAccountInfo', () => { }); }); describe('getAuthors', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -5243,25 +5706,28 @@ describe('getAuthors', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('アカウント内のAuthorユーザーの一覧を取得できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); - const { id: userId1 } = await makeTestUser(source, { + const userId1 = await makeTestUser(source, { account_id: account.id, role: USER_ROLES.AUTHOR, author_id: 'AUTHOR_ID_1', }); - const { id: userId2 } = await makeTestUser(source, { + const userId2 = await makeTestUser(source, { account_id: account.id, role: USER_ROLES.AUTHOR, author_id: 'AUTHOR_ID_2', }); - const { id: userId3 } = await makeTestUser(source, { + const userId3 = await makeTestUser(source, { account_id: account.id, role: USER_ROLES.TYPIST, }); @@ -5270,9 +5736,9 @@ describe('getAuthors', () => { { const users = await getUsers(source); expect(users.length).toBe(4); - expect(users[1].id).toBe(userId1); - expect(users[2].id).toBe(userId2); - expect(users[3].id).toBe(userId3); + expect(users[1].id).toBe(userId1.id); + expect(users[2].id).toBe(userId2.id); + expect(users[3].id).toBe(userId3.id); } const service = module.get(AccountsService); @@ -5282,14 +5748,16 @@ describe('getAuthors', () => { //実行結果を確認 { expect(authors.length).toBe(2); - expect(authors[0].id).toBe(userId1); + expect(authors[0].id).toBe(userId1.id); expect(authors[0].authorId).toBe('AUTHOR_ID_1'); - expect(authors[1].id).toBe(userId2); + expect(authors[1].id).toBe(userId2.id); expect(authors[1].authorId).toBe('AUTHOR_ID_2'); } }); it('アカウント内のAuthorユーザーの一覧を取得できる(0件)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { admin } = await makeTestAccount(source, { tier: 5 }); @@ -5309,7 +5777,9 @@ describe('getAuthors', () => { } }); it('DBアクセスに失敗した場合、500エラーとなる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { admin } = await makeTestAccount(source, { tier: 5 }); @@ -5336,7 +5806,7 @@ describe('getAuthors', () => { }); }); describe('deleteAccountAndData', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -5349,50 +5819,133 @@ describe('deleteAccountAndData', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('アカウント情報が削除されること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); - // 第五階層のアカウント作成 - const tier4Accounts = await makeHierarchicalAccounts(source); - const { account: account1, admin: admin1 } = await makeTestAccount(source, { - parent_account_id: tier4Accounts.tier4Accounts[0].account.id, - }); - const account = account1; - const admin = admin1; - const context = makeContext(admin.external_id); - // 第五階層のアカウント作成 - const tier5Accounts = await makeTestAccount(source, { - parent_account_id: account.id, + // 第一~第四階層のアカウント作成 + const { + tier1Accounts: tier1Accounts, + tier2Accounts: tier2Accounts, + tier3Accounts: tier3Accounts, + tier4Accounts: tier4Accounts, + } = await makeHierarchicalAccounts(source); + + // 第五階層のアカウント作成(A) + const tier5AccountsA = await makeTestAccount(source, { + parent_account_id: tier4Accounts[0].account.id, tier: 5, }); - - // ユーザの作成 - const user = await makeTestUser(source, { - account_id: tier5Accounts.account.id, + // 第五階層のアカウント作成(B) + const tier5AccountsB = await makeTestAccount(source, { + parent_account_id: tier4Accounts[0].account.id, + tier: 5, }); - // ライセンス作成 - await createLicense( + // ユーザの作成(A) + const userA = await makeTestUser(source, { + account_id: tier5AccountsA.account.id, + }); + // ユーザの作成(B) + const userB = await makeTestUser(source, { + account_id: tier5AccountsB.account.id, + }); + + const context = makeContext(tier5AccountsA.admin.external_id); + // 第一階層~第五階層までのライセンス注文を作成 + await createLicenseOrder( source, - 1, - new Date(), - tier5Accounts.account.id, - LICENSE_TYPE.NORMAL, - LICENSE_ALLOCATED_STATUS.UNALLOCATED, - null, - user.id, - null, - null, + tier2Accounts[0].account.id, + tier1Accounts[0].account.id, + 100, + 'PO001', ); - await createLicenseAllocationHistory( + await createLicenseOrder( source, + tier3Accounts[0].account.id, + tier2Accounts[0].account.id, + 90, + 'PO002', + ); + await createLicenseOrder( + source, + tier4Accounts[0].account.id, + tier3Accounts[0].account.id, + 80, + 'PO003', + ); + await createLicenseOrder( + source, + tier5AccountsA.account.id, + tier4Accounts[0].account.id, + 40, + 'PO004A', + ); + await createLicenseOrder( + source, + tier5AccountsB.account.id, + tier4Accounts[0].account.id, + 40, + 'PO004B', + ); + + // 第一階層~第五階層までのライセンス注文を発行済みにする + await service.issueLicense( + context, + tier2Accounts[0].account.id, + tier1Accounts[0].users[0].external_id, 1, - user.id, - 1, - tier5Accounts.account.id, - 'NONE', + 'PO001', + ); + await service.issueLicense( + context, + tier3Accounts[0].account.id, + tier2Accounts[0].users[0].external_id, + 2, + 'PO002', + ); + await service.issueLicense( + context, + tier4Accounts[0].account.id, + tier3Accounts[0].users[0].external_id, + 3, + 'PO003', + ); + await service.issueLicense( + context, + tier5AccountsA.account.id, + tier4Accounts[0].users[0].external_id, + 4, + 'PO004A', + ); + await service.issueLicense( + context, + tier5AccountsB.account.id, + tier4Accounts[0].users[0].external_id, + 4, + 'PO004B', + ); + // アカウントAのライセンスを取得する + const licensesA = await getLicenses(source, tier5AccountsA.account.id); + // アカウントAのライセンスを取得する + const licensesB = await getLicenses(source, tier5AccountsB.account.id); + + const usersService = module.get(UsersService); + // アカウントAのライセンスを割り当てる + await usersService.allocateLicense( + context, + userA?.id ?? 0, + licensesA[0].id, + ); + // アカウントBのライセンスを割り当てる + await usersService.allocateLicense( + context, + userB?.id ?? 0, + licensesB[0].id, ); // ADB2Cユーザーの削除成功 @@ -5403,32 +5956,77 @@ describe('deleteAccountAndData', () => { overrideBlobstorageService(service, { deleteContainer: jest.fn(), }); + // アカウント情報の削除 await service.deleteAccountAndData( context, - tier5Accounts.admin.external_id, - tier5Accounts.account.id, + tier5AccountsA.admin.external_id, + tier5AccountsA.account.id, ); - // DB内が想定通りになっているか確認 - const accountRecord = await getAccount(source, tier5Accounts.account.id); - expect(accountRecord).toBe(null); + // 第五階層のアカウントAが削除されていること + const accountRecordA = await getAccount(source, tier5AccountsA.account.id); + expect(accountRecordA).toBe(null); + const userRecordA = await getUser(source, userA?.id ?? 0); + expect(userRecordA).toBe(null); - const userRecord = await getUser(source, user.id); - expect(userRecord).toBe(null); + // 第五階層のアカウントAのライセンスが削除されていること + const licenseRecordA = await source.manager.find(License, { + where: { account_id: tier5AccountsA.account.id }, + }); + expect(licenseRecordA.length).toBe(0); + // 第五階層のアカウントAのライセンス注文履歴が削除されていること + const licenseOrderRecordA = await source.manager.find(LicenseOrder, { + where: { from_account_id: tier5AccountsA.account.id }, + }); + expect(licenseOrderRecordA.length).toBe(0); + // 第五階層のアカウントAのライセンス割り当て履歴が削除されていること + const LicenseAllocationHistoryRecordA = await source.manager.find( + LicenseAllocationHistory, + { + where: { account_id: tier5AccountsA.account.id }, + }, + ); + expect(LicenseAllocationHistoryRecordA.length).toBe(0); + + // 第五階層のアカウントBは削除されていないこと + const accountRecordB = await getAccount(source, tier5AccountsB.account.id); + expect(accountRecordB?.id).not.toBeNull(); + const userRecordB = await getUser(source, userB?.id ?? 0); + expect(userRecordB).not.toBeNull(); + // 第五階層のアカウントBのライセンスが削除されていないこと + const licenseRecordB = await source.manager.find(License, { + where: { account_id: tier5AccountsB.account.id }, + }); + expect(licenseRecordB.length).not.toBe(0); + // 第五階層のアカウントBのライセンス注文履歴が削除されていないこと + const licenseOrderRecordB = await source.manager.find(LicenseOrder, { + where: { from_account_id: tier5AccountsB.account.id }, + }); + expect(licenseOrderRecordB.length).not.toBe(0); + // 第五階層のアカウントBのライセンス割り当て履歴が削除されていないこと + const LicenseAllocationHistoryRecordB = await source.manager.find( + LicenseAllocationHistory, + { + where: { account_id: tier5AccountsB.account.id }, + }, + ); + expect(LicenseAllocationHistoryRecordB.length).not.toBe(0); const UserArchive = await getUserArchive(source); expect(UserArchive.length).toBe(2); const LicenseArchive = await getLicenseArchive(source); - expect(LicenseArchive.length).toBe(1); + expect(LicenseArchive.length).toBe(40); const LicenseAllocationHistoryArchive = await getLicenseAllocationHistoryArchive(source); expect(LicenseAllocationHistoryArchive.length).toBe(1); }); it('アカウントの削除に失敗した場合はエラーを返す', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const loggerSpy = jest.spyOn(service['logger'], 'log').mockImplementation(); // 第五階層のアカウント作成 @@ -5485,12 +6083,14 @@ describe('deleteAccountAndData', () => { // DB内が削除されていないことを確認 const accountRecord = await getAccount(source, tier5Accounts.account.id); - expect(accountRecord.id).not.toBeNull(); - const userRecord = await getUser(source, user.id); - expect(userRecord.id).not.toBeNull(); + expect(accountRecord?.id).not.toBeNull(); + const userRecord = await getUser(source, user?.id ?? 0); + expect(userRecord?.id).not.toBeNull(); }); it('ADB2Cユーザーの削除失敗時は、MANUAL_RECOVERY_REQUIREDを出して処理続行', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const loggerSpy = jest.spyOn(service['logger'], 'log').mockImplementation(); // 第五階層のアカウント作成 @@ -5538,11 +6138,13 @@ describe('deleteAccountAndData', () => { // DB内が想定通りになっているか確認 const accountRecord = await getAccount(source, tier5Accounts.account.id); expect(accountRecord).toBe(null); - const userRecord = await getUser(source, user.id); + const userRecord = await getUser(source, user?.id ?? 0); expect(userRecord).toBe(null); }); it('blobstorageコンテナを削除で失敗した場合は、MANUAL_RECOVERY_REQUIRED出して正常終了', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(AccountsService); const loggerSpy = jest.spyOn(service['logger'], 'log').mockImplementation(); @@ -5591,7 +6193,140 @@ describe('deleteAccountAndData', () => { // DB内が想定通りになっているか確認 const accountRecord = await getAccount(source, tier5Accounts.account.id); expect(accountRecord).toBe(null); - const userRecord = await getUser(source, user.id); + const userRecord = await getUser(source, user?.id ?? 0); expect(userRecord).toBe(null); }); }); +describe('getAccountInfoMinimalAccess', () => { + 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(); + }); + + afterEach(async () => { + if (!source) return; + await source.destroy(); + source = null; + }); + it('IDトークンのsub情報からアカウントの階層情報を取得できること(第五階層)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AccountsService); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { + tier: 5, + }); + const context = makeContext(admin.external_id); + + // 作成したデータを確認 + { + const tier5Account = await getAccount(source, account.id); + expect(tier5Account?.tier).toBe(5); + } + + const tier = await service.getAccountInfoMinimalAccess( + context, + admin.external_id, + ); + + //実行結果を確認 + expect(tier).toBe(5); + }); + it('IDトークンのSub情報からアカウントの階層情報を取得できること(第四階層)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AccountsService); + // 第四階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { + tier: 4, + }); + const context = makeContext(admin.external_id); + + // 作成したデータを確認 + { + const tier5Account = await getAccount(source, account.id); + expect(tier5Account?.tier).toBe(4); + } + + const tier = await service.getAccountInfoMinimalAccess( + context, + admin.external_id, + ); + + //実行結果を確認 + expect(tier).toBe(4); + }); + it('対象のユーザーが存在しない場合、400エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AccountsService); + // 第四階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { + tier: 4, + }); + const context = makeContext(admin.external_id); + + // 作成したデータを確認 + { + const tier5Account = await getAccount(source, account.id); + expect(tier5Account?.tier).toBe(4); + } + + try { + await service.getAccountInfoMinimalAccess(context, 'fail_external_id'); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010204')); + } else { + fail(); + } + } + }); + it('DBアクセスに失敗した場合、500エラーとなること', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AccountsService); + // 第四階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { + tier: 4, + }); + const context = makeContext(admin.external_id); + + // 作成したデータを確認 + { + const tier5Account = await getAccount(source, account.id); + expect(tier5Account?.tier).toBe(4); + } + + //DBアクセスに失敗するようにする + const usersRepositoryService = module.get( + UsersRepositoryService, + ); + usersRepositoryService.findUserByExternalId = jest + .fn() + .mockRejectedValue('DB failed'); + + try { + await service.getAccountInfoMinimalAccess(context, admin.external_id); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + }); +}); diff --git a/dictation_server/src/features/accounts/accounts.service.ts b/dictation_server/src/features/accounts/accounts.service.ts index 0587575..985b764 100644 --- a/dictation_server/src/features/accounts/accounts.service.ts +++ b/dictation_server/src/features/accounts/accounts.service.ts @@ -33,6 +33,7 @@ import { GetPartnersResponse, PostWorktypeOptionItem, Author, + Partner, } from './types/types'; import { DateWithZeroTime, @@ -65,12 +66,15 @@ import { import { WorktypesRepositoryService } from '../../repositories/worktypes/worktypes.repository.service'; import { WorktypeIdAlreadyExistsError, + WorktypeIdInUseError, WorktypeIdMaxCountError, WorktypeIdNotFoundError, } from '../../repositories/worktypes/errors/types'; @Injectable() export class AccountsService { + private readonly mailFrom = + this.configService.getOrThrow('MAIL_FROM'); constructor( private readonly accountRepository: AccountsRepositoryService, private readonly licensesRepository: LicensesRepositoryService, @@ -256,9 +260,6 @@ export class AccountsService { } try { - // メールの送信元を取得 - const from = this.configService.get('MAIL_FROM') ?? ''; - // メールの内容を構成 const { subject, text, html } = await this.sendgridService.createMailContentFromEmailConfirm( @@ -272,7 +273,7 @@ export class AccountsService { await this.sendgridService.sendMail( context, email, - from, + this.mailFrom, subject, text, html, @@ -393,7 +394,7 @@ export class AccountsService { userInfo.account_id, ); - let parentInfo: Account; + let parentInfo: Account | undefined; if (accountInfo.parent_account_id) { parentInfo = await this.accountRepository.findAccountById( accountInfo.parent_account_id, @@ -480,14 +481,20 @@ export class AccountsService { const { account_id } = await this.usersRepository.findUserByExternalId( externalId, ); - const userGroup = await this.userGroupsRepository.getTypistGroup( - account_id, - typistGroupId, - ); + const { name, userGroupMembers } = + await this.userGroupsRepository.getTypistGroup( + account_id, + typistGroupId, + ); + if (!userGroupMembers) { + throw new TypistGroupNotExistError( + `Typist Group is not exist. typistGroupId: ${typistGroupId}`, + ); + } return { - typistGroupName: userGroup.name, - typistIds: userGroup.userGroupMembers.map((x) => x.user_id), + typistGroupName: name, + typistIds: userGroupMembers.map((x) => x.user_id), }; } catch (e) { this.logger.error(`error=${e}`); @@ -540,6 +547,11 @@ export class AccountsService { const typists = typistUsers.map((x) => { const user = adb2cUsers.find((adb2c) => adb2c.id === x.external_id); + if (!user) { + throw new Error( + `user not found. externalId: ${x.external_id}, userId: ${x.id}`, + ); + } return { id: x.id, name: user.displayName, @@ -585,6 +597,11 @@ export class AccountsService { ); const authors = authorUsers.map((x) => { + if (!x.author_id) { + throw new Error( + `author_id is Not Found. externalId: ${x.external_id}, userId: ${x.id}`, + ); + } return { id: x.id, authorId: x.author_id, @@ -700,8 +717,8 @@ export class AccountsService { creatorAccountTier + 1, externalUser.sub, USER_ROLES.NONE, - null, - null, + undefined, + undefined, ); account = newAccount; user = adminUser; @@ -742,7 +759,6 @@ export class AccountsService { } try { - const from = this.configService.get('MAIL_FROM') || ''; const { subject, text, html } = await this.sendgridService.createMailContentFromEmailConfirmForNormalUser( account.id, @@ -752,7 +768,7 @@ export class AccountsService { await this.sendgridService.sendMail( context, email, - from, + this.mailFrom, subject, text, html, @@ -835,11 +851,19 @@ export class AccountsService { // 各子アカウントのShortageを算出してreturn用の変数にマージする const childrenPartnerLicenses: PartnerLicenseInfo[] = []; for (const childPartnerLicenseFromRepository of getPartnerLicenseResult.childPartnerLicensesFromRepository) { - let childShortage; + const { allocatableLicenseWithMargin, expiringSoonLicense } = + childPartnerLicenseFromRepository; + let childShortage: number = 0; if (childPartnerLicenseFromRepository.tier === TIERS.TIER5) { - childShortage = - childPartnerLicenseFromRepository.allocatableLicenseWithMargin - - childPartnerLicenseFromRepository.expiringSoonLicense; + if ( + allocatableLicenseWithMargin === undefined || + expiringSoonLicense === undefined + ) { + throw new Error( + `Tier5 account has no allocatableLicenseWithMargin or expiringSoonLicense. accountId: ${accountId}`, + ); + } + childShortage = allocatableLicenseWithMargin - expiringSoonLicense; } else { childShortage = childPartnerLicenseFromRepository.stockLicense - @@ -906,13 +930,13 @@ export class AccountsService { licenseOrder.issued_at !== null ? new Date(licenseOrder.issued_at) .toISOString() - .substr(0, 10) + .substring(0, 10) .replace(/-/g, '/') - : null, + : undefined, numberOfOrder: licenseOrder.quantity, orderDate: new Date(licenseOrder.ordered_at) .toISOString() - .substr(0, 10) + .substring(0, 10) .replace(/-/g, '/'), poNumber: licenseOrder.po_number, status: licenseOrder.status, @@ -1417,6 +1441,81 @@ export class AccountsService { } } + /** + * ワークタイプを削除します + * @param context + * @param externalId + * @param id + * @returns worktype + */ + async deleteWorktype( + context: Context, + externalId: string, + id: number, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.deleteWorktype.name} | params: { ` + + `externalId: ${externalId}, ` + + `id: ${id} };`, + ); + + try { + // 外部IDをもとにユーザー情報を取得する + const { account, account_id: accountId } = + await this.usersRepository.findUserByExternalId(externalId); + + if (!account) { + throw new AccountNotFoundError( + `account not found. externalId: ${externalId}`, + ); + } + + // ワークタイプを削除する + await this.worktypesRepository.deleteWorktype(accountId, id); + } catch (e) { + this.logger.error(`error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case UserNotFoundError: + throw new HttpException( + makeErrorResponse('E010204'), + HttpStatus.BAD_REQUEST, + ); + case AccountNotFoundError: + throw new HttpException( + makeErrorResponse('E010501'), + HttpStatus.BAD_REQUEST, + ); + // 内部IDで指定されたWorktypeが存在しない場合は400エラーを返す + case WorktypeIdNotFoundError: + throw new HttpException( + makeErrorResponse('E011003'), + HttpStatus.BAD_REQUEST, + ); + // 内部IDで指定されたWorktypeがWorkflowで使用中の場合は400エラーを返す + case WorktypeIdInUseError: + throw new HttpException( + makeErrorResponse('E011004'), + 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.trackingId}] ${this.deleteWorktype.name}`, + ); + } + } + /** * ワークタイプに紐づいたオプションアイテム一覧を取得します * @param context @@ -1558,7 +1657,7 @@ export class AccountsService { async updateActiveWorktype( context: Context, externalId: string, - id: number, + id: number | undefined, ): Promise { this.logger.log( `[IN] [${context.trackingId}] ${this.updateActiveWorktype.name} | params: { ` + @@ -1625,34 +1724,40 @@ export class AccountsService { const { account_id: accountId } = await this.usersRepository.findUserByExternalId(externalId); - const partners = await this.accountRepository.getPartners( + const partnersRecords = await this.accountRepository.getPartners( accountId, limit, offset, ); // DBから取得したユーザーの外部IDをもとにADB2Cからユーザーを取得する - let externalIds = partners.partnersInfo.map( + let externalIds = partnersRecords.partnersInfo.map( (x) => x.primaryAccountExternalId, ); externalIds = externalIds.filter((item) => item !== undefined); const adb2cUsers = await this.adB2cService.getUsers(context, externalIds); // DBから取得した情報とADB2Cから取得した情報をマージ - const response = partners.partnersInfo.map((db) => { + const partners = partnersRecords.partnersInfo.map((db): Partner => { const adb2cUser = adb2cUsers.find( (adb2c) => db.primaryAccountExternalId === adb2c.id, ); - - let primaryAdmin = undefined; - let mail = undefined; - if (adb2cUser) { - primaryAdmin = adb2cUser.displayName; - mail = adb2cUser.identities.find( - (identity) => - identity.signInType === ADB2C_SIGN_IN_TYPE.EAMILADDRESS, - ).issuerAssignedId; + if (!adb2cUser) { + throw new Error( + `adb2c user not found. externalId: ${db.primaryAccountExternalId}`, + ); } + + const primaryAdmin = adb2cUser.displayName; + const mail = adb2cUser.identities?.find( + (identity) => identity.signInType === ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + )?.issuerAssignedId; + if (!mail) { + throw new Error( + `adb2c user mail not found. externalId: ${db.primaryAccountExternalId}`, + ); + } + return { name: db.name, tier: db.tier, @@ -1665,17 +1770,15 @@ export class AccountsService { }); return { - total: partners.total, - partners: response, + total: partnersRecords.total, + partners: partners, }; } catch (e) { this.logger.error(`error=${e}`); - if (e instanceof Error) { - throw new HttpException( - makeErrorResponse('E009999'), - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); } finally { this.logger.log(`[OUT] [${context.trackingId}] ${this.getPartners.name}`); } @@ -1843,4 +1946,61 @@ export class AccountsService { `[OUT] [${context.trackingId}] ${this.deleteAccountAndData.name}`, ); } + + /** + * IDトークンのsubからアカウントの階層情報を取得します + * @param context + * @param externalId + * @returns account info minimal access + */ + async getAccountInfoMinimalAccess( + context: Context, + externalId: string, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.getAccountInfoMinimalAccess.name} | params: { externalId: ${externalId} };`, + ); + + try { + const { account } = await this.usersRepository.findUserByExternalId( + externalId, + ); + if (!account) { + throw new AccountNotFoundError( + `Account not found. externalId: ${externalId}`, + ); + } + + return account.tier; + } catch (e) { + this.logger.error(`[${context.trackingId}] error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case UserNotFoundError: + throw new HttpException( + makeErrorResponse('E010204'), + HttpStatus.BAD_REQUEST, + ); + case AccountNotFoundError: + throw new HttpException( + makeErrorResponse('E010501'), + 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.trackingId}] ${this.getAccountInfoMinimalAccess.name}`, + ); + } + } } diff --git a/dictation_server/src/features/accounts/test/accounts.service.mock.ts b/dictation_server/src/features/accounts/test/accounts.service.mock.ts index 920b877..0330884 100644 --- a/dictation_server/src/features/accounts/test/accounts.service.mock.ts +++ b/dictation_server/src/features/accounts/test/accounts.service.mock.ts @@ -30,7 +30,7 @@ export type LicensesRepositoryMockValue = { orderHistories: LicenseOrder[]; } | Error; - issueLicense: undefined | Error; + issueLicense: void | Error; }; export type UsersRepositoryMockValue = { findUserById: User | Error; @@ -61,10 +61,12 @@ export type ConfigMockValue = { get: string | Error; }; export type AccountsRepositoryMockValue = { - getLicenseSummaryInfo: { - licenseSummary: LicenseSummaryInfo; - isStorageAvailable: boolean; - }; + getLicenseSummaryInfo: + | { + licenseSummary: LicenseSummaryInfo; + isStorageAvailable: boolean; + } + | Error; createAccount: { newAccount: Account; adminUser: User } | Error; }; @@ -181,18 +183,7 @@ export const makeLicensesRepositoryMock = ( issueLicense: issueLicense instanceof Error ? jest.fn, []>().mockRejectedValue(issueLicense) - : jest - .fn< - Promise<{ - context: Context; - orderedAccountId: number; - myAccountId: number; - tier: number; - poNumber: string; - }>, - [] - >() - .mockResolvedValue(issueLicense), + : jest.fn, []>().mockResolvedValue(issueLicense), }; }; export const makeUsersRepositoryMock = (value: UsersRepositoryMockValue) => { @@ -355,7 +346,7 @@ export const makeDefaultAccountsRepositoryMockValue = user.created_by = 'test'; user.created_at = new Date(); user.updated_by = null; - user.updated_at = null; + user.updated_at = new Date(); return { getLicenseSummaryInfo: { licenseSummary: licenseSummaryInfo, @@ -385,7 +376,7 @@ export const makeDefaultUsersRepositoryMockValue = user.created_by = 'test'; user.created_at = new Date(); user.updated_by = null; - user.updated_at = null; + user.updated_at = new Date(); const typists: User[] = []; typists.push( @@ -434,7 +425,7 @@ export const makeDefaultUserGroupsRepositoryMockValue = user.created_by = 'test'; user.created_at = new Date(); user.updated_by = null; - user.updated_at = null; + user.updated_at = new Date(); return { getUserGroups: [ @@ -444,6 +435,10 @@ export const makeDefaultUserGroupsRepositoryMockValue = name: 'GroupA', created_by: 'test', updated_by: 'test', + created_at: new Date(), + deleted_at: null, + updated_at: null, + userGroupMembers: null, }, { id: 2, @@ -451,6 +446,10 @@ export const makeDefaultUserGroupsRepositoryMockValue = name: 'GroupB', created_by: 'test', updated_by: 'test', + created_at: new Date(), + deleted_at: null, + updated_at: null, + userGroupMembers: null, }, ], }; diff --git a/dictation_server/src/features/accounts/test/utility.ts b/dictation_server/src/features/accounts/test/utility.ts index a91a8d4..70cfc3a 100644 --- a/dictation_server/src/features/accounts/test/utility.ts +++ b/dictation_server/src/features/accounts/test/utility.ts @@ -23,14 +23,14 @@ export const getSortCriteria = async (dataSource: DataSource) => { export const createLicense = async ( datasource: DataSource, licenseId: number, - expiry_date: Date, + expiry_date: Date | null, accountId: number, type: string, status: string, - allocated_user_id: number, - order_id: number, - deleted_at: Date, - delete_order_id: number, + allocated_user_id: number | null, + order_id: number | null, + deleted_at: Date | null, + delete_order_id: number | null, ): Promise => { const { identifiers } = await datasource.getRepository(License).insert({ id: licenseId, @@ -54,7 +54,7 @@ export const createLicense = async ( export const createLicenseSetExpiryDateAndStatus = async ( datasource: DataSource, accountId: number, - expiryDate: Date, + expiryDate: Date | null, status: string, ): Promise => { const { identifiers } = await datasource.getRepository(License).insert({ @@ -171,19 +171,21 @@ export const createOptionItems = async ( datasource: DataSource, worktypeId: number, ): Promise => { - const optionItems = []; + const optionItems: OptionItem[] = []; for (let i = 0; i < 10; i++) { - optionItems.push({ - worktype_id: worktypeId, - item_label: '', - default_value_type: OPTION_ITEM_VALUE_TYPE.DEFAULT, - initial_value: '', - created_by: 'test_runner', - created_at: new Date(), - updated_by: 'updater', - updated_at: new Date(), - }); + const optionItem = new OptionItem(); + { + optionItem.worktype_id = worktypeId; + optionItem.item_label = ''; + optionItem.default_value_type = OPTION_ITEM_VALUE_TYPE.DEFAULT; + optionItem.initial_value = ''; + optionItem.created_by = 'test_runner'; + optionItem.created_at = new Date(); + optionItem.updated_by = 'updater'; + optionItem.updated_at = new Date(); + } + optionItems.push(optionItem); } await datasource.getRepository(OptionItem).insert(optionItems); diff --git a/dictation_server/src/features/accounts/types/types.ts b/dictation_server/src/features/accounts/types/types.ts index 47f6536..ccb45d9 100644 --- a/dictation_server/src/features/accounts/types/types.ts +++ b/dictation_server/src/features/accounts/types/types.ts @@ -327,8 +327,8 @@ export class GetOrderHistoriesRequest { export class LicenseOrder { @ApiProperty({ description: '注文日付' }) orderDate: string; - @ApiProperty({ description: '発行日付' }) - issueDate: string; + @ApiProperty({ description: '発行日付', required: false }) + issueDate?: string; @ApiProperty({ description: '注文数' }) numberOfOrder: number; @ApiProperty({ description: 'POナンバー' }) @@ -497,6 +497,16 @@ export class UpdateWorktypeRequestParam { id: number; } +export class DeleteWorktypeRequestParam { + @ApiProperty({ description: 'Worktypeの内部ID' }) + @Type(() => Number) + @IsInt() + @Min(0) + id: number; +} + +export class DeleteWorktypeResponse {} + export class PostActiveWorktypeRequest { @ApiProperty({ required: false, diff --git a/dictation_server/src/features/auth/auth.controller.spec.ts b/dictation_server/src/features/auth/auth.controller.spec.ts index b6e162a..93a7999 100644 --- a/dictation_server/src/features/auth/auth.controller.spec.ts +++ b/dictation_server/src/features/auth/auth.controller.spec.ts @@ -5,12 +5,19 @@ import { makeAdB2cServiceMock, makeDefaultAdB2cMockValue, } from './test/auth.service.mock'; +import { ConfigModule } from '@nestjs/config'; describe('AuthController', () => { let controller: AuthController; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + envFilePath: ['.env.local', '.env'], + isGlobal: true, + }), + ], controllers: [AuthController], providers: [AuthService], }) diff --git a/dictation_server/src/features/auth/auth.controller.ts b/dictation_server/src/features/auth/auth.controller.ts index 029e60e..4d8d33b 100644 --- a/dictation_server/src/features/auth/auth.controller.ts +++ b/dictation_server/src/features/auth/auth.controller.ts @@ -67,6 +67,18 @@ export class AuthController { const context = makeContext(uuidv4()); + // 同意済み利用規約バージョンが最新かチェック + const isAcceptedLatestVersion = + await this.authService.isAcceptedLatestVersion(context, idToken); + + // 最新でなければエラー + if (!isAcceptedLatestVersion) { + throw new HttpException( + makeErrorResponse('E010209'), + HttpStatus.UNAUTHORIZED, + ); + } + const refreshToken = await this.authService.generateRefreshToken( context, idToken, diff --git a/dictation_server/src/features/auth/auth.module.ts b/dictation_server/src/features/auth/auth.module.ts index 6db0c3f..2e3dc4c 100644 --- a/dictation_server/src/features/auth/auth.module.ts +++ b/dictation_server/src/features/auth/auth.module.ts @@ -4,8 +4,14 @@ import { AdB2cModule } from '../../gateways/adb2c/adb2c.module'; import { UsersRepositoryModule } from '../../repositories/users/users.repository.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; +import { TermsRepositoryModule } from '../../repositories/terms/terms.repository.module'; @Module({ - imports: [ConfigModule, AdB2cModule, UsersRepositoryModule], + imports: [ + ConfigModule, + AdB2cModule, + UsersRepositoryModule, + TermsRepositoryModule, + ], controllers: [AuthController], providers: [AuthService], }) diff --git a/dictation_server/src/features/auth/auth.service.spec.ts b/dictation_server/src/features/auth/auth.service.spec.ts index a14268a..af11c93 100644 --- a/dictation_server/src/features/auth/auth.service.spec.ts +++ b/dictation_server/src/features/auth/auth.service.spec.ts @@ -3,13 +3,22 @@ import { makeErrorResponse } from '../../common/error/makeErrorResponse'; import { makeAuthServiceMock, makeDefaultAdB2cMockValue, + makeDefaultConfigValue, makeDefaultGetPublicKeyFromJwk, } from './test/auth.service.mock'; +import { DataSource } from 'typeorm'; +import { makeContext } from '../../common/log'; +import { makeTestingModule } from '../../common/test/modules'; +import { makeTestAccount } from '../../common/test/utility'; +import { AuthService } from './auth.service'; +import { createTermInfo } from './test/utility'; +import { v4 as uuidv4 } from 'uuid'; describe('AuthService', () => { it('IDトークンの検証とペイロードの取得に成功する', async () => { const adb2cParam = makeDefaultAdB2cMockValue(); - const service = await makeAuthServiceMock(adb2cParam); + const configMockValue = makeDefaultConfigValue(); + const service = await makeAuthServiceMock(adb2cParam, configMockValue); //JWKの生成→PEM変換を自力で表現することが厳しいためMockで代替 service.getPublicKeyFromJwk = makeDefaultGetPublicKeyFromJwk; const token = @@ -20,7 +29,8 @@ describe('AuthService', () => { it('IDトークンの形式が不正な場合、形式不正エラーとなる。', async () => { const adb2cParam = makeDefaultAdB2cMockValue(); - const service = await makeAuthServiceMock(adb2cParam); + const configMockValue = makeDefaultConfigValue(); + const service = await makeAuthServiceMock(adb2cParam, configMockValue); const token = 'invalid.id.token'; await expect(service.getVerifiedIdToken(token)).rejects.toEqual( @@ -30,7 +40,8 @@ describe('AuthService', () => { it('IDトークンの有効期限が切れている場合、有効期限切れエラーとなる。', async () => { const adb2cParam = makeDefaultAdB2cMockValue(); - const service = await makeAuthServiceMock(adb2cParam); + const configMockValue = makeDefaultConfigValue(); + const service = await makeAuthServiceMock(adb2cParam, configMockValue); //JWKの生成→PEM変換を自力で表現することが厳しいためMockで代替 service.getPublicKeyFromJwk = makeDefaultGetPublicKeyFromJwk; const token = @@ -43,7 +54,8 @@ describe('AuthService', () => { it('IDトークンが開始日より前の場合、開始前エラーとなる。', async () => { const adb2cParam = makeDefaultAdB2cMockValue(); - const service = await makeAuthServiceMock(adb2cParam); + const configMockValue = makeDefaultConfigValue(); + const service = await makeAuthServiceMock(adb2cParam, configMockValue); //JWKの生成→PEM変換を自力で表現することが厳しいためMockで代替 service.getPublicKeyFromJwk = makeDefaultGetPublicKeyFromJwk; const token = @@ -56,7 +68,8 @@ describe('AuthService', () => { it('IDトークンの署名が不正な場合、署名不正エラーとなる。', async () => { const adb2cParam = makeDefaultAdB2cMockValue(); - const service = await makeAuthServiceMock(adb2cParam); + const configMockValue = makeDefaultConfigValue(); + const service = await makeAuthServiceMock(adb2cParam, configMockValue); const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwidmVyIjoiMS4wIiwiaXNzIjoiaXNzdXNlciIsInN1YiI6InN1YiIsImF1ZCI6ImF1ZCIsIm5vbmNlIjoiZGVmYXVsdE5vbmNlIiwiaWF0IjoxMDAwMDAwMDAwLCJhdXRoX3RpbWUiOjEwMDAwMDAwMDAsImVtYWlscyI6WyJ4eHhAeHguY29tIl0sInRmcCI6InNpZ25pbl91c2VyZmxvdyJ9.sign'; @@ -67,7 +80,8 @@ describe('AuthService', () => { it('IDトークンの発行元が想定と異なる場合、発行元不正エラーとなる。', async () => { const adb2cParam = makeDefaultAdB2cMockValue(); - const service = await makeAuthServiceMock(adb2cParam); + const configMockValue = makeDefaultConfigValue(); + const service = await makeAuthServiceMock(adb2cParam, configMockValue); //JWKの生成→PEM変換を自力で表現することが厳しいためMockで代替 service.getPublicKeyFromJwk = makeDefaultGetPublicKeyFromJwk; const token = @@ -80,8 +94,9 @@ describe('AuthService', () => { it('Azure ADB2Cでネットワークエラーとなる場合、エラーとなる。(メタデータ)', async () => { const adb2cParam = makeDefaultAdB2cMockValue(); + const configMockValue = makeDefaultConfigValue(); adb2cParam.getMetaData = new Error('failed get metadata'); - const service = await makeAuthServiceMock(adb2cParam); + const service = await makeAuthServiceMock(adb2cParam, configMockValue); const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwidmVyIjoiMS4wIiwiaXNzIjoiaXNzdWVyIiwic3ViIjoic3ViIiwiYXVkIjoiYXVkIiwibm9uY2UiOiJkZWZhdWx0Tm9uY2UiLCJpYXQiOjEwMDAwMDAwMDAsImF1dGhfdGltZSI6MTAwMDAwMDAwMCwiZW1haWxzIjpbInh4eEB4eC5jb20iXSwidGZwIjoic2lnbmluX3VzZXJmbG93In0.RyieW-VHsHPQOjXbbhRc307AYJOc1sq2hrcu4SW1-K0pvLlkplepxvx02a3vCwQrnBYrIP5w6HExG-S_JgW5nYyWr6DeY11mA484n9KA8GeAcAXV37StH1gfWUJvfGb4C8BaMbMM9Ix4Z9NGwKA9vjNwevfmBZnz9lQUePgv6BJNmyvCt8ElJ01O-1WODbZuojJ4xXymA1OqluzfbphPOsqWTSNmTn0emkLjjnlMQf1iwM4C_kvvr8dUCFg0_UGDfQVJnzPEKB38UqnhLnC5WacrddDwQ0kBuGKZgZ_63Q_7fOvqAZivqLK7BPmbPxi6mx3R1S9Eq2ugzpY1LfJOjA'; @@ -94,8 +109,9 @@ describe('AuthService', () => { }); it('Azure ADB2Cでネットワークエラーとなる場合、エラーとなる。(キーセット)', async () => { const adb2cParam = makeDefaultAdB2cMockValue(); + const configMockValue = makeDefaultConfigValue(); adb2cParam.getSignKeySets = new Error('failed get keyset'); - const service = await makeAuthServiceMock(adb2cParam); + const service = await makeAuthServiceMock(adb2cParam, configMockValue); const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwidmVyIjoiMS4wIiwiaXNzIjoiaXNzdWVyIiwic3ViIjoic3ViIiwiYXVkIjoiYXVkIiwibm9uY2UiOiJkZWZhdWx0Tm9uY2UiLCJpYXQiOjEwMDAwMDAwMDAsImF1dGhfdGltZSI6MTAwMDAwMDAwMCwiZW1haWxzIjpbInh4eEB4eC5jb20iXSwidGZwIjoic2lnbmluX3VzZXJmbG93In0.RyieW-VHsHPQOjXbbhRc307AYJOc1sq2hrcu4SW1-K0pvLlkplepxvx02a3vCwQrnBYrIP5w6HExG-S_JgW5nYyWr6DeY11mA484n9KA8GeAcAXV37StH1gfWUJvfGb4C8BaMbMM9Ix4Z9NGwKA9vjNwevfmBZnz9lQUePgv6BJNmyvCt8ElJ01O-1WODbZuojJ4xXymA1OqluzfbphPOsqWTSNmTn0emkLjjnlMQf1iwM4C_kvvr8dUCFg0_UGDfQVJnzPEKB38UqnhLnC5WacrddDwQ0kBuGKZgZ_63Q_7fOvqAZivqLK7BPmbPxi6mx3R1S9Eq2ugzpY1LfJOjA'; @@ -109,10 +125,11 @@ describe('AuthService', () => { it('Azure ADB2Cから取得した鍵が一致しない場合、エラーとなる。', async () => { const adb2cParam = makeDefaultAdB2cMockValue(); + const configMockValue = makeDefaultConfigValue(); adb2cParam.getSignKeySets = [ { kid: 'invalid', kty: 'RSA', nbf: 0, use: 'sig', e: '', n: '' }, ]; - const service = await makeAuthServiceMock(adb2cParam); + const service = await makeAuthServiceMock(adb2cParam, configMockValue); const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwidmVyIjoiMS4wIiwiaXNzIjoiaXNzdWVyIiwic3ViIjoic3ViIiwiYXVkIjoiYXVkIiwibm9uY2UiOiJkZWZhdWx0Tm9uY2UiLCJpYXQiOjEwMDAwMDAwMDAsImF1dGhfdGltZSI6MTAwMDAwMDAwMCwiZW1haWxzIjpbInh4eEB4eC5jb20iXSwidGZwIjoic2lnbmluX3VzZXJmbG93In0.RyieW-VHsHPQOjXbbhRc307AYJOc1sq2hrcu4SW1-K0pvLlkplepxvx02a3vCwQrnBYrIP5w6HExG-S_JgW5nYyWr6DeY11mA484n9KA8GeAcAXV37StH1gfWUJvfGb4C8BaMbMM9Ix4Z9NGwKA9vjNwevfmBZnz9lQUePgv6BJNmyvCt8ElJ01O-1WODbZuojJ4xXymA1OqluzfbphPOsqWTSNmTn0emkLjjnlMQf1iwM4C_kvvr8dUCFg0_UGDfQVJnzPEKB38UqnhLnC5WacrddDwQ0kBuGKZgZ_63Q_7fOvqAZivqLK7BPmbPxi6mx3R1S9Eq2ugzpY1LfJOjA'; @@ -125,6 +142,140 @@ describe('AuthService', () => { }); }); +describe('checkIsAcceptedLatestVersion', () => { + 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(); + }); + + afterEach(async () => { + if (!source) return; + await source.destroy(); + source = null; + }); + it('同意済み利用規約バージョンが最新のときにチェックが通ること(第五)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AuthService); + const { admin } = await makeTestAccount(source, { + tier: 5, + }); + const context = makeContext(uuidv4()); + + const idToken = { + emails: [], + sub: admin.external_id, + exp: 0, + iat: 0, + }; + + await createTermInfo(source, 'EULA', '1.0'); + await createTermInfo(source, 'DPA', '1.0'); + const result = await service.isAcceptedLatestVersion(context, idToken); + expect(result).toBe(true); + }); + + it('同意済み利用規約バージョンが最新のときにチェックが通ること(第一~第四)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AuthService); + const { admin } = await makeTestAccount(source, { + tier: 4, + }); + const context = makeContext(uuidv4()); + + const idToken = { + emails: [], + sub: admin.external_id, + exp: 0, + iat: 0, + }; + + await createTermInfo(source, 'EULA', '1.0'); + await createTermInfo(source, 'DPA', '1.0'); + const result = await service.isAcceptedLatestVersion(context, idToken); + expect(result).toBe(true); + }); + + it('同意済み利用規約バージョンが最新でないときにチェックが通らないこと(第五)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AuthService); + const { admin } = await makeTestAccount(source, { + tier: 5, + }); + const context = makeContext(uuidv4()); + + const idToken = { + emails: [], + sub: admin.external_id, + exp: 0, + iat: 0, + }; + + await createTermInfo(source, 'EULA', '1.1'); + await createTermInfo(source, 'DPA', '1.0'); + const result = await service.isAcceptedLatestVersion(context, idToken); + expect(result).toBe(false); + }); + + it('同意済み利用規約(EULA)バージョンが最新でないときにチェックが通らないこと(第一~第四)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AuthService); + const { admin } = await makeTestAccount(source, { + tier: 4, + }); + const context = makeContext(uuidv4()); + + const idToken = { + emails: [], + sub: admin.external_id, + exp: 0, + iat: 0, + }; + + await createTermInfo(source, 'EULA', '1.1'); + await createTermInfo(source, 'DPA', '1.0'); + const result = await service.isAcceptedLatestVersion(context, idToken); + expect(result).toBe(false); + }); + + it('同意済み利用規約バージョン(DPA)が最新でないときにチェックが通らないこと(第一~第四)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(AuthService); + const { admin } = await makeTestAccount(source, { + tier: 4, + }); + const context = makeContext(uuidv4()); + + const idToken = { + emails: [], + sub: admin.external_id, + exp: 0, + iat: 0, + }; + + await createTermInfo(source, 'EULA', '1.0'); + await createTermInfo(source, 'DPA', '1.1'); + const result = await service.isAcceptedLatestVersion(context, idToken); + expect(result).toBe(false); + }); +}); + const idTokenPayload = { exp: 9000000000, nbf: 1000000000, diff --git a/dictation_server/src/features/auth/auth.service.ts b/dictation_server/src/features/auth/auth.service.ts index a3198d7..1454960 100644 --- a/dictation_server/src/features/auth/auth.service.ts +++ b/dictation_server/src/features/auth/auth.service.ts @@ -17,7 +17,7 @@ import { RefreshToken, isIDToken, } from '../../common/token'; -import { ADMIN_ROLES, USER_ROLES } from '../../constants'; +import { ADMIN_ROLES, TIERS, USER_ROLES } from '../../constants'; import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; import { User } from '../../repositories/users/entity/user.entity'; import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; @@ -25,6 +25,13 @@ import { Context } from '../../common/log'; @Injectable() export class AuthService { + private readonly refreshTokenLifetimeWeb = + this.configService.getOrThrow('REFRESH_TOKEN_LIFETIME_WEB'); + private readonly refreshTokenLifetimeDefault = + this.configService.getOrThrow('REFRESH_TOKEN_LIFETIME_DEFAULT'); + private readonly accessTokenlifetime = this.configService.getOrThrow( + 'ACCESS_TOKEN_LIFETIME_WEB', + ); constructor( private readonly adB2cService: AdB2cService, private readonly configService: ConfigService, @@ -68,10 +75,7 @@ export class AuthService { this.logger.log( `[IN] [${context.trackingId}] ${this.generateRefreshToken.name}`, ); - const lifetimeWeb = this.configService.get('REFRESH_TOKEN_LIFETIME_WEB'); - const lifetimeDefault = this.configService.get( - 'REFRESH_TOKEN_LIFETIME_DEFAULT', - ); + let user: User; // ユーザー情報とユーザーが属しているアカウント情報を取得 try { @@ -106,7 +110,10 @@ export class AuthService { ); } // 要求された環境用トークンの寿命を決定 - const refreshTokenLifetime = type === 'web' ? lifetimeWeb : lifetimeDefault; + const refreshTokenLifetime = + type === 'web' + ? this.refreshTokenLifetimeWeb + : this.refreshTokenLifetimeDefault; const privateKey = getPrivateKey(this.configService); // ユーザーのロールを設定 @@ -165,7 +172,6 @@ export class AuthService { this.logger.log( `[IN] [${context.trackingId}] ${this.generateAccessToken.name}`, ); - const lifetime = this.configService.get('ACCESS_TOKEN_LIFETIME_WEB'); const privateKey = getPrivateKey(this.configService); const pubkey = getPublicKey(this.configService); @@ -188,7 +194,7 @@ export class AuthService { tier: token.tier, userId: token.userId, }, - lifetime, + this.accessTokenlifetime, privateKey, ); @@ -205,11 +211,14 @@ export class AuthService { async getVerifiedIdToken(token: string): Promise { this.logger.log(`[IN] ${this.getVerifiedIdToken.name}`); - let kid = ''; + let kid: string | undefined = ''; try { // JWTトークンのヘッダを見るため一度デコードする const decodedToken = jwt.decode(token, { complete: true }); - kid = decodedToken.header.kid; + kid = decodedToken?.header.kid; + if (!kid) { + throw new Error('kid not found'); + } } catch (e) { this.logger.error(`error=${e}`); throw new HttpException( @@ -331,4 +340,59 @@ export class AuthService { HttpStatus.UNAUTHORIZED, ); }; + + /** + * 同意済み利用規約バージョンが最新かチェック + * @param idToken AzureAD B2Cにより発行されたIDトークン + * @returns boolean + */ + async isAcceptedLatestVersion( + context: Context, + idToken: IDToken, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.isAcceptedLatestVersion.name} | params: { ` + + `idToken.sub: ${idToken.sub}, };`, + ); + + try { + // DBからユーザーの同意済み利用規約バージョンと最新バージョンを取得 + const { + acceptedEulaVersion, + latestEulaVersion, + acceptedDpaVersion, + latestDpaVersion, + tier, + } = await this.usersRepository.getAcceptedAndLatestVersion(idToken.sub); + + // 第五階層はEULAのみ判定 + if (tier === TIERS.TIER5) { + if (!acceptedEulaVersion) { + return false; + } + // 最新バージョンに同意済みか判定 + const eulaAccepted = acceptedEulaVersion === latestEulaVersion; + return eulaAccepted; + } else { + // 第一~第四階層はEULA、DPAを判定 + if (!acceptedEulaVersion || !acceptedDpaVersion) { + return false; + } + // 最新バージョンに同意済みか判定 + const eulaAccepted = acceptedEulaVersion === latestEulaVersion; + const dpaAccepted = acceptedDpaVersion === latestDpaVersion; + return eulaAccepted && dpaAccepted; + } + } catch (e) { + this.logger.error(`[${context.trackingId}] error=${e}`); + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } finally { + this.logger.log( + `[OUT] [${context.trackingId}] ${this.isAcceptedLatestVersion.name}`, + ); + } + } } diff --git a/dictation_server/src/features/auth/test/auth.service.mock.ts b/dictation_server/src/features/auth/test/auth.service.mock.ts index 08a7f32..5f8c9f8 100644 --- a/dictation_server/src/features/auth/test/auth.service.mock.ts +++ b/dictation_server/src/features/auth/test/auth.service.mock.ts @@ -9,9 +9,13 @@ export type AdB2cMockValue = { getMetaData: B2cMetadata | Error; getSignKeySets: JwkSignKey[] | Error; }; +export type ConfigMockValue = { + getOrThrow: number; +}; export const makeAuthServiceMock = async ( adB2cMockValue: AdB2cMockValue, + configMockValue: ConfigMockValue, ): Promise => { const module: TestingModule = await Test.createTestingModule({ providers: [AuthService], @@ -21,7 +25,7 @@ export const makeAuthServiceMock = async ( case AdB2cService: return makeAdB2cServiceMock(adB2cMockValue); case ConfigService: - return {}; + return makeConfigMock(configMockValue); case UsersRepositoryService: return {}; } @@ -80,3 +84,16 @@ export const makeDefaultGetPublicKeyFromJwk = (jwkKey: JwkSignKey): string => { '-----END PUBLIC KEY-----', ].join('\n'); }; +export const makeConfigMock = (value: ConfigMockValue) => { + const { getOrThrow } = value; + + return { + getOrThrow: jest.fn, []>().mockResolvedValue(getOrThrow), + }; +}; + +export const makeDefaultConfigValue = (): ConfigMockValue => { + return { + getOrThrow: 80000, + }; +}; diff --git a/dictation_server/src/features/auth/test/utility.ts b/dictation_server/src/features/auth/test/utility.ts new file mode 100644 index 0000000..c5c2c51 --- /dev/null +++ b/dictation_server/src/features/auth/test/utility.ts @@ -0,0 +1,18 @@ +import { DataSource } from 'typeorm'; +import { Term } from '../../../repositories/terms/entity/term.entity'; + +export const createTermInfo = async ( + datasource: DataSource, + documentType: string, + version: string, +): Promise => { + const { identifiers } = await datasource.getRepository(Term).insert({ + document_type: documentType, + version: version, + created_by: 'test_runner', + created_at: new Date(), + updated_by: 'updater', + updated_at: new Date(), + }); + identifiers.pop() as Term; +}; diff --git a/dictation_server/src/features/auth/types/types.ts b/dictation_server/src/features/auth/types/types.ts index 95f41a3..a5250bc 100644 --- a/dictation_server/src/features/auth/types/types.ts +++ b/dictation_server/src/features/auth/types/types.ts @@ -17,3 +17,11 @@ export class AccessTokenResponse { accessToken: string; } export class AccessTokenRequest {} + +export type TermsCheckInfo = { + tier: number; + acceptedEulaVersion?: string; + acceptedDpaVersion?: string; + latestEulaVersion: string; + latestDpaVersion: string; +}; diff --git a/dictation_server/src/features/files/files.controller.ts b/dictation_server/src/features/files/files.controller.ts index d188e94..4a72268 100644 --- a/dictation_server/src/features/files/files.controller.ts +++ b/dictation_server/src/features/files/files.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, + HttpException, HttpStatus, Post, Query, @@ -37,6 +38,7 @@ import { ADMIN_ROLES, USER_ROLES } from '../../constants'; import { retrieveAuthorizationToken } from '../../common/http/helper'; import { Request } from 'express'; import { makeContext } from '../../common/log'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; @ApiTags('files') @Controller('files') @@ -70,16 +72,28 @@ export class FilesController { }) @ApiBearerAuth() @UseGuards(AuthGuard) - @UseGuards(RoleGuard.requireds({ roles: [USER_ROLES.AUTHOR] })) @Post('audio/upload-finished') async uploadFinished( @Req() req: Request, @Body() body: AudioUploadFinishedRequest, ): Promise { - const token = retrieveAuthorizationToken(req); - const accessToken = jwt.decode(token, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req); + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; - const context = makeContext(accessToken.userId); + const context = makeContext(userId); const { url, @@ -100,7 +114,7 @@ export class FilesController { const res = await this.filesService.uploadFinished( context, - accessToken.userId, + userId, url, authorId, fileName, @@ -143,7 +157,6 @@ export class FilesController { }) @ApiBearerAuth() @UseGuards(AuthGuard) - @UseGuards(RoleGuard.requireds({ roles: [USER_ROLES.AUTHOR] })) async uploadLocation( @Req() req: Request, // クエリパラメータ AudioUploadLocationRequest は空であるため内部で使用しない。 @@ -151,10 +164,23 @@ export class FilesController { // eslint-disable-next-line @typescript-eslint/no-unused-vars @Query() _query: AudioUploadLocationRequest, ): Promise { - const token = retrieveAuthorizationToken(req); - const accessToken = jwt.decode(token, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req); + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; - const context = makeContext(accessToken.userId); + const context = makeContext(userId); const url = await this.filesService.publishUploadSas(context, accessToken); return { url }; @@ -197,14 +223,27 @@ export class FilesController { ): Promise { const { audioFileId } = body; - const token = retrieveAuthorizationToken(req); - const accessToken = jwt.decode(token, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req); + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; - const context = makeContext(accessToken.userId); + const context = makeContext(userId); const url = await this.filesService.publishAudioFileDownloadSas( context, - accessToken.userId, + userId, audioFileId, ); @@ -248,14 +287,27 @@ export class FilesController { ): Promise { const { audioFileId } = body; - const token = retrieveAuthorizationToken(req); - const accessToken = jwt.decode(token, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req); + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; - const context = makeContext(accessToken.userId); + const context = makeContext(userId); const url = await this.filesService.publishTemplateFileDownloadSas( context, - accessToken.userId, + userId, audioFileId, ); @@ -289,8 +341,21 @@ export class FilesController { async uploadTemplateLocation( @Req() req: Request, ): Promise { - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req); + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); @@ -335,8 +400,21 @@ export class FilesController { @Body() body: TemplateUploadFinishedRequest, ): Promise { const { name, url } = body; - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req); + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); await this.filesService.templateUploadFinished(context, userId, url, name); diff --git a/dictation_server/src/features/files/files.service.spec.ts b/dictation_server/src/features/files/files.service.spec.ts index 2ea8e73..dccffd8 100644 --- a/dictation_server/src/features/files/files.service.spec.ts +++ b/dictation_server/src/features/files/files.service.spec.ts @@ -35,11 +35,10 @@ describe('音声ファイルアップロードURL取得', () => { ); expect( - await service.publishUploadSas(makeContext('trackingId'), { - userId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', - role: 'Author', - tier: 5, - }), + await service.publishUploadSas( + makeContext('trackingId'), + 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', + ), ).toEqual('https://blob-storage?sas-token'); }); @@ -57,11 +56,10 @@ describe('音声ファイルアップロードURL取得', () => { ); expect( - await service.publishUploadSas(makeContext('trackingId'), { - userId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', - role: 'Author', - tier: 5, - }), + await service.publishUploadSas( + makeContext('trackingId'), + 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', + ), ).toEqual('https://blob-storage?sas-token'); }); @@ -78,11 +76,10 @@ describe('音声ファイルアップロードURL取得', () => { ); await expect( - service.publishUploadSas(makeContext('trackingId'), { - userId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', - role: 'Author', - tier: 5, - }), + service.publishUploadSas( + makeContext('trackingId'), + 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', + ), ).rejects.toEqual( new HttpException(makeErrorResponse('E009999'), HttpStatus.UNAUTHORIZED), ); @@ -102,11 +99,10 @@ describe('音声ファイルアップロードURL取得', () => { blobParam.publishUploadSas = new Error('Azure service down'); await expect( - service.publishUploadSas(makeContext('trackingId'), { - userId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', - role: 'Author', - tier: 5, - }), + service.publishUploadSas( + makeContext('trackingId'), + 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', + ), ).rejects.toEqual( new HttpException(makeErrorResponse('E009999'), HttpStatus.UNAUTHORIZED), ); @@ -295,7 +291,7 @@ describe('タスク作成', () => { }); describe('音声ファイルダウンロードURL取得', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -308,11 +304,13 @@ describe('音声ファイルダウンロードURL取得', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('ダウンロードSASトークンが乗っているURLを取得できる', async () => { + if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId, @@ -333,7 +331,7 @@ describe('音声ファイルダウンロードURL取得', () => { 'test.zip', 'InProgress', undefined, - authorId, + authorId ?? '', ); const blobParam = makeBlobstorageServiceMockValue(); @@ -341,6 +339,7 @@ describe('音声ファイルダウンロードURL取得', () => { blobParam.fileExists = true; const module = await makeTestingModuleWithBlob(source, blobParam); + if (!module) fail(); const service = module.get(FilesService); expect( @@ -353,6 +352,7 @@ describe('音声ファイルダウンロードURL取得', () => { }); it('Typistの場合、タスクのステータスが[Inprogress,Pending]以外でエラー', async () => { + if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId, id: userId } = await makeTestUser(source, { account_id: accountId, @@ -382,6 +382,7 @@ describe('音声ファイルダウンロードURL取得', () => { blobParam.fileExists = true; const module = await makeTestingModuleWithBlob(source, blobParam); + if (!module) fail(); const service = module.get(FilesService); await expect( @@ -396,6 +397,7 @@ describe('音声ファイルダウンロードURL取得', () => { }); it('Typistの場合、自身が担当するタスクでない場合エラー', async () => { + if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId } = await makeTestUser(source, { account_id: accountId, @@ -429,6 +431,7 @@ describe('音声ファイルダウンロードURL取得', () => { blobParam.fileExists = true; const module = await makeTestingModuleWithBlob(source, blobParam); + if (!module) fail(); const service = module.get(FilesService); await expect( @@ -443,6 +446,7 @@ describe('音声ファイルダウンロードURL取得', () => { }); it('Authorの場合、自身が登録したタスクでない場合エラー', async () => { + if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId, id: userId } = await makeTestUser(source, { account_id: accountId, @@ -467,6 +471,7 @@ describe('音声ファイルダウンロードURL取得', () => { blobParam.fileExists = true; const module = await makeTestingModuleWithBlob(source, blobParam); + if (!module) fail(); const service = module.get(FilesService); await expect( @@ -481,6 +486,7 @@ describe('音声ファイルダウンロードURL取得', () => { }); it('Taskが存在しない場合はエラーとなる', async () => { + if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId } = await makeTestUser(source, { account_id: accountId, @@ -492,6 +498,7 @@ describe('音声ファイルダウンロードURL取得', () => { const blobParam = makeBlobstorageServiceMockValue(); const module = await makeTestingModuleWithBlob(source, blobParam); + if (!module) fail(); const service = module.get(FilesService); await expect( @@ -506,6 +513,7 @@ describe('音声ファイルダウンロードURL取得', () => { }); it('blobストレージにファイルが存在しない場合はエラーとなる', async () => { + if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId, @@ -526,7 +534,7 @@ describe('音声ファイルダウンロードURL取得', () => { 'test.zip', 'InProgress', undefined, - authorId, + authorId ?? '', ); const blobParam = makeBlobstorageServiceMockValue(); @@ -534,6 +542,7 @@ describe('音声ファイルダウンロードURL取得', () => { blobParam.fileExists = false; const module = await makeTestingModuleWithBlob(source, blobParam); + if (!module) fail(); const service = module.get(FilesService); await expect( @@ -549,7 +558,7 @@ describe('音声ファイルダウンロードURL取得', () => { }); describe('テンプレートファイルダウンロードURL取得', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -562,11 +571,13 @@ describe('テンプレートファイルダウンロードURL取得', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('ダウンロードSASトークンが乗っているURLを取得できる', async () => { + if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId, author_id: authorId } = await makeTestUser( source, @@ -586,7 +597,7 @@ describe('テンプレートファイルダウンロードURL取得', () => { 'test.zip', 'InProgress', undefined, - authorId, + authorId ?? '', ); const blobParam = makeBlobstorageServiceMockValue(); @@ -594,6 +605,7 @@ describe('テンプレートファイルダウンロードURL取得', () => { blobParam.fileExists = true; const module = await makeTestingModuleWithBlob(source, blobParam); + if (!module) fail(); const service = module.get(FilesService); expect( @@ -606,6 +618,7 @@ describe('テンプレートファイルダウンロードURL取得', () => { }); it('Typistの場合、タスクのステータスが[Inprogress,Pending]以外でエラー', async () => { + if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId, id: userId } = await makeTestUser(source, { account_id: accountId, @@ -629,6 +642,7 @@ describe('テンプレートファイルダウンロードURL取得', () => { blobParam.fileExists = true; const module = await makeTestingModuleWithBlob(source, blobParam); + if (!module) fail(); const service = module.get(FilesService); await expect( @@ -643,6 +657,7 @@ describe('テンプレートファイルダウンロードURL取得', () => { }); it('Typistの場合、自身が担当するタスクでない場合エラー', async () => { + if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId } = await makeTestUser(source, { account_id: accountId, @@ -672,6 +687,7 @@ describe('テンプレートファイルダウンロードURL取得', () => { blobParam.fileExists = true; const module = await makeTestingModuleWithBlob(source, blobParam); + if (!module) fail(); const service = module.get(FilesService); await expect( @@ -686,6 +702,7 @@ describe('テンプレートファイルダウンロードURL取得', () => { }); it('Authorの場合、自身が登録したタスクでない場合エラー', async () => { + if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId } = await makeTestUser(source, { account_id: accountId, @@ -710,6 +727,7 @@ describe('テンプレートファイルダウンロードURL取得', () => { blobParam.fileExists = true; const module = await makeTestingModuleWithBlob(source, blobParam); + if (!module) fail(); const service = module.get(FilesService); await expect( @@ -724,6 +742,7 @@ describe('テンプレートファイルダウンロードURL取得', () => { }); it('Taskが存在しない場合はエラーとなる', async () => { + if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId } = await makeTestUser(source, { account_id: accountId, @@ -735,6 +754,7 @@ describe('テンプレートファイルダウンロードURL取得', () => { const blobParam = makeBlobstorageServiceMockValue(); const module = await makeTestingModuleWithBlob(source, blobParam); + if (!module) fail(); const service = module.get(FilesService); await expect( @@ -749,6 +769,7 @@ describe('テンプレートファイルダウンロードURL取得', () => { }); it('blobストレージにファイルが存在しない場合はエラーとなる', async () => { + if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId, author_id: authorId } = await makeTestUser( source, @@ -768,7 +789,7 @@ describe('テンプレートファイルダウンロードURL取得', () => { 'test.zip', 'InProgress', undefined, - authorId, + authorId ?? '', ); const blobParam = makeBlobstorageServiceMockValue(); @@ -776,6 +797,7 @@ describe('テンプレートファイルダウンロードURL取得', () => { blobParam.fileExists = false; const module = await makeTestingModuleWithBlob(source, blobParam); + if (!module) fail(); const service = module.get(FilesService); await expect( @@ -791,7 +813,7 @@ describe('テンプレートファイルダウンロードURL取得', () => { }); describe('publishTemplateFileUploadSas', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -804,12 +826,15 @@ describe('publishTemplateFileUploadSas', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('テンプレートファイルアップロードSASトークンが乗っているURLを取得できる', 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 }); @@ -832,7 +857,9 @@ describe('publishTemplateFileUploadSas', () => { }); it('blobストレージにコンテナが存在しない場合はエラーとなる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(FilesService); // 第五階層のアカウント作成 const { admin } = await makeTestAccount(source, { tier: 5 }); @@ -858,7 +885,9 @@ describe('publishTemplateFileUploadSas', () => { }); it('SASトークンの取得に失敗した場合はエラーとなる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(FilesService); // 第五階層のアカウント作成 const { admin } = await makeTestAccount(source, { tier: 5 }); @@ -887,7 +916,7 @@ describe('publishTemplateFileUploadSas', () => { }); describe('templateUploadFinished', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -900,12 +929,15 @@ describe('templateUploadFinished', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('アップロード完了後のテンプレートファイル情報をDBに保存できる(新規追加)', 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 }); @@ -937,7 +969,9 @@ describe('templateUploadFinished', () => { }); it('アップロード完了後のテンプレートファイル情報をDBに保存できる(更新)', 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 }); @@ -975,7 +1009,9 @@ describe('templateUploadFinished', () => { }); it('DBへの保存に失敗した場合はエラーとなる', 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 }); diff --git a/dictation_server/src/features/files/files.service.ts b/dictation_server/src/features/files/files.service.ts index bcae416..db97e77 100644 --- a/dictation_server/src/features/files/files.service.ts +++ b/dictation_server/src/features/files/files.service.ts @@ -24,6 +24,7 @@ import { } from '../../repositories/tasks/errors/types'; import { Context } from '../../common/log'; import { TemplateFilesRepositoryService } from '../../repositories/template_files/template_files.repository.service'; +import { AccountNotFoundError } from '../../repositories/accounts/errors/types'; @Injectable() export class FilesService { @@ -206,7 +207,7 @@ export class FilesService { */ async publishUploadSas( context: Context, - token: AccessToken, + externalId: string, ): Promise { this.logger.log( `[IN] [${context.trackingId}] ${this.publishUploadSas.name}`, @@ -216,10 +217,11 @@ export class FilesService { let accountId: number; let country: string; try { - const user = await this.usersRepository.findUserByExternalId( - token.userId, - ); - accountId = user.account.id; + const user = await this.usersRepository.findUserByExternalId(externalId); + if (!user.account) { + throw new AccountNotFoundError('account not found.'); + } + accountId = user.account_id; country = user.account.country; } catch (e) { this.logger.error(`error=${e}`); @@ -291,14 +293,17 @@ export class FilesService { let userId: number; let country: string; let isTypist: boolean; - let authorId: string; + let authorId: string | undefined; try { const user = await this.usersRepository.findUserByExternalId(externalId); + if (!user.account) { + throw new AccountNotFoundError('account not found.'); + } accountId = user.account.id; userId = user.id; country = user.account.country; isTypist = user.role === USER_ROLES.TYPIST; - authorId = user.author_id; + authorId = user.author_id ?? undefined; } catch (e) { this.logger.error(`error=${e}`); @@ -321,7 +326,7 @@ export class FilesService { accountId, status, ); - const file = task.file; + const { file } = task; // タスクに紐づく音声ファイルだけが消される場合がある。 // その場合はダウンロード不可なので不在エラーとして扱う @@ -332,9 +337,9 @@ export class FilesService { } // ユーザーがAuthorの場合、自身が追加したタスクでない場合はエラー - if (!isTypist && task.file.author_id !== authorId) { + if (!isTypist && file.author_id !== authorId) { throw new AuthorUserNotMatchError( - `task author is not match. audio_file_id:${audioFileId}, task.file.author_id:${task.file.author_id}, authorId:${authorId}`, + `task author is not match. audio_file_id:${audioFileId}, task.file.author_id:${file.author_id}, authorId:${authorId}`, ); } @@ -425,14 +430,17 @@ export class FilesService { let userId: number; let country: string; let isTypist: boolean; - let authorId: string; + let authorId: string | undefined; try { const user = await this.usersRepository.findUserByExternalId(externalId); - accountId = user.account.id; + if (!user.account) { + throw new AccountNotFoundError('account not found.'); + } + accountId = user.account_id; userId = user.id; country = user.account.country; isTypist = user.role === USER_ROLES.TYPIST; - authorId = user.author_id; + authorId = user.author_id ?? undefined; } catch (e) { this.logger.error(`error=${e}`); this.logger.log( @@ -454,6 +462,15 @@ export class FilesService { accountId, status, ); + const { file } = task; + + // タスクに紐づく音声ファイルだけが消される場合がある。 + // その場合はダウンロード不可なので不在エラーとして扱う + if (!file) { + throw new AudioFileNotFoundError( + `Audio file is not exists in DB. audio_file_id:${audioFileId}`, + ); + } const template_file = task.template_file; @@ -466,9 +483,9 @@ export class FilesService { } // ユーザーがAuthorの場合、自身が追加したタスクでない場合はエラー - if (!isTypist && task.file.author_id !== authorId) { + if (!isTypist && file.author_id !== authorId) { throw new AuthorUserNotMatchError( - `task author is not match. audio_file_id:${audioFileId}, task.file.author_id:${task.file.author_id}, authorId:${authorId}`, + `task author is not match. audio_file_id:${audioFileId}, task.file.author_id:${file.author_id}, authorId:${authorId}`, ); } @@ -515,6 +532,7 @@ export class FilesService { makeErrorResponse('E010603'), HttpStatus.BAD_REQUEST, ); + case AudioFileNotFoundError: case TemplateFileNotFoundError: throw new HttpException( makeErrorResponse('E010701'), @@ -552,15 +570,18 @@ export class FilesService { `[IN] [${context.trackingId}] ${this.publishTemplateFileUploadSas.name} | params: { externalId: ${externalId} };`, ); try { - const { - account: { id: accountId, country }, - } = await this.usersRepository.findUserByExternalId(externalId); + const { account } = await this.usersRepository.findUserByExternalId( + externalId, + ); + if (!account) { + throw new AccountNotFoundError('account not found.'); + } // 国に応じたリージョンのBlobストレージにコンテナが存在するか確認 const isContainerExists = await this.blobStorageService.containerExists( context, - accountId, - country, + account.id, + account.country, ); if (!isContainerExists) { throw new Error('container not found.'); @@ -569,8 +590,8 @@ export class FilesService { // SASトークン発行 const url = await this.blobStorageService.publishTemplateUploadSas( context, - accountId, - country, + account.id, + account.country, ); return url; 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 dbc8a6f..9facc6f 100644 --- a/dictation_server/src/features/files/test/files.service.mock.ts +++ b/dictation_server/src/features/files/test/files.service.mock.ts @@ -134,12 +134,15 @@ export const makeDefaultUsersRepositoryMockValue = created_by: 'test', created_at: new Date(), updated_by: null, - updated_at: null, + updated_at: new Date(), auto_renew: true, license_alert: true, notification: true, encryption: false, prompt: false, + encryption_password: null, + license: null, + userGroupMembers: null, account: { id: 2, parent_account_id: 2, @@ -154,7 +157,10 @@ export const makeDefaultUsersRepositoryMockValue = created_by: '', created_at: new Date(), updated_by: '', - updated_at: null, + updated_at: new Date(), + active_worktype_id: null, + secondary_admin_user_id: null, + user: null, }, }, }; @@ -172,6 +178,14 @@ export const makeDefaultTasksRepositoryMockValue = status: 'Uploaded', priority: '01', created_at: new Date(), + finished_at: null, + started_at: null, + template_file_id: null, + typist_user_id: null, + file: null, + option_items: null, + template_file: null, + typist_user: null, }, getTasksFromAccountId: { tasks: [], diff --git a/dictation_server/src/features/files/test/utility.ts b/dictation_server/src/features/files/test/utility.ts index 61772f6..c1f6e52 100644 --- a/dictation_server/src/features/files/test/utility.ts +++ b/dictation_server/src/features/files/test/utility.ts @@ -98,7 +98,7 @@ export const createTask = async ( export const makeTestingModuleWithBlob = async ( datasource: DataSource, blobStorageService: BlobstorageServiceMockValue, -): Promise => { +): Promise => { try { const module: TestingModule = await Test.createTestingModule({ imports: [ diff --git a/dictation_server/src/features/licenses/licenses.controller.ts b/dictation_server/src/features/licenses/licenses.controller.ts index d739ccd..fac9163 100644 --- a/dictation_server/src/features/licenses/licenses.controller.ts +++ b/dictation_server/src/features/licenses/licenses.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, + HttpException, HttpStatus, Post, Req, @@ -34,6 +35,7 @@ import { RoleGuard } from '../../common/guards/role/roleguards'; import { ADMIN_ROLES, TIERS } from '../../constants'; import jwt from 'jsonwebtoken'; import { makeContext } from '../../common/log'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; @ApiTags('licenses') @Controller('licenses') @@ -73,12 +75,25 @@ export class LicensesController { @Req() req: Request, @Body() body: CreateOrdersRequest, ): Promise { - const accessToken = retrieveAuthorizationToken(req); - const payload = jwt.decode(accessToken, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; // ライセンス注文処理 await this.licensesService.licenseOrders( - payload, + userId, body.poNumber, body.orderCount, ); @@ -111,11 +126,24 @@ export class LicensesController { @Req() req: Request, @Body() body: IssueCardLicensesRequest, ): Promise { - const accessToken = retrieveAuthorizationToken(req); - const payload = jwt.decode(accessToken, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const cardLicenseKeys = await this.licensesService.issueCardLicenseKeys( - payload.userId, + userId, body.createCount, ); @@ -154,11 +182,24 @@ export class LicensesController { @Req() req: Request, @Body() body: ActivateCardLicensesRequest, ): Promise { - const accessToken = retrieveAuthorizationToken(req); - const payload = jwt.decode(accessToken, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; await this.licensesService.activateCardLicenseKey( - payload.userId, + userId, body.cardLicenseKey, ); @@ -194,16 +235,26 @@ export class LicensesController { // eslint-disable-next-line @typescript-eslint/no-unused-vars @Req() req: Request, ): Promise { - const token = retrieveAuthorizationToken(req); - const payload = jwt.decode(token, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; - const context = makeContext(payload.userId); + const context = makeContext(userId); const allocatableLicenses = - await this.licensesService.getAllocatableLicenses( - context, - payload.userId, - ); + await this.licensesService.getAllocatableLicenses(context, userId); return allocatableLicenses; } @@ -245,16 +296,25 @@ export class LicensesController { @Req() req: Request, @Body() body: CancelOrderRequest, ): Promise { - const token = retrieveAuthorizationToken(req); - const payload = jwt.decode(token, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; - const context = makeContext(payload.userId); + const context = makeContext(userId); - await this.licensesService.cancelOrder( - context, - payload.userId, - body.poNumber, - ); + await this.licensesService.cancelOrder(context, userId, body.poNumber); return {}; } } diff --git a/dictation_server/src/features/licenses/licenses.service.spec.ts b/dictation_server/src/features/licenses/licenses.service.spec.ts index 4b5b87d..56d1ef2 100644 --- a/dictation_server/src/features/licenses/licenses.service.spec.ts +++ b/dictation_server/src/features/licenses/licenses.service.spec.ts @@ -56,11 +56,11 @@ describe('LicensesService', () => { accountsRepositoryMockValue, ); const body = new CreateOrdersRequest(); - const token: AccessToken = { userId: '0001', role: '', tier: 5 }; + const userId = '0001'; body.orderCount = 1000; body.poNumber = '1'; expect( - await service.licenseOrders(token, body.poNumber, body.orderCount), + await service.licenseOrders(userId, body.poNumber, body.orderCount), ).toEqual(undefined); }); it('ユーザID取得できなかった場合、エラーとなる', async () => { @@ -78,11 +78,11 @@ describe('LicensesService', () => { accountsRepositoryMockValue, ); const body = new CreateOrdersRequest(); - const token: AccessToken = { userId: '', role: '', tier: 5 }; + const userId = ''; body.orderCount = 1000; body.poNumber = '1'; await expect( - service.licenseOrders(token, body.poNumber, body.orderCount), + service.licenseOrders(userId, body.poNumber, body.orderCount), ).rejects.toEqual( new HttpException( makeErrorResponse('E009999'), @@ -105,11 +105,11 @@ describe('LicensesService', () => { accountsRepositoryMockValue, ); const body = new CreateOrdersRequest(); - const token: AccessToken = { userId: '0001', role: '', tier: 5 }; + const userId = '0001'; body.orderCount = 1000; body.poNumber = '1'; await expect( - service.licenseOrders(token, body.poNumber, body.orderCount), + service.licenseOrders(userId, body.poNumber, body.orderCount), ).rejects.toEqual( new HttpException( makeErrorResponse('E009999'), @@ -130,11 +130,11 @@ describe('LicensesService', () => { accountsRepositoryMockValue, ); const body = new CreateOrdersRequest(); - const token: AccessToken = { userId: '0001', role: '', tier: 5 }; + const userId = '0001'; body.orderCount = 1000; body.poNumber = '1'; await expect( - service.licenseOrders(token, body.poNumber, body.orderCount), + service.licenseOrders(userId, body.poNumber, body.orderCount), ).rejects.toEqual( new HttpException( makeErrorResponse('E010401'), @@ -154,7 +154,7 @@ describe('LicensesService', () => { accountsRepositoryMockValue, ); const body = new IssueCardLicensesRequest(); - const token: AccessToken = { userId: '0001', role: '', tier: 5 }; + const userId = '0001'; body.createCount = 10; const issueCardLicensesResponse: IssueCardLicensesResponse = { cardLicenseKeys: [ @@ -171,7 +171,7 @@ describe('LicensesService', () => { ], }; expect( - await service.issueCardLicenseKeys(token.userId, body.createCount), + await service.issueCardLicenseKeys(userId, body.createCount), ).toEqual(issueCardLicensesResponse); }); it('カードライセンス発行に失敗した場合、エラーになる', async () => { @@ -187,10 +187,10 @@ describe('LicensesService', () => { accountsRepositoryMockValue, ); const body = new IssueCardLicensesRequest(); - const token: AccessToken = { userId: '0001', role: '', tier: 5 }; + const userId = '0001'; body.createCount = 1000; await expect( - service.issueCardLicenseKeys(token.userId, body.createCount), + service.issueCardLicenseKeys(userId, body.createCount), ).rejects.toEqual( new HttpException( makeErrorResponse('E009999'), @@ -210,10 +210,10 @@ describe('LicensesService', () => { accountsRepositoryMockValue, ); const body = new ActivateCardLicensesRequest(); - const token: AccessToken = { userId: '0001', role: '', tier: 5 }; + const userId = '0001'; body.cardLicenseKey = 'WZCETXC0Z9PQZ9GKRGGY'; expect( - await service.activateCardLicenseKey(token.userId, body.cardLicenseKey), + await service.activateCardLicenseKey(userId, body.cardLicenseKey), ).toEqual(undefined); }); it('カードライセンス取り込みに失敗した場合、エラーになる(DBエラー)', async () => { @@ -229,10 +229,10 @@ describe('LicensesService', () => { accountsRepositoryMockValue, ); const body = new ActivateCardLicensesRequest(); - const token: AccessToken = { userId: '0001', role: '', tier: 5 }; + const userId = '0001'; body.cardLicenseKey = 'WZCETXC0Z9PQZ9GKRGGY'; await expect( - service.activateCardLicenseKey(token.userId, body.cardLicenseKey), + service.activateCardLicenseKey(userId, body.cardLicenseKey), ).rejects.toEqual( new HttpException( makeErrorResponse('E009999'), @@ -254,10 +254,10 @@ describe('LicensesService', () => { accountsRepositoryMockValue, ); const body = new ActivateCardLicensesRequest(); - const token: AccessToken = { userId: '0001', role: '', tier: 5 }; + const userId = '0001'; body.cardLicenseKey = 'WZCETXC0Z9PQZ9GKRGGY'; await expect( - service.activateCardLicenseKey(token.userId, body.cardLicenseKey), + service.activateCardLicenseKey(userId, body.cardLicenseKey), ).rejects.toEqual( new HttpException(makeErrorResponse('E010801'), HttpStatus.BAD_REQUEST), ); @@ -276,10 +276,10 @@ describe('LicensesService', () => { accountsRepositoryMockValue, ); const body = new ActivateCardLicensesRequest(); - const token: AccessToken = { userId: '0001', role: '', tier: 5 }; + const userId = '0001'; body.cardLicenseKey = 'WZCETXC0Z9PQZ9GKRGGY'; await expect( - service.activateCardLicenseKey(token.userId, body.cardLicenseKey), + service.activateCardLicenseKey(userId, body.cardLicenseKey), ).rejects.toEqual( new HttpException(makeErrorResponse('E010802'), HttpStatus.BAD_REQUEST), ); @@ -287,7 +287,7 @@ describe('LicensesService', () => { }); describe('DBテスト', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -300,12 +300,15 @@ describe('DBテスト', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('カードライセンス発行が完了する(発行数が合っているか確認)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId } = await makeTestUser(source, { @@ -323,7 +326,9 @@ describe('DBテスト', () => { }); it('カードライセンス取り込みが完了する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId } = await makeTestUser(source, { @@ -362,13 +367,15 @@ describe('DBテスト', () => { ); const dbSelectResultFromLicense = await selectLicense(source, license_id); expect( - dbSelectResultFromCardLicense.cardLicense.activated_at, + dbSelectResultFromCardLicense?.cardLicense?.activated_at, ).toBeDefined(); - expect(dbSelectResultFromLicense.license.account_id).toEqual(accountId); + expect(dbSelectResultFromLicense?.license?.account_id).toEqual(accountId); }); it('取込可能なライセンスのみが取得できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const now = new Date(); const { id: accountId } = await makeTestSimpleAccount(source); @@ -513,7 +520,7 @@ describe('DBテスト', () => { }); describe('ライセンス割り当て', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -526,12 +533,15 @@ describe('ライセンス割り当て', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('未割当のライセンスに対して、ライセンス割り当てが完了する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: userId } = await makeTestUser(source, { @@ -567,11 +577,11 @@ describe('ライセンス割り当て', () => { await service.allocateLicense(makeContext('trackingId'), userId, 1); const resultLicense = await selectLicense(source, 1); - expect(resultLicense.license.allocated_user_id).toBe(userId); - expect(resultLicense.license.status).toBe( + expect(resultLicense.license?.allocated_user_id).toBe(userId); + expect(resultLicense.license?.status).toBe( LICENSE_ALLOCATED_STATUS.ALLOCATED, ); - expect(resultLicense.license.expiry_date.setMilliseconds(0)).toEqual( + expect(resultLicense.license?.expiry_date?.setMilliseconds(0)).toEqual( expiry_date.setMilliseconds(0), ); const licenseAllocationHistory = await selectLicenseAllocationHistory( @@ -579,22 +589,24 @@ describe('ライセンス割り当て', () => { userId, 1, ); - expect(licenseAllocationHistory.licenseAllocationHistory.user_id).toBe( + expect(licenseAllocationHistory.licenseAllocationHistory?.user_id).toBe( userId, ); - expect(licenseAllocationHistory.licenseAllocationHistory.license_id).toBe( + expect(licenseAllocationHistory.licenseAllocationHistory?.license_id).toBe( 1, ); - expect(licenseAllocationHistory.licenseAllocationHistory.is_allocated).toBe( - true, - ); - expect(licenseAllocationHistory.licenseAllocationHistory.account_id).toBe( + expect( + licenseAllocationHistory.licenseAllocationHistory?.is_allocated, + ).toBe(true); + expect(licenseAllocationHistory.licenseAllocationHistory?.account_id).toBe( accountId, ); }); it('再割り当て可能なライセンスに対して、ライセンス割り当てが完了する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: userId } = await makeTestUser(source, { @@ -630,30 +642,32 @@ describe('ライセンス割り当て', () => { await service.allocateLicense(makeContext('trackingId'), userId, 1); const result = await selectLicense(source, 1); - expect(result.license.allocated_user_id).toBe(userId); - expect(result.license.status).toBe(LICENSE_ALLOCATED_STATUS.ALLOCATED); - expect(result.license.expiry_date).toEqual(date); + expect(result.license?.allocated_user_id).toBe(userId); + expect(result.license?.status).toBe(LICENSE_ALLOCATED_STATUS.ALLOCATED); + expect(result.license?.expiry_date).toEqual(date); const licenseAllocationHistory = await selectLicenseAllocationHistory( source, userId, 1, ); - expect(licenseAllocationHistory.licenseAllocationHistory.user_id).toBe( + expect(licenseAllocationHistory.licenseAllocationHistory?.user_id).toBe( userId, ); - expect(licenseAllocationHistory.licenseAllocationHistory.license_id).toBe( + expect(licenseAllocationHistory.licenseAllocationHistory?.license_id).toBe( 1, ); - expect(licenseAllocationHistory.licenseAllocationHistory.is_allocated).toBe( - true, - ); - expect(licenseAllocationHistory.licenseAllocationHistory.account_id).toBe( + expect( + licenseAllocationHistory.licenseAllocationHistory?.is_allocated, + ).toBe(true); + expect(licenseAllocationHistory.licenseAllocationHistory?.account_id).toBe( accountId, ); }); it('未割当のライセンスに対して、別のライセンスが割り当てられているユーザーの割り当てが完了する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: userId } = await makeTestUser(source, { @@ -705,32 +719,32 @@ describe('ライセンス割り当て', () => { // もともと割り当てられていたライセンスの状態確認 const result1 = await selectLicense(source, 1); - expect(result1.license.allocated_user_id).toBe(null); - expect(result1.license.status).toBe(LICENSE_ALLOCATED_STATUS.REUSABLE); - expect(result1.license.expiry_date).toEqual(date); + expect(result1.license?.allocated_user_id).toBe(null); + expect(result1.license?.status).toBe(LICENSE_ALLOCATED_STATUS.REUSABLE); + expect(result1.license?.expiry_date).toEqual(date); const licenseAllocationHistory = await selectLicenseAllocationHistory( source, userId, 1, ); - expect(licenseAllocationHistory.licenseAllocationHistory.user_id).toBe( + expect(licenseAllocationHistory.licenseAllocationHistory?.user_id).toBe( userId, ); - expect(licenseAllocationHistory.licenseAllocationHistory.license_id).toBe( + expect(licenseAllocationHistory.licenseAllocationHistory?.license_id).toBe( 1, ); - expect(licenseAllocationHistory.licenseAllocationHistory.is_allocated).toBe( - false, - ); - expect(licenseAllocationHistory.licenseAllocationHistory.account_id).toBe( + expect( + licenseAllocationHistory.licenseAllocationHistory?.is_allocated, + ).toBe(false); + expect(licenseAllocationHistory.licenseAllocationHistory?.account_id).toBe( accountId, ); // 新たに割り当てたライセンスの状態確認 const result2 = await selectLicense(source, 2); - expect(result2.license.allocated_user_id).toBe(userId); - expect(result2.license.status).toBe(LICENSE_ALLOCATED_STATUS.ALLOCATED); - expect(result2.license.expiry_date.setMilliseconds(0)).toEqual( + expect(result2.license?.allocated_user_id).toBe(userId); + expect(result2.license?.status).toBe(LICENSE_ALLOCATED_STATUS.ALLOCATED); + expect(result2.license?.expiry_date?.setMilliseconds(0)).toEqual( expiry_date.setMilliseconds(0), ); const newlicenseAllocationHistory = await selectLicenseAllocationHistory( @@ -738,22 +752,24 @@ describe('ライセンス割り当て', () => { userId, 2, ); - expect(newlicenseAllocationHistory.licenseAllocationHistory.user_id).toBe( + expect(newlicenseAllocationHistory.licenseAllocationHistory?.user_id).toBe( userId, ); expect( - newlicenseAllocationHistory.licenseAllocationHistory.license_id, + newlicenseAllocationHistory.licenseAllocationHistory?.license_id, ).toBe(2); expect( - newlicenseAllocationHistory.licenseAllocationHistory.is_allocated, + newlicenseAllocationHistory.licenseAllocationHistory?.is_allocated, ).toBe(true); expect( - newlicenseAllocationHistory.licenseAllocationHistory.account_id, + newlicenseAllocationHistory.licenseAllocationHistory?.account_id, ).toBe(accountId); }); it('割り当て時にライセンス履歴テーブルへの登録が完了する(元がNORMALのとき)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: userId } = await makeTestUser(source, { @@ -806,12 +822,14 @@ describe('ライセンス割り当て', () => { 2, ); expect( - licenseAllocationHistory.licenseAllocationHistory.switch_from_type, + licenseAllocationHistory.licenseAllocationHistory?.switch_from_type, ).toBe('NONE'); }); it('割り当て時にライセンス履歴テーブルへの登録が完了する(元がCARDのとき)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: userId } = await makeTestUser(source, { @@ -864,12 +882,14 @@ describe('ライセンス割り当て', () => { 2, ); expect( - licenseAllocationHistory.licenseAllocationHistory.switch_from_type, + licenseAllocationHistory.licenseAllocationHistory?.switch_from_type, ).toBe('CARD'); }); it('割り当て時にライセンス履歴テーブルへの登録が完了する(元がTRIALのとき)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: userId } = await makeTestUser(source, { @@ -922,12 +942,14 @@ describe('ライセンス割り当て', () => { 2, ); expect( - licenseAllocationHistory.licenseAllocationHistory.switch_from_type, + licenseAllocationHistory.licenseAllocationHistory?.switch_from_type, ).toBe('TRIAL'); }); it('有効期限が切れているライセンスを割り当てようとした場合、エラーになる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: userId } = await makeTestUser(source, { @@ -961,7 +983,9 @@ describe('ライセンス割り当て', () => { }); it('割り当て不可なライセンスを割り当てようとした場合、エラーになる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: userId } = await makeTestUser(source, { @@ -1013,7 +1037,7 @@ describe('ライセンス割り当て', () => { }); describe('ライセンス割り当て解除', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -1026,12 +1050,15 @@ describe('ライセンス割り当て解除', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('ライセンスの割り当て解除が完了する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: userId } = await makeTestUser(source, { @@ -1068,11 +1095,11 @@ describe('ライセンス割り当て解除', () => { // 割り当て解除したライセンスの状態確認 const deallocatedLicense = await selectLicense(source, 1); - expect(deallocatedLicense.license.allocated_user_id).toBe(null); - expect(deallocatedLicense.license.status).toBe( + expect(deallocatedLicense.license?.allocated_user_id).toBe(null); + expect(deallocatedLicense.license?.status).toBe( LICENSE_ALLOCATED_STATUS.REUSABLE, ); - expect(deallocatedLicense.license.expiry_date).toEqual(date); + expect(deallocatedLicense.license?.expiry_date).toEqual(date); // ライセンス履歴テーブルの状態確認 const licenseAllocationHistory = await selectLicenseAllocationHistory( @@ -1080,25 +1107,27 @@ describe('ライセンス割り当て解除', () => { userId, 1, ); - expect(licenseAllocationHistory.licenseAllocationHistory.user_id).toBe( + expect(licenseAllocationHistory.licenseAllocationHistory?.user_id).toBe( userId, ); - expect(licenseAllocationHistory.licenseAllocationHistory.license_id).toBe( + expect(licenseAllocationHistory.licenseAllocationHistory?.license_id).toBe( 1, ); - expect(licenseAllocationHistory.licenseAllocationHistory.is_allocated).toBe( - false, - ); - expect(licenseAllocationHistory.licenseAllocationHistory.account_id).toBe( + expect( + licenseAllocationHistory.licenseAllocationHistory?.is_allocated, + ).toBe(false); + expect(licenseAllocationHistory.licenseAllocationHistory?.account_id).toBe( accountId, ); expect( - licenseAllocationHistory.licenseAllocationHistory.switch_from_type, + licenseAllocationHistory.licenseAllocationHistory?.switch_from_type, ).toBe('NONE'); }); it('ライセンスが既に割り当て解除されていた場合、エラーとなる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: userId } = await makeTestUser(source, { @@ -1158,7 +1187,7 @@ describe('ライセンス割り当て解除', () => { }); describe('ライセンス注文キャンセル', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -1171,12 +1200,15 @@ describe('ライセンス注文キャンセル', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('ライセンス注文のキャンセルが完了する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { tier2Accounts: tier2Accounts } = await makeHierarchicalAccounts( source, ); @@ -1185,7 +1217,7 @@ describe('ライセンス注文キャンセル', () => { source, poNumber, tier2Accounts[0].account.id, - tier2Accounts[0].account.parent_account_id, + tier2Accounts[0].account.parent_account_id ?? 0, null, 10, 'Issue Requesting', @@ -1195,7 +1227,7 @@ describe('ライセンス注文キャンセル', () => { source, poNumber, tier2Accounts[0].account.id, - tier2Accounts[0].account.parent_account_id, + tier2Accounts[0].account.parent_account_id ?? 0, null, 10, 'Order Canceled', @@ -1214,12 +1246,14 @@ describe('ライセンス注文キャンセル', () => { tier2Accounts[0].account.id, poNumber, ); - expect(orderRecord.orderLicense.canceled_at).toBeDefined(); - expect(orderRecord.orderLicense.status).toBe('Order Canceled'); + expect(orderRecord.orderLicense?.canceled_at).toBeDefined(); + expect(orderRecord.orderLicense?.status).toBe('Order Canceled'); }); it('ライセンスが既に発行済みの場合、エラーとなる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { tier2Accounts: tier2Accounts } = await makeHierarchicalAccounts( source, ); @@ -1228,7 +1262,7 @@ describe('ライセンス注文キャンセル', () => { source, poNumber, tier2Accounts[0].account.id, - tier2Accounts[0].account.parent_account_id, + tier2Accounts[0].account.parent_account_id ?? 0, null, 10, 'Issued', @@ -1247,7 +1281,9 @@ describe('ライセンス注文キャンセル', () => { }); it('ライセンスが既にキャンセル済みの場合、エラーとなる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { tier2Accounts: tier2Accounts } = await makeHierarchicalAccounts( source, @@ -1257,7 +1293,7 @@ describe('ライセンス注文キャンセル', () => { source, poNumber, tier2Accounts[0].account.id, - tier2Accounts[0].account.parent_account_id, + tier2Accounts[0].account.parent_account_id ?? 0, null, 10, 'Order Canceled', diff --git a/dictation_server/src/features/licenses/licenses.service.ts b/dictation_server/src/features/licenses/licenses.service.ts index 2c23bd8..3aa04f1 100644 --- a/dictation_server/src/features/licenses/licenses.service.ts +++ b/dictation_server/src/features/licenses/licenses.service.ts @@ -33,20 +33,20 @@ export class LicensesService { * @param body */ async licenseOrders( - accessToken: AccessToken, + externalId: string, poNumber: string, orderCount: number, ): Promise { //アクセストークンからユーザーIDを取得する this.logger.log(`[IN] ${this.licenseOrders.name}`); - const userId = accessToken.userId; let myAccountId: number; - let parentAccountId: number; + let parentAccountId: number | undefined; // ユーザIDからアカウントIDを取得する try { - myAccountId = (await this.usersRepository.findUserByExternalId(userId)) - .account_id; + myAccountId = ( + await this.usersRepository.findUserByExternalId(externalId) + ).account_id; } catch (e) { this.logger.error(`error=${e}`); switch (e.constructor) { @@ -65,9 +65,13 @@ export class LicensesService { // 親アカウントIDを取得 try { - parentAccountId = ( - await this.accountsRepository.findAccountById(myAccountId) - ).parent_account_id; + parentAccountId = + (await this.accountsRepository.findAccountById(myAccountId)) + .parent_account_id ?? undefined; + // 親アカウントIDが取得できない場合はエラー + if (parentAccountId === undefined) { + throw new Error('parent account id is undefined'); + } } catch (e) { this.logger.error(`error=${e}`); switch (e.constructor) { diff --git a/dictation_server/src/features/licenses/test/liscense.service.mock.ts b/dictation_server/src/features/licenses/test/liscense.service.mock.ts index 619aa95..5c0ef7b 100644 --- a/dictation_server/src/features/licenses/test/liscense.service.mock.ts +++ b/dictation_server/src/features/licenses/test/liscense.service.mock.ts @@ -124,11 +124,11 @@ export const makeDefaultUsersRepositoryMockValue = user1.notification = false; user1.encryption = false; user1.prompt = false; - user1.deleted_at = undefined; + user1.deleted_at = null; user1.created_by = 'test'; user1.created_at = new Date(); - user1.updated_by = undefined; - user1.updated_at = undefined; + user1.updated_by = null; + user1.updated_at = new Date(); return { findUserByExternalId: user1, diff --git a/dictation_server/src/features/licenses/test/utility.ts b/dictation_server/src/features/licenses/test/utility.ts index d0e9a89..321f9c0 100644 --- a/dictation_server/src/features/licenses/test/utility.ts +++ b/dictation_server/src/features/licenses/test/utility.ts @@ -12,14 +12,14 @@ import { export const createLicense = async ( datasource: DataSource, licenseId: number, - expiry_date: Date, + expiry_date: Date | null, accountId: number, type: string, status: string, - allocated_user_id: number, - order_id: number, - deleted_at: Date, - delete_order_id: number, + allocated_user_id: number | null, + order_id: number | null, + deleted_at: Date | null, + delete_order_id: number | null, ): Promise => { const { identifiers } = await datasource.getRepository(License).insert({ id: licenseId, @@ -107,7 +107,7 @@ export const createOrder = async ( poNumber: string, fromId: number, toId: number, - issuedAt: Date, + issuedAt: Date | null, quantity: number, status: string, ): Promise => { @@ -138,7 +138,7 @@ export const selectCardLicensesCount = async ( export const selectCardLicense = async ( datasource: DataSource, cardLicenseKey: string, -): Promise<{ cardLicense: CardLicense }> => { +): Promise<{ cardLicense: CardLicense | null }> => { const cardLicense = await datasource.getRepository(CardLicense).findOne({ where: { card_license_key: cardLicenseKey, @@ -150,7 +150,7 @@ export const selectCardLicense = async ( export const selectLicense = async ( datasource: DataSource, id: number, -): Promise<{ license: License }> => { +): Promise<{ license: License | null }> => { const license = await datasource.getRepository(License).findOne({ where: { id: id, @@ -163,7 +163,7 @@ export const selectLicenseAllocationHistory = async ( datasource: DataSource, userId: number, licence_id: number, -): Promise<{ licenseAllocationHistory: LicenseAllocationHistory }> => { +): Promise<{ licenseAllocationHistory: LicenseAllocationHistory | null }> => { const licenseAllocationHistory = await datasource .getRepository(LicenseAllocationHistory) .findOne({ @@ -182,7 +182,7 @@ export const selectOrderLicense = async ( datasource: DataSource, accountId: number, poNumber: string, -): Promise<{ orderLicense: LicenseOrder }> => { +): Promise<{ orderLicense: LicenseOrder | null }> => { const orderLicense = await datasource.getRepository(LicenseOrder).findOne({ where: { from_account_id: accountId, diff --git a/dictation_server/src/features/licenses/types/types.ts b/dictation_server/src/features/licenses/types/types.ts index a75885f..d303b1e 100644 --- a/dictation_server/src/features/licenses/types/types.ts +++ b/dictation_server/src/features/licenses/types/types.ts @@ -47,8 +47,8 @@ export class GetAllocatableLicensesRequest {} export class AllocatableLicenseInfo { @ApiProperty() licenseId: number; - @ApiProperty() - expiryDate: Date; + @ApiProperty({ required: false }) + expiryDate?: Date; } export class GetAllocatableLicensesResponse { @ApiProperty({ type: [AllocatableLicenseInfo] }) diff --git a/dictation_server/src/features/notification/notification.controller.ts b/dictation_server/src/features/notification/notification.controller.ts index c234163..0a640be 100644 --- a/dictation_server/src/features/notification/notification.controller.ts +++ b/dictation_server/src/features/notification/notification.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + HttpException, HttpStatus, Post, Req, @@ -21,6 +22,7 @@ import { retrieveAuthorizationToken } from '../../common/http/helper'; import { AccessToken } from '../../common/token'; import jwt from 'jsonwebtoken'; import { makeContext } from '../../common/log'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; @ApiTags('notification') @Controller('notification') @@ -57,7 +59,20 @@ export class NotificationController { const { handler, pns } = body; const accessToken = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(accessToken, { json: true }) as AccessToken; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); diff --git a/dictation_server/src/features/notification/test/notification.service.mock.ts b/dictation_server/src/features/notification/test/notification.service.mock.ts index c2df2d6..51d7601 100644 --- a/dictation_server/src/features/notification/test/notification.service.mock.ts +++ b/dictation_server/src/features/notification/test/notification.service.mock.ts @@ -77,14 +77,14 @@ export const makeDefaultUsersRepositoryMockValue = user.external_id = 'external_id'; user.account_id = 123; user.role = 'none'; - user.author_id = undefined; + user.author_id = null; user.accepted_eula_version = '1.0'; user.accepted_dpa_version = '1.0'; user.email_verified = true; user.auto_renew = false; user.license_alert = false; user.notification = false; - user.deleted_at = undefined; + user.deleted_at = null; user.created_by = 'test'; user.created_at = new Date(); user.updated_by = 'test'; diff --git a/dictation_server/src/features/tasks/tasks.controller.ts b/dictation_server/src/features/tasks/tasks.controller.ts index b2c860e..49fbe9e 100644 --- a/dictation_server/src/features/tasks/tasks.controller.ts +++ b/dictation_server/src/features/tasks/tasks.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, Headers, + HttpException, HttpStatus, Param, ParseIntPipe, @@ -32,6 +33,8 @@ import { TasksResponse, } from './types/types'; import { + SortDirection, + TaskListSortableAttribute, isSortDirection, isTaskListSortableAttribute, } from '../../common/types/sort'; @@ -43,6 +46,7 @@ import { RoleGuard } from '../../common/guards/role/roleguards'; import { ADMIN_ROLES, USER_ROLES } from '../../constants'; import { Roles } from '../../common/types/role'; import { makeContext } from '../../common/log'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; @ApiTags('tasks') @Controller('tasks') @@ -80,22 +84,38 @@ export class TasksController { @Req() req, @Query() body: TasksRequest, ): Promise { - const accessToken = retrieveAuthorizationToken(req); - const decodedToken = jwt.decode(accessToken, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + 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(decodedToken.userId); + const context = makeContext(userId); const { limit, offset, status } = body; - const paramName = isTaskListSortableAttribute(body.paramName) - ? body.paramName + const paramName = isTaskListSortableAttribute(body.paramName ?? '') + ? (body.paramName as TaskListSortableAttribute) : undefined; - const direction = isSortDirection(body.direction) - ? body.direction + const direction = isSortDirection(body.direction ?? '') + ? (body.direction as SortDirection) : undefined; const { tasks, total } = await this.taskService.getTasks( context, - decodedToken, + userId, + roles, offset, limit, // statusが指定されていない場合は全てのステータスを取得する @@ -183,10 +203,22 @@ export class TasksController { @Param() param: ChangeStatusRequest, ): Promise { // AuthGuardでチェック済みなのでここでのアクセストークンチェックはしない - const accessToken = retrieveAuthorizationToken(req); - const { role, userId } = jwt.decode(accessToken, { - json: true, - }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + 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[]; @@ -241,10 +273,22 @@ export class TasksController { ): Promise { const { audioFileId } = params; // AuthGuardでチェック済みなのでここでのアクセストークンチェックはしない - const accessToken = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(accessToken, { - json: true, - }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId, role } = decodedAccessToken as AccessToken; const context = makeContext(userId); @@ -296,10 +340,22 @@ export class TasksController { ): Promise { const { audioFileId } = params; // AuthGuardでチェック済みなのでここでのアクセストークンチェックはしない - const accessToken = retrieveAuthorizationToken(req); - const { userId, role } = jwt.decode(accessToken, { - json: true, - }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + 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[]; @@ -353,10 +409,22 @@ export class TasksController { ): Promise { const { audioFileId } = params; // AuthGuardでチェック済みなのでここでのアクセストークンチェックはしない - const accessToken = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(accessToken, { - json: true, - }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); @@ -491,11 +559,22 @@ export class TasksController { @Body() body: PostCheckoutPermissionRequest, ): Promise { const { assignees } = body; - const accessToken = retrieveAuthorizationToken(req); - const { role, userId } = jwt.decode(accessToken, { - json: true, - }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + 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[]; diff --git a/dictation_server/src/features/tasks/tasks.service.spec.ts b/dictation_server/src/features/tasks/tasks.service.spec.ts index 6b11396..bc61100 100644 --- a/dictation_server/src/features/tasks/tasks.service.spec.ts +++ b/dictation_server/src/features/tasks/tasks.service.spec.ts @@ -16,11 +16,12 @@ import { createUserGroup, getCheckoutPermissions, getTask, - makeTaskTestingModule, + makeTaskTestingModuleWithNotificaiton, } from './test/utility'; import { Adb2cTooManyRequestsError } from '../../gateways/adb2c/adb2c.service'; import { makeContext } from '../../common/log'; import { makeTestSimpleAccount, makeTestUser } from '../../common/test/utility'; +import { ADMIN_ROLES, USER_ROLES } from '../../constants'; describe('TasksService', () => { it('タスク一覧を取得できる(admin)', async () => { @@ -39,7 +40,7 @@ describe('TasksService', () => { notificationhubServiceMockValue, ); - const accessToken = { userId: 'userId', role: 'admin', tier: 5 }; + const userId = 'userId'; const offset = 0; const limit = 20; const status = ['Uploaded', 'Backup']; @@ -48,7 +49,8 @@ describe('TasksService', () => { expect( await service.tasksService.getTasks( makeContext('trackingId'), - accessToken, + userId, + [ADMIN_ROLES.ADMIN, USER_ROLES.NONE], offset, limit, status, @@ -102,7 +104,6 @@ describe('TasksService', () => { const userGroupsRepositoryMockValue = makeDefaultUserGroupsRepositoryMockValue(); const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue(); - const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); usersRepositoryMockValue.findUserByExternalId = new Error('DB failed'); @@ -114,7 +115,7 @@ describe('TasksService', () => { notificationhubServiceMockValue, ); - const accessToken = { userId: 'userId', role: 'admin', tier: 5 }; + const userId = 'userId'; const offset = 0; const limit = 20; const status = ['Uploaded', 'Backup']; @@ -123,7 +124,8 @@ describe('TasksService', () => { await expect( service.tasksService.getTasks( makeContext('trackingId'), - accessToken, + userId, + [ADMIN_ROLES.ADMIN, USER_ROLES.NONE], offset, limit, status, @@ -155,7 +157,7 @@ describe('TasksService', () => { notificationhubServiceMockValue, ); - const accessToken = { userId: 'userId', role: 'admin', tier: 5 }; + const userId = 'userId'; const offset = 0; const limit = 20; const status = ['Uploaded', 'Backup']; @@ -164,7 +166,8 @@ describe('TasksService', () => { await expect( service.tasksService.getTasks( makeContext('trackingId'), - accessToken, + userId, + [ADMIN_ROLES.ADMIN, USER_ROLES.NONE], offset, limit, status, @@ -192,7 +195,13 @@ describe('TasksService', () => { status: 'Uploaded', priority: '00', created_at: new Date('2023-01-01T01:01:01.000'), - option_items: undefined, // 存在しない場合でも空配列であるはずのものがundefined + option_items: null, // 存在しない場合でも空配列であるはずのものがnullになっているため形式不正 + finished_at: null, + started_at: null, + typist_user_id: null, + template_file_id: null, + typist_user: null, + template_file: null, file: { id: 1, account_id: 1, @@ -210,6 +219,8 @@ describe('TasksService', () => { audio_format: 'DS', comment: 'comment', is_encrypted: true, + deleted_at: null, + task: null, }, }, ], @@ -229,7 +240,7 @@ describe('TasksService', () => { adb2cServiceMockValue, notificationhubServiceMockValue, ); - const accessToken = { userId: 'userId', role: 'admin', tier: 5 }; + const userId = 'userId'; const offset = 0; const limit = 20; const status = ['Uploaded', 'Backup']; @@ -238,7 +249,8 @@ describe('TasksService', () => { await expect( service.tasksService.getTasks( makeContext('trackingId'), - accessToken, + userId, + [ADMIN_ROLES.ADMIN, USER_ROLES.NONE], offset, limit, status, @@ -273,7 +285,7 @@ describe('TasksService', () => { notificationhubServiceMockValue, ); - const accessToken = { userId: 'userId', role: 'author', tier: 5 }; + const userId = 'userId'; const offset = 0; const limit = 20; const status = ['Uploaded', 'Backup']; @@ -281,7 +293,8 @@ describe('TasksService', () => { const direction = 'ASC'; const result = await service.tasksService.getTasks( makeContext('trackingId'), - accessToken, + userId, + [USER_ROLES.AUTHOR], offset, limit, status, @@ -354,7 +367,7 @@ describe('TasksService', () => { notificationhubServiceMockValue, ); - const accessToken = { userId: 'userId', role: 'author', tier: 5 }; + const userId = 'userId'; const offset = 0; const limit = 20; const status = ['Uploaded', 'Backup']; @@ -363,7 +376,8 @@ describe('TasksService', () => { await expect( service.tasksService.getTasks( makeContext('trackingId'), - accessToken, + userId, + [USER_ROLES.AUTHOR], offset, limit, status, @@ -399,7 +413,7 @@ describe('TasksService', () => { notificationhubServiceMockValue, ); - const accessToken = { userId: 'userId', role: 'typist', tier: 5 }; + const userId = 'userId'; const offset = 0; const limit = 20; const status = ['Uploaded', 'Backup']; @@ -407,7 +421,8 @@ describe('TasksService', () => { const direction = 'ASC'; const result = await service.tasksService.getTasks( makeContext('trackingId'), - accessToken, + userId, + [USER_ROLES.TYPIST], offset, limit, status, @@ -480,7 +495,7 @@ describe('TasksService', () => { notificationhubServiceMockValue, ); - const accessToken = { userId: 'userId', role: 'typist', tier: 5 }; + const userId = 'userId'; const offset = 0; const limit = 20; const status = ['Uploaded', 'Backup']; @@ -489,47 +504,8 @@ describe('TasksService', () => { await expect( service.tasksService.getTasks( makeContext('trackingId'), - accessToken, - offset, - limit, - status, - paramName, - direction, - ), - ).rejects.toEqual( - new HttpException( - makeErrorResponse('E009999'), - HttpStatus.INTERNAL_SERVER_ERROR, - ), - ); - }); - - it('想定外のRoleの場合、エラーを返却する', async () => { - const tasksRepositoryMockValue = makeDefaultTasksRepositoryMockValue(); - const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); - const userGroupsRepositoryMockValue = - makeDefaultUserGroupsRepositoryMockValue(); - const adb2cServiceMockValue = makeDefaultAdb2cServiceMockValue(); - const notificationhubServiceMockValue = - makeDefaultNotificationhubServiceMockValue(); - const service = await makeTasksServiceMock( - tasksRepositoryMockValue, - usersRepositoryMockValue, - userGroupsRepositoryMockValue, - adb2cServiceMockValue, - notificationhubServiceMockValue, - ); - - const accessToken = { userId: 'userId', role: 'XXX', tier: 5 }; - const offset = 0; - const limit = 20; - const status = ['Uploaded', 'Backup']; - const paramName = 'JOB_NUMBER'; - const direction = 'ASC'; - await expect( - service.tasksService.getTasks( - makeContext('trackingId'), - accessToken, + userId, + [USER_ROLES.TYPIST], offset, limit, status, @@ -561,7 +537,7 @@ describe('TasksService', () => { notificationhubServiceMockValue, ); - const accessToken = { userId: 'userId', role: 'admin', tier: 5 }; + const userId = 'userId'; const offset = 0; const limit = 20; const status = ['Uploaded,Backup']; @@ -570,7 +546,8 @@ describe('TasksService', () => { await expect( service.tasksService.getTasks( makeContext('trackingId'), - accessToken, + userId, + [ADMIN_ROLES.ADMIN, USER_ROLES.NONE], offset, limit, status, @@ -586,7 +563,7 @@ describe('TasksService', () => { }); describe('DBテスト', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -599,6 +576,7 @@ describe('TasksService', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); @@ -606,10 +584,12 @@ describe('TasksService', () => { it('[Admin] Taskが0件であっても実行できる', async () => { const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + if (!source) fail(); + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId } = await makeTestUser(source, { account_id: accountId, @@ -618,7 +598,6 @@ describe('TasksService', () => { }); const service = module.get(TasksService); - const accessToken = { userId: externalId, role: 'admin', tier: 5 }; const offset = 0; const limit = 20; const status = ['Uploaded,Backup']; @@ -627,7 +606,8 @@ describe('TasksService', () => { const { tasks, total } = await service.getTasks( makeContext('trackingId'), - accessToken, + externalId, + [ADMIN_ROLES.ADMIN, USER_ROLES.NONE], offset, limit, status, @@ -637,15 +617,18 @@ describe('TasksService', () => { expect(tasks).toEqual([]); expect(total).toEqual(0); }); + it('[Author] Authorは自分が作成者のTask一覧を取得できる', async () => { const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + if (!source) fail(); + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); - const { id: userId } = await makeTestUser(source, { + const { id: userId, external_id } = await makeTestUser(source, { account_id: accountId, external_id: 'userId', role: 'author', @@ -673,7 +656,6 @@ describe('TasksService', () => { ); const service = module.get(TasksService); - const accessToken = { userId: 'userId', role: 'author', tier: 5 }; const offset = 0; const limit = 20; const status = ['Uploaded', 'Backup']; @@ -682,7 +664,8 @@ describe('TasksService', () => { const { tasks, total } = await service.getTasks( makeContext('trackingId'), - accessToken, + external_id, + [USER_ROLES.AUTHOR], offset, limit, status, @@ -703,12 +686,14 @@ describe('TasksService', () => { it('[Author] Authorは同一アカウントであっても自分以外のAuhtorのTaskは取得できない', async () => { const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + if (!source) fail(); + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); - const { id: userId_1 } = await makeTestUser(source, { + const { id: userId_1, external_id } = await makeTestUser(source, { account_id: accountId, external_id: 'userId_1', role: 'author', @@ -743,7 +728,6 @@ describe('TasksService', () => { ); const service = module.get(TasksService); - const accessToken = { userId: 'userId_1', role: 'author', tier: 5 }; const offset = 0; const limit = 20; const status = ['Uploaded', 'Backup']; @@ -752,7 +736,8 @@ describe('TasksService', () => { const { tasks, total } = await service.getTasks( makeContext('trackingId'), - accessToken, + external_id, + [USER_ROLES.AUTHOR], offset, limit, status, @@ -769,7 +754,7 @@ describe('TasksService', () => { }); describe('changeCheckoutPermission', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -782,17 +767,20 @@ describe('changeCheckoutPermission', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('タスクのチェックアウト権限を変更できる。(個人指定)', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId_1 } = await makeTestUser(source, { account_id: accountId, @@ -847,12 +835,14 @@ describe('changeCheckoutPermission', () => { }); it('タスクのチェックアウト権限を変更できる。(グループ指定)', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId_1 } = await makeTestUser(source, { account_id: accountId, @@ -913,12 +903,14 @@ describe('changeCheckoutPermission', () => { }); it('タスクのチェックアウト権限を変更できる。(チェックアウト権限を外す)', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId_1 } = await makeTestUser(source, { account_id: accountId, @@ -962,12 +954,14 @@ describe('changeCheckoutPermission', () => { }); it('ユーザーが存在しない場合、タスクのチェックアウト権限を変更できない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId_1 } = await makeTestUser(source, { account_id: accountId, @@ -1014,12 +1008,14 @@ describe('changeCheckoutPermission', () => { }); it('ユーザーグループが存在しない場合、タスクのチェックアウト権限を変更できない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId_1 } = await makeTestUser(source, { account_id: accountId, @@ -1066,12 +1062,14 @@ describe('changeCheckoutPermission', () => { }); it('タスクが存在しない場合、タスクのチェックアウト権限を変更できない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -1100,12 +1098,14 @@ describe('changeCheckoutPermission', () => { }); it('タスクのステータスがUploadedでない場合、タスクのチェックアウト権限を変更できない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -1144,12 +1144,14 @@ describe('changeCheckoutPermission', () => { }); it('ユーザーのRoleがAuthorでタスクのAuthorIDと自身のAuthorIDが一致しない場合、タスクのチェックアウト権限を変更できない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -1188,13 +1190,15 @@ describe('changeCheckoutPermission', () => { }); it('通知に失敗した場合、エラーとなる', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); notificationhubServiceMockValue.notify = new Error('Notify Error.'); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId_1 } = await makeTestUser(source, { account_id: accountId, @@ -1250,7 +1254,7 @@ describe('changeCheckoutPermission', () => { }); describe('checkout', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -1263,17 +1267,20 @@ describe('checkout', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('ユーザーのRoleがTypistで、タスクのチェックアウト権限が個人指定である時、タスクをチェックアウトできる', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -1315,15 +1322,12 @@ describe('checkout', () => { ['typist'], 'typist-user-external-id', ); - const { status, typist_user_id, started_at } = await getTask( - source, - taskId, - ); + const resultTask = await getTask(source, taskId); const permisions = await getCheckoutPermissions(source, taskId); - expect(status).toEqual('InProgress'); - expect(typist_user_id).toEqual(typistUserId); - expect(started_at).not.toEqual(initTask.started_at); + expect(resultTask?.status).toEqual('InProgress'); + expect(resultTask?.typist_user_id).toEqual(typistUserId); + expect(resultTask?.started_at).not.toEqual(initTask?.started_at); expect(permisions.length).toEqual(1); expect(permisions[0]).toEqual({ id: 3, @@ -1334,12 +1338,14 @@ describe('checkout', () => { }); it('ユーザーのRoleがTypistで、タスクのチェックアウト権限がグループ指定である時、タスクをチェックアウトできる', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -1381,15 +1387,12 @@ describe('checkout', () => { ['typist'], 'typist-user-external-id', ); - const { status, typist_user_id, started_at } = await getTask( - source, - taskId, - ); + const resultTask = await getTask(source, taskId); const permisions = await getCheckoutPermissions(source, taskId); - expect(status).toEqual('InProgress'); - expect(typist_user_id).toEqual(typistUserId); - expect(started_at).not.toEqual(initTask.started_at); + expect(resultTask?.status).toEqual('InProgress'); + expect(resultTask?.typist_user_id).toEqual(typistUserId); + expect(resultTask?.started_at).not.toEqual(initTask?.started_at); expect(permisions.length).toEqual(1); expect(permisions[0]).toEqual({ id: 3, @@ -1400,12 +1403,14 @@ describe('checkout', () => { }); it('ユーザーのRoleがTypistで、タスクのステータスがPendingである時、タスクをチェックアウトできる', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -1440,16 +1445,13 @@ describe('checkout', () => { ['typist'], 'typist-user-external-id', ); - const { status, typist_user_id, started_at } = await getTask( - source, - taskId, - ); + const resultTask = await getTask(source, taskId); const permisions = await getCheckoutPermissions(source, taskId); - expect(status).toEqual('InProgress'); - expect(typist_user_id).toEqual(typistUserId); + expect(resultTask?.status).toEqual('InProgress'); + expect(resultTask?.typist_user_id).toEqual(typistUserId); //タスクの元々のステータスがPending,Inprogressの場合、文字起こし開始時刻は更新されない - expect(started_at).toEqual(initTask.started_at); + expect(resultTask?.started_at).toEqual(initTask?.started_at); expect(permisions.length).toEqual(1); expect(permisions[0]).toEqual({ id: 2, @@ -1460,12 +1462,14 @@ describe('checkout', () => { }); it('ユーザーのRoleがTypistで、対象のタスクのStatus[Uploaded,Inprogress,Pending]以外の時、タスクをチェックアウトできない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); await makeTestUser(source, { account_id: accountId, @@ -1504,12 +1508,14 @@ describe('checkout', () => { }); it('ユーザーのRoleがTypistで、チェックアウト権限が存在しない時、タスクをチェックアウトできない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); await makeTestUser(source, { account_id: accountId, @@ -1548,12 +1554,14 @@ describe('checkout', () => { }); it('ユーザーのRoleがAuthorで、アップロードした音声ファイルに紐づいたタスクをチェックアウトできる(Uploaded)', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: authorUserId } = await makeTestUser(source, { account_id: accountId, @@ -1584,12 +1592,14 @@ describe('checkout', () => { }); it('ユーザーのRoleがAuthorで、アップロードした音声ファイルに紐づいたタスクをチェックアウトできる(Finished)', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: authorUserId } = await makeTestUser(source, { account_id: accountId, @@ -1620,12 +1630,14 @@ describe('checkout', () => { }); it('ユーザーのRoleがAuthorで、アップロードした音声ファイルに紐づいたタスクが存在しない場合、タスクをチェックアウトできない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); await makeTestUser(source, { account_id: accountId, @@ -1648,12 +1660,14 @@ describe('checkout', () => { }); it('ユーザーのRoleがAuthorで、音声ファイルに紐づいたタスクでユーザーと一致するAuthorIDでない場合、タスクをチェックアウトできない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: authorUserId } = await makeTestUser(source, { account_id: accountId, @@ -1686,12 +1700,14 @@ describe('checkout', () => { }); it('ユーザーのRoleに[Typist,author]が設定されていない時、タスクをチェックアウトできない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); await makeTestUser(source, { account_id: accountId, @@ -1715,7 +1731,7 @@ describe('checkout', () => { }); describe('checkin', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -1728,17 +1744,20 @@ describe('checkin', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('API実行者が文字起こし実行中のタスクである場合、タスクをチェックインできる', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -1773,19 +1792,21 @@ describe('checkin', () => { 1, 'typist-user-external-id', ); - const { status, finished_at } = await getTask(source, taskId); + const resultTask = await getTask(source, taskId); - expect(status).toEqual('Finished'); - expect(finished_at).not.toEqual(initTask.finished_at); + expect(resultTask?.status).toEqual('Finished'); + expect(resultTask?.finished_at).not.toEqual(initTask?.finished_at); }); it('タスクのステータスがInprogressでない時、タスクをチェックインできない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -1820,12 +1841,14 @@ describe('checkin', () => { }); it('API実行者が文字起こし実行中のタスクでない場合、タスクをチェックインできない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); await makeTestUser(source, { account_id: accountId, @@ -1866,12 +1889,14 @@ describe('checkin', () => { }); it('タスクがない時、タスクをチェックインできない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); await makeTestUser(source, { account_id: accountId, @@ -1897,7 +1922,7 @@ describe('checkin', () => { }); describe('suspend', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -1910,17 +1935,20 @@ describe('suspend', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('API実行者が文字起こし実行中のタスクである場合、タスクを中断できる', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -1952,18 +1980,20 @@ describe('suspend', () => { 1, 'typist-user-external-id', ); - const { status } = await getTask(source, taskId); + const resultTask = await getTask(source, taskId); - expect(status).toEqual('Pending'); + expect(resultTask?.status).toEqual('Pending'); }); it('タスクのステータスがInprogressでない時、タスクを中断できない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -1998,12 +2028,14 @@ describe('suspend', () => { }); it('API実行者が文字起こし実行中のタスクでない場合、タスクを中断できない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); await makeTestUser(source, { account_id: accountId, @@ -2044,12 +2076,14 @@ describe('suspend', () => { }); it('タスクがない時、タスクを中断できない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); await makeTestUser(source, { account_id: accountId, @@ -2075,7 +2109,7 @@ describe('suspend', () => { }); describe('cancel', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -2088,17 +2122,20 @@ describe('cancel', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('API実行者のRoleがTypistの場合、自身が文字起こし実行中のタスクをキャンセルできる', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -2132,21 +2169,23 @@ describe('cancel', () => { 'typist-user-external-id', ['typist', 'standard'], ); - const { status, typist_user_id } = await getTask(source, taskId); + const resultTask = await getTask(source, taskId); const permisions = await getCheckoutPermissions(source, taskId); - expect(status).toEqual('Uploaded'); - expect(typist_user_id).toEqual(null); + expect(resultTask?.status).toEqual('Uploaded'); + expect(resultTask?.typist_user_id).toEqual(null); expect(permisions.length).toEqual(0); }); it('API実行者のRoleがTypistの場合、自身が文字起こし中断しているタスクをキャンセルできる', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -2179,22 +2218,24 @@ describe('cancel', () => { 'typist-user-external-id', ['typist', 'standard'], ); - const { status, typist_user_id } = await getTask(source, taskId); + const resultTask = await getTask(source, taskId); const permisions = await getCheckoutPermissions(source, taskId); - expect(status).toEqual('Uploaded'); - expect(typist_user_id).toEqual(null); + expect(resultTask?.status).toEqual('Uploaded'); + expect(resultTask?.typist_user_id).toEqual(null); expect(permisions.length).toEqual(0); }); it('API実行者のRoleがAdminの場合、文字起こし実行中のタスクをキャンセルできる', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -2229,21 +2270,23 @@ describe('cancel', () => { 'typist-user-external-id', ['admin', 'author'], ); - const { status, typist_user_id } = await getTask(source, taskId); + const resultTask = await getTask(source, taskId); const permisions = await getCheckoutPermissions(source, taskId); - expect(status).toEqual('Uploaded'); - expect(typist_user_id).toEqual(null); + expect(resultTask?.status).toEqual('Uploaded'); + expect(resultTask?.typist_user_id).toEqual(null); expect(permisions.length).toEqual(0); }); it('API実行者のRoleがAdminの場合、文字起こし中断しているタスクをキャンセルできる', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -2278,21 +2321,23 @@ describe('cancel', () => { 'typist-user-external-id', ['admin', 'author'], ); - const { status, typist_user_id } = await getTask(source, taskId); + const resultTask = await getTask(source, taskId); const permisions = await getCheckoutPermissions(source, taskId); - expect(status).toEqual('Uploaded'); - expect(typist_user_id).toEqual(null); + expect(resultTask?.status).toEqual('Uploaded'); + expect(resultTask?.typist_user_id).toEqual(null); expect(permisions.length).toEqual(0); }); it('タスクのステータスが[Inprogress,Pending]でない時、タスクをキャンセルできない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: typistUserId } = await makeTestUser(source, { account_id: accountId, @@ -2330,12 +2375,14 @@ describe('cancel', () => { }); it('API実行者のRoleがTypistの場合、他人が文字起こし実行中のタスクをキャンセルできない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); await makeTestUser(source, { account_id: accountId, @@ -2379,12 +2426,14 @@ describe('cancel', () => { }); it('タスクがない時、タスクをキャンセルできない', async () => { + if (!source) fail(); const notificationhubServiceMockValue = makeDefaultNotificationhubServiceMockValue(); - const module = await makeTaskTestingModule( + const module = await makeTaskTestingModuleWithNotificaiton( source, notificationhubServiceMockValue, ); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); await makeTestUser(source, { account_id: accountId, diff --git a/dictation_server/src/features/tasks/tasks.service.ts b/dictation_server/src/features/tasks/tasks.service.ts index 23659b7..902dfc1 100644 --- a/dictation_server/src/features/tasks/tasks.service.ts +++ b/dictation_server/src/features/tasks/tasks.service.ts @@ -45,10 +45,10 @@ export class TasksService { private readonly notificationhubService: NotificationhubService, ) {} - // TODO [Task2244] 引数にAccessTokenがあるのは不適切なのでController側で分解したい async getTasks( context: Context, - accessToken: AccessToken, + userId: string, + roles: Roles[], offset: number, limit: number, status?: string[], @@ -59,10 +59,6 @@ export class TasksService { `[IN] [${context.trackingId}] ${this.getTasks.name} | params: { offset: ${offset}, limit: ${limit}, status: ${status}, paramName: ${paramName}, direction: ${direction} };`, ); - const { role, userId } = accessToken; - // TODO [Task2244] Roleに型で定義されている値が入っているかをチェックして異常値を弾く実装に修正する - const roles = role.split(' '); - // パラメータが省略された場合のデフォルト値: 保存するソート条件の値の初期値と揃える const defaultParamName: TaskListSortableAttribute = 'JOB_NUMBER'; const defaultDirection: SortDirection = 'ASC'; @@ -95,6 +91,10 @@ export class TasksService { return { tasks: tasks, total: result.count }; } if (roles.includes(USER_ROLES.AUTHOR)) { + // API実行者がAuthorで、AuthorIDが存在しないことは想定外のため、エラーとする + if (!author_id) { + throw new Error('AuthorID not found'); + } const result = await this.taskRepository.getTasksFromAuthorIdAndAccountId( author_id, @@ -179,6 +179,10 @@ export class TasksService { await this.usersRepository.findUserByExternalId(externalId); if (roles.includes(USER_ROLES.AUTHOR)) { + // API実行者がAuthorで、AuthorIDが存在しないことは想定外のため、エラーとする + if (!author_id) { + throw new Error('AuthorID not found'); + } await this.taskRepository.getTaskFromAudioFileId( audioFileId, account_id, @@ -407,30 +411,21 @@ export class TasksService { permissions: CheckoutPermission[], ): Promise { // 割り当て候補の外部IDを列挙 - const assigneesExternalIds = permissions.map((x) => { - if (x.user) { - return x.user.external_id; - } - }); + const assigneesExternalIds = permissions.flatMap((permission) => + permission.user ? [permission.user.external_id] : [], + ); // 割り当てられているタイピストの外部IDを列挙 - const typistExternalIds = tasks.flatMap((x) => { - if (x.typist_user) { - return x.typist_user.external_id; - } - }); + const typistExternalIds = tasks.flatMap((task) => + task.typist_user ? [task.typist_user.external_id] : [], + ); //重複をなくす const distinctedExternalIds = [ ...new Set(assigneesExternalIds.concat(typistExternalIds)), ]; - // undefinedがあった場合、取り除く - const filteredExternalIds: string[] = distinctedExternalIds.filter( - (x): x is string => x !== undefined, - ); - // B2Cからユーザー名を取得する - return await this.adB2cService.getUsers(context, filteredExternalIds); + return await this.adB2cService.getUsers(context, distinctedExternalIds); } /** * 文字起こし候補を変更する @@ -451,10 +446,14 @@ export class TasksService { ); const { author_id, account_id } = await this.usersRepository.findUserByExternalId(externalId); + // RoleがAuthorで、AuthorIDが存在しないことは想定外のため、エラーとする + if (role.includes(USER_ROLES.AUTHOR) && !author_id) { + throw new Error('AuthorID not found'); + } await this.taskRepository.changeCheckoutPermission( audioFileId, - author_id, + author_id ?? undefined, account_id, role, assignees, @@ -462,11 +461,16 @@ export class TasksService { // すべての割り当て候補ユーザーを取得する const assigneesGroupIds = assignees - .filter((x) => x.typistGroupId) - .map((x) => x.typistGroupId); + .filter((assignee) => assignee.typistGroupId) + .flatMap((assignee) => + assignee.typistGroupId ? [assignee.typistGroupId] : [], + ); + const assigneesUserIds = assignees - .filter((x) => x.typistUserId) - .map((x) => x.typistUserId); + .filter((assignee) => assignee.typistUserId) + .flatMap((assignee) => + assignee.typistUserId ? [assignee.typistUserId] : [], + ); const groupMembers = await this.userGroupsRepositoryService.getGroupMembersFromGroupIds( 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 c09742f..1e09b84 100644 --- a/dictation_server/src/features/tasks/test/tasks.service.mock.ts +++ b/dictation_server/src/features/tasks/test/tasks.service.mock.ts @@ -263,6 +263,11 @@ export const makeDefaultUserGroupsRepositoryMockValue = user_id: 1, created_by: 'test', updated_by: 'test', + created_at: new Date(), + deleted_at: null, + updated_at: null, + user: null, + userGroup: null, }, { id: 2, @@ -270,6 +275,11 @@ export const makeDefaultUserGroupsRepositoryMockValue = user_id: 2, created_by: 'test', updated_by: 'test', + created_at: new Date(), + deleted_at: null, + updated_at: null, + user: null, + userGroup: null, }, { id: 3, @@ -277,6 +287,11 @@ export const makeDefaultUserGroupsRepositoryMockValue = user_id: 1, created_by: 'test', updated_by: 'test', + created_at: new Date(), + deleted_at: null, + updated_at: null, + user: null, + userGroup: null, }, { id: 4, @@ -284,6 +299,11 @@ export const makeDefaultUserGroupsRepositoryMockValue = user_id: 1, created_by: 'test', updated_by: 'test', + created_at: new Date(), + deleted_at: null, + updated_at: null, + user: null, + userGroup: null, }, { id: 5, @@ -291,6 +311,11 @@ export const makeDefaultUserGroupsRepositoryMockValue = user_id: 3, created_by: 'test', updated_by: 'test', + created_at: new Date(), + deleted_at: null, + updated_at: null, + user: null, + userGroup: null, }, ], }; @@ -307,7 +332,7 @@ export const makeDefaultUsersRepositoryMockValue = user1.auto_renew = false; user1.license_alert = false; user1.notification = false; - user1.deleted_at = undefined; + user1.deleted_at = null; user1.created_by = 'test'; user1.created_at = new Date(); user1.author_id = 'abcdef'; @@ -331,66 +356,82 @@ const defaultTasksRepositoryMockValue: { status: 'Uploaded', priority: '00', created_at: new Date('2023-01-01T01:01:01.000Z'), + finished_at: null, + started_at: null, + typist_user_id: null, + template_file_id: null, + typist_user: null, + template_file: null, option_items: [ { id: 1, audio_file_id: 1, label: 'label01', value: 'value01', + task: null, }, { id: 2, audio_file_id: 1, label: 'label02', value: 'value02', + task: null, }, { id: 3, audio_file_id: 1, label: 'label03', value: 'value03', + task: null, }, { id: 4, audio_file_id: 1, label: 'label04', value: 'value04', + task: null, }, { id: 5, audio_file_id: 1, label: 'label05', value: 'value05', + task: null, }, { id: 6, audio_file_id: 1, label: 'label06', value: 'value06', + task: null, }, { id: 7, audio_file_id: 1, label: 'label07', value: 'value07', + task: null, }, { id: 8, audio_file_id: 1, label: 'label08', value: 'value08', + task: null, }, { id: 9, audio_file_id: 1, label: 'label09', value: 'value09', + task: null, }, { id: 10, audio_file_id: 1, label: 'label10', value: 'value10', + task: null, }, ], file: { @@ -410,6 +451,8 @@ const defaultTasksRepositoryMockValue: { audio_format: 'DS', comment: 'comment', is_encrypted: true, + deleted_at: null, + task: null, }, }, ], @@ -435,7 +478,16 @@ const defaultTasksRepositoryMockValue: { created_at: new Date(), updated_by: 'test', updated_at: new Date(), + account: null, + author_id: null, + deleted_at: null, + encryption_password: null, + license: null, + userGroupMembers: null, }, + task: null, + user_group_id: null, + user_group: null, }, ], count: 1, diff --git a/dictation_server/src/features/tasks/test/utility.ts b/dictation_server/src/features/tasks/test/utility.ts index 8c49279..8b8d1c4 100644 --- a/dictation_server/src/features/tasks/test/utility.ts +++ b/dictation_server/src/features/tasks/test/utility.ts @@ -39,10 +39,10 @@ import { makeNotificationhubServiceMock, } from './tasks.service.mock'; -export const makeTaskTestingModule = async ( +export const makeTaskTestingModuleWithNotificaiton = async ( datasource: DataSource, notificationhubServiceMockValue: NotificationhubServiceMockValue, -): Promise => { +): Promise => { try { const module: TestingModule = await Test.createTestingModule({ imports: [ @@ -205,7 +205,7 @@ export const createUserGroup = async ( export const getTask = async ( datasource: DataSource, task_id: number, -): Promise => { +): Promise => { const task = await datasource.getRepository(Task).findOne({ where: { id: task_id, diff --git a/dictation_server/src/features/tasks/types/convert.ts b/dictation_server/src/features/tasks/types/convert.ts index bdb96f6..5353542 100644 --- a/dictation_server/src/features/tasks/types/convert.ts +++ b/dictation_server/src/features/tasks/types/convert.ts @@ -45,7 +45,7 @@ const createTask = ( const assignees = createAssignees(permissions, b2cUserInfo); // RepositoryDTO => ControllerDTOに変換 - const typist: Typist = + const typist: Typist | undefined = typist_user != null ? convertUserToTypist(typist_user, b2cUserInfo) : undefined; @@ -113,7 +113,10 @@ const convertUserToAssignee = ( ): Assignee => { const typistName = b2cUserInfo.find( (x) => x.id === user.external_id, - ).displayName; + )?.displayName; + if (!typistName) { + throw new Error('typistName not found.'); + } return { typistUserId: user.id, typistName, @@ -135,7 +138,10 @@ const convertUserToTypist = ( ): Typist => { const typistName = b2cUserInfo.find( (x) => x.id === user.external_id, - ).displayName; + )?.displayName; + if (!typistName) { + throw new Error('typistName not found.'); + } return { id: user.id, name: typistName, diff --git a/dictation_server/src/features/templates/templates.controller.ts b/dictation_server/src/features/templates/templates.controller.ts index 6d85968..ef972cf 100644 --- a/dictation_server/src/features/templates/templates.controller.ts +++ b/dictation_server/src/features/templates/templates.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Get, HttpStatus, Req, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + HttpException, + HttpStatus, + Req, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, @@ -16,6 +23,7 @@ import { retrieveAuthorizationToken } from '../../common/http/helper'; import { Request } from 'express'; import { makeContext } from '../../common/log'; import { TemplatesService } from './templates.service'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; @ApiTags('templates') @Controller('templates') @@ -46,8 +54,21 @@ export class TemplatesController { @UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] })) @Get() async getTemplates(@Req() req: Request): Promise { - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req); + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); const templates = await this.templatesService.getTemplates(context, userId); diff --git a/dictation_server/src/features/templates/templates.service.spec.ts b/dictation_server/src/features/templates/templates.service.spec.ts index c29b168..d3b0160 100644 --- a/dictation_server/src/features/templates/templates.service.spec.ts +++ b/dictation_server/src/features/templates/templates.service.spec.ts @@ -9,7 +9,7 @@ import { HttpException, HttpStatus } from '@nestjs/common'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; describe('getTemplates', () => { - let source: DataSource = null; + let source: DataSource | undefined = undefined; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -22,12 +22,16 @@ describe('getTemplates', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); - source = null; + source = undefined; }); it('テンプレートファイル一覧を取得できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(TemplatesService); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -65,7 +69,10 @@ describe('getTemplates', () => { }); it('テンプレートファイル一覧を取得できる(0件)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(TemplatesService); // 第五階層のアカウント作成 const { admin } = await makeTestAccount(source, { tier: 5 }); @@ -80,7 +87,10 @@ describe('getTemplates', () => { }); it('テンプレートファイル一覧の取得に失敗した場合、エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(TemplatesService); // 第五階層のアカウント作成 const { admin } = await makeTestAccount(source, { tier: 5 }); diff --git a/dictation_server/src/features/templates/test/utility.ts b/dictation_server/src/features/templates/test/utility.ts index ac78857..224b536 100644 --- a/dictation_server/src/features/templates/test/utility.ts +++ b/dictation_server/src/features/templates/test/utility.ts @@ -23,6 +23,9 @@ export const createTemplateFile = async ( id: template.id, }, }); + if (!templateFile) { + fail(); + } return templateFile; }; diff --git a/dictation_server/src/features/terms/terms.controller.spec.ts b/dictation_server/src/features/terms/terms.controller.spec.ts index 7473f05..b57a498 100644 --- a/dictation_server/src/features/terms/terms.controller.spec.ts +++ b/dictation_server/src/features/terms/terms.controller.spec.ts @@ -6,11 +6,14 @@ describe('TermsController', () => { let controller: TermsController; beforeEach(async () => { + const mockTermsService = {}; const module: TestingModule = await Test.createTestingModule({ controllers: [TermsController], providers: [TermsService], - }).compile(); - + }) + .overrideProvider(TermsService) + .useValue(mockTermsService) + .compile(); controller = module.get(TermsController); }); diff --git a/dictation_server/src/features/terms/terms.controller.ts b/dictation_server/src/features/terms/terms.controller.ts index f28d5f8..1d855ba 100644 --- a/dictation_server/src/features/terms/terms.controller.ts +++ b/dictation_server/src/features/terms/terms.controller.ts @@ -1,4 +1,4 @@ -import { Controller, HttpStatus, Post } from '@nestjs/common'; +import { Controller, HttpStatus, Get } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { TermsService } from '../terms/terms.service'; import { ErrorResponse } from '../../common/error/types/types'; @@ -13,7 +13,7 @@ export class TermsController { private readonly termsService: TermsService, //private readonly cryptoService: CryptoService, ) {} - @Post() + @Get() @ApiResponse({ status: HttpStatus.OK, type: GetTermsInfoResponse, @@ -28,12 +28,7 @@ export class TermsController { async getTermsInfo(): Promise { const context = makeContext(uuidv4()); - // TODO 仮実装。API実装タスクで本実装する。 - // const termInfo = await this.termsService.getTermsInfo(context); - const termsInfo = [ - { documentType: 'EULA', version: '1.0' }, - { documentType: 'DPA', version: '1.1' }, - ] as TermInfo[]; + const termsInfo = await this.termsService.getTermsInfo(context); return { termsInfo }; } diff --git a/dictation_server/src/features/terms/terms.module.ts b/dictation_server/src/features/terms/terms.module.ts index e314518..704e003 100644 --- a/dictation_server/src/features/terms/terms.module.ts +++ b/dictation_server/src/features/terms/terms.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { TermsController } from './terms.controller'; import { TermsService } from './terms.service'; +import { TermsRepositoryModule } from '../../repositories/terms/terms.repository.module'; @Module({ + imports: [TermsRepositoryModule], controllers: [TermsController], - providers: [TermsService] + providers: [TermsService], }) export class TermsModule {} diff --git a/dictation_server/src/features/terms/terms.service.spec.ts b/dictation_server/src/features/terms/terms.service.spec.ts index 6e8839b..772e9f5 100644 --- a/dictation_server/src/features/terms/terms.service.spec.ts +++ b/dictation_server/src/features/terms/terms.service.spec.ts @@ -1,18 +1,92 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { TermsService } from './terms.service'; +import { DataSource } from 'typeorm'; +import { makeTestingModule } from '../../common/test/modules'; +import { createTermInfo } from '../auth/test/utility'; +import { makeContext } from '../../common/log'; +import { v4 as uuidv4 } from 'uuid'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; -describe('TermsService', () => { - let service: TermsService; - +describe('利用規約取得', () => { + let source: DataSource | null = null; beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [TermsService], - }).compile(); - - service = module.get(TermsService); + source = new DataSource({ + type: 'sqlite', + database: ':memory:', + logging: false, + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: true, // trueにすると自動的にmigrationが行われるため注意 + }); + return source.initialize(); }); - it('should be defined', () => { - expect(service).toBeDefined(); + afterEach(async () => { + if (!source) return; + await source.destroy(); + source = null; + }); + + it('最新の利用規約情報が取得できる', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(TermsService); + + await createTermInfo(source, 'EULA', 'v1.0'); + await createTermInfo(source, 'EULA', 'v1.1'); + await createTermInfo(source, 'DPA', 'v1.0'); + await createTermInfo(source, 'DPA', 'v1.2'); + + const context = makeContext(uuidv4()); + const result = await service.getTermsInfo(context); + + expect(result[0].documentType).toBe('EULA'); + expect(result[0].version).toBe('v1.1'); + expect(result[1].documentType).toBe('DPA'); + expect(result[1].version).toBe('v1.2'); + }); + + it('利用規約情報(EULA、DPA両方)が存在しない場合エラーとなる', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(TermsService); + const context = makeContext(uuidv4()); + await expect(service.getTermsInfo(context)).rejects.toEqual( + new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + }); + + it('利用規約情報(EULAのみ)が存在しない場合エラーとなる', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(TermsService); + await createTermInfo(source, 'DPA', 'v1.0'); + const context = makeContext(uuidv4()); + await expect(service.getTermsInfo(context)).rejects.toEqual( + new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + }); + + it('利用規約情報(DPAのみ)が存在しない場合エラーとなる', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(TermsService); + await createTermInfo(source, 'EULA', 'v1.0'); + const context = makeContext(uuidv4()); + await expect(service.getTermsInfo(context)).rejects.toEqual( + new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); }); }); diff --git a/dictation_server/src/features/terms/terms.service.ts b/dictation_server/src/features/terms/terms.service.ts index 51ba395..526bde6 100644 --- a/dictation_server/src/features/terms/terms.service.ts +++ b/dictation_server/src/features/terms/terms.service.ts @@ -1,4 +1,44 @@ -import { Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { Context } from '../../common/log'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; +import { TermInfo } from './types/types'; +import { TermsRepositoryService } from '../../repositories/terms/terms.repository.service'; +import { TERM_TYPE } from '../../constants'; @Injectable() -export class TermsService {} +export class TermsService { + constructor(private readonly termsRepository: TermsRepositoryService) {} + private readonly logger = new Logger(TermsService.name); + + /** + * 利用規約情報を取得する + * return termsInfo + */ + async getTermsInfo(context: Context): Promise { + this.logger.log(`[IN] [${context.trackingId}] ${this.getTermsInfo.name}`); + try { + const { eulaVersion, dpaVersion } = + await this.termsRepository.getLatestTermsInfo(); + return [ + { + documentType: TERM_TYPE.EULA, + version: eulaVersion, + }, + { + documentType: TERM_TYPE.DPA, + version: dpaVersion, + }, + ]; + } catch (e) { + this.logger.error(`error=${e}`); + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } finally { + this.logger.log( + `[OUT] [${context.trackingId}] ${this.getTermsInfo.name}`, + ); + } + } +} diff --git a/dictation_server/src/features/terms/types/types.ts b/dictation_server/src/features/terms/types/types.ts index e832cc6..6a45eae 100644 --- a/dictation_server/src/features/terms/types/types.ts +++ b/dictation_server/src/features/terms/types/types.ts @@ -1,12 +1,17 @@ import { ApiProperty } from '@nestjs/swagger'; -export class GetTermsInfoResponse { - termsInfo: TermInfo[]; -} - export class TermInfo { @ApiProperty({ description: '利用規約種別' }) documentType: string; @ApiProperty({ description: 'バージョン' }) version: string; } +export class GetTermsInfoResponse { + @ApiProperty({ type: [TermInfo] }) + termsInfo: TermInfo[]; +} + +export type TermsVersion = { + eulaVersion: string; + dpaVersion: string; +}; 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 036f759..b862547 100644 --- a/dictation_server/src/features/users/test/users.service.mock.ts +++ b/dictation_server/src/features/users/test/users.service.mock.ts @@ -64,7 +64,7 @@ export type ConfigMockValue = { export const makeUsersServiceMock = async ( usersRepositoryMockValue: UsersRepositoryMockValue, - licensesRepositoryMockValue: LicensesRepositoryMockValue, + licensesRepositoryMockValue: LicensesRepositoryMockValue | null, adB2cMockValue: AdB2cMockValue, sendGridMockValue: SendGridMockValue, configMockValue: ConfigMockValue, @@ -368,7 +368,7 @@ export const makeDefaultUsersRepositoryMockValue = user1.created_by = 'test'; user1.created_at = new Date(); user1.updated_by = null; - user1.updated_at = null; + user1.updated_at = new Date(); const user2 = new User(); user2.id = 3; @@ -388,7 +388,7 @@ export const makeDefaultUsersRepositoryMockValue = user2.created_by = 'test'; user2.created_at = new Date(); user2.updated_by = null; - user2.updated_at = null; + user2.updated_at = new Date(); return { updateUserVerified: undefined, @@ -406,7 +406,7 @@ const AdB2cMockUsers: AdB2cUser[] = [ displayName: 'test1', identities: [ { - signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS, + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, issuer: 'issuer', issuerAssignedId: 'test1@mail.com', }, @@ -417,7 +417,7 @@ const AdB2cMockUsers: AdB2cUser[] = [ displayName: 'test2', identities: [ { - signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS, + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, issuer: 'issuer', issuerAssignedId: 'test2@mail.com', }, @@ -428,7 +428,7 @@ const AdB2cMockUsers: AdB2cUser[] = [ displayName: 'test3', identities: [ { - signInType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS, + signInType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, issuer: 'issuer', issuerAssignedId: 'test3@mail.com', }, diff --git a/dictation_server/src/features/users/test/utility.ts b/dictation_server/src/features/users/test/utility.ts index 7f66b68..ab4dfe2 100644 --- a/dictation_server/src/features/users/test/utility.ts +++ b/dictation_server/src/features/users/test/utility.ts @@ -107,7 +107,7 @@ export const createLicense = async ( export const makeTestingModuleWithAdb2c = async ( datasource: DataSource, adB2cMockValue: AdB2cMockValue, -): Promise => { +): Promise => { try { const module: TestingModule = await Test.createTestingModule({ imports: [ diff --git a/dictation_server/src/features/users/users.controller.spec.ts b/dictation_server/src/features/users/users.controller.spec.ts index 6193160..b64a9fc 100644 --- a/dictation_server/src/features/users/users.controller.spec.ts +++ b/dictation_server/src/features/users/users.controller.spec.ts @@ -2,10 +2,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { ConfigModule } from '@nestjs/config'; +import { AuthService } from '../auth/auth.service'; describe('UsersController', () => { let controller: UsersController; const mockUserService = {}; + const mockAuthService = {}; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -16,10 +18,12 @@ describe('UsersController', () => { }), ], controllers: [UsersController], - providers: [UsersService], + providers: [UsersService, AuthService], }) .overrideProvider(UsersService) .useValue(mockUserService) + .overrideProvider(AuthService) + .useValue(mockAuthService) .compile(); controller = module.get(UsersController); diff --git a/dictation_server/src/features/users/users.controller.ts b/dictation_server/src/features/users/users.controller.ts index 81306ec..ee6175a 100644 --- a/dictation_server/src/features/users/users.controller.ts +++ b/dictation_server/src/features/users/users.controller.ts @@ -41,6 +41,7 @@ import { UpdateAcceptedVersionResponse, } from './types/types'; import { UsersService } from './users.service'; +import { AuthService } from '../auth/auth.service'; import jwt from 'jsonwebtoken'; import { AuthGuard } from '../../common/guards/auth/authguards'; import { @@ -56,7 +57,10 @@ import { v4 as uuidv4 } from 'uuid'; @ApiTags('users') @Controller('users') export class UsersController { - constructor(private readonly usersService: UsersService) {} + constructor( + private readonly usersService: UsersService, + private readonly authService: AuthService, + ) {} @ApiResponse({ status: HttpStatus.OK, @@ -126,10 +130,23 @@ export class UsersController { @UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] })) @Get() async getUsers(@Req() req: Request): Promise { - const accessToken = retrieveAuthorizationToken(req); - const decodedToken = jwt.decode(accessToken, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; - const users = await this.usersService.getUsers(decodedToken.userId); + const users = await this.usersService.getUsers(userId); return { users }; } @@ -175,15 +192,28 @@ export class UsersController { prompt, } = body; - const accessToken = retrieveAuthorizationToken(req); - const payload = jwt.decode(accessToken, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; - const context = makeContext(payload.userId); + const context = makeContext(userId); //ユーザ作成処理 await this.usersService.createUser( context, - payload, + userId, name, role as UserRoles, email, @@ -221,8 +251,21 @@ export class UsersController { @UseGuards(AuthGuard) @Get('relations') async getRelations(@Req() req: Request): Promise { - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); @@ -261,8 +304,22 @@ export class UsersController { @Req() req: Request, ): Promise { const { direction, paramName } = body; - const accessToken = retrieveAuthorizationToken(req); - const decodedToken = jwt.decode(accessToken, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; //型チェック if ( @@ -274,11 +331,7 @@ export class UsersController { HttpStatus.BAD_REQUEST, ); } - await this.usersService.updateSortCriteria( - paramName, - direction, - decodedToken, - ); + await this.usersService.updateSortCriteria(paramName, direction, userId); return {}; } @@ -309,11 +362,25 @@ export class UsersController { @Req() req: Request, ): Promise { const {} = query; - const accessToken = retrieveAuthorizationToken(req); - const decodedToken = jwt.decode(accessToken, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const { direction, paramName } = await this.usersService.getSortCriteria( - decodedToken, + userId, ); return { direction, paramName }; } @@ -363,7 +430,20 @@ export class UsersController { } = body; const accessToken = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(accessToken, { json: true }) as AccessToken; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); @@ -417,8 +497,21 @@ export class UsersController { @Body() body: AllocateLicenseRequest, @Req() req: Request, ): Promise { - const accessToken = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(accessToken, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); await this.usersService.allocateLicense( @@ -463,8 +556,21 @@ export class UsersController { @Body() body: DeallocateLicenseRequest, @Req() req: Request, ): Promise { - const accessToken = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(accessToken, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req) as string; + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); @@ -495,11 +601,24 @@ export class UsersController { async updateAcceptedVersion( @Body() body: UpdateAcceptedVersionRequest, ): Promise { - const context = makeContext(uuidv4()); + const { idToken, acceptedEULAVersion, acceptedDPAVersion } = body; - // TODO 仮実装。API実装タスクで本実装する。 - // const idToken = await this.authService.getVerifiedIdToken(body.idToken); - // await this.usersService.updateAcceptedVersion(context, idToken); + const verifiedIdToken = await this.authService.getVerifiedIdToken(idToken); + const context = makeContext(verifiedIdToken.sub); + + const isVerified = await this.authService.isVerifiedUser(verifiedIdToken); + if (!isVerified) { + throw new HttpException( + makeErrorResponse('E010201'), + HttpStatus.BAD_REQUEST, + ); + } + await this.usersService.updateAcceptedVersion( + context, + verifiedIdToken.sub, + acceptedEULAVersion, + acceptedDPAVersion, + ); return {}; } } diff --git a/dictation_server/src/features/users/users.module.ts b/dictation_server/src/features/users/users.module.ts index e4ae006..f349a95 100644 --- a/dictation_server/src/features/users/users.module.ts +++ b/dictation_server/src/features/users/users.module.ts @@ -7,6 +7,7 @@ import { UsersRepositoryModule } from '../../repositories/users/users.repository import { LicensesRepositoryModule } from '../../repositories/licenses/licenses.repository.module'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; +import { AuthService } from '../auth/auth.service'; @Module({ imports: [ @@ -18,6 +19,6 @@ import { UsersService } from './users.service'; ConfigModule, ], controllers: [UsersController], - providers: [UsersService], + providers: [UsersService, AuthService], }) export class UsersModule {} diff --git a/dictation_server/src/features/users/users.service.spec.ts b/dictation_server/src/features/users/users.service.spec.ts index 7bc2eae..6c5c2b4 100644 --- a/dictation_server/src/features/users/users.service.spec.ts +++ b/dictation_server/src/features/users/users.service.spec.ts @@ -43,9 +43,10 @@ import { makeTestSimpleAccount, makeTestUser, } from '../../common/test/utility'; +import { v4 as uuidv4 } from 'uuid'; describe('UsersService.confirmUser', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -56,12 +57,15 @@ describe('UsersService.confirmUser', () => { return source.initialize(); }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('ユーザの仮登録時に払い出されるトークンにより、未認証のユーザが認証済みになり、トライアルライセンスが100件作成される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = (await makeTestAccount(source)).account; const { id: userId } = await makeTestUser(source, { account_id: accountId, @@ -109,9 +113,10 @@ describe('UsersService.confirmUser', () => { allocated_user_id: resultLicenses[0].allocated_user_id, order_id: resultLicenses[0].order_id, delete_order_id: resultLicenses[0].delete_order_id, + user: resultLicenses[0].user ?? null, }; - expect(resultUser.email_verified).toBe(true); + expect(resultUser?.email_verified).toBe(true); expect(resultLicenses.length).toBe(100); expect(resultLicensesCheckParam).toEqual({ id: 0, @@ -122,11 +127,14 @@ describe('UsersService.confirmUser', () => { allocated_user_id: null, order_id: null, delete_order_id: null, + user: null, }); - }); + }, 600000); it('トークンの形式が不正な場合、形式不正エラーとなる。', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const token = 'invalid.id.token'; const service = module.get(UsersService); await expect(service.confirmUser(token)).rejects.toEqual( @@ -135,7 +143,9 @@ describe('UsersService.confirmUser', () => { }); it('ユーザが既に認証済みだった場合、認証済みユーザエラーとなる。', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = (await makeTestAccount(source)).account; await makeTestUser(source, { account_id: accountId, @@ -167,7 +177,9 @@ describe('UsersService.confirmUser', () => { ); }); it('ユーザーが存在しない場合は、想定外のエラーとなる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(UsersService); const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; @@ -200,6 +212,12 @@ describe('UsersService.confirmUserAndInitPassword', () => { notification: true, encryption: false, prompt: false, + account: null, + author_id: null, + deleted_at: null, + encryption_password: null, + license: null, + userGroupMembers: null, }; const licensesRepositoryMockValue = null; const adb2cParam = makeDefaultAdB2cMockValue(); @@ -245,6 +263,12 @@ describe('UsersService.confirmUserAndInitPassword', () => { notification: true, encryption: false, prompt: false, + account: null, + author_id: null, + deleted_at: null, + encryption_password: null, + license: null, + userGroupMembers: null, }; const licensesRepositoryMockValue = null; const adb2cParam = makeDefaultAdB2cMockValue(); @@ -286,6 +310,12 @@ describe('UsersService.confirmUserAndInitPassword', () => { notification: true, encryption: false, prompt: false, + account: null, + author_id: null, + deleted_at: null, + encryption_password: null, + license: null, + userGroupMembers: null, }; const licensesRepositoryMockValue = null; const adb2cParam = makeDefaultAdB2cMockValue(); @@ -331,6 +361,12 @@ describe('UsersService.confirmUserAndInitPassword', () => { notification: true, encryption: false, prompt: false, + account: null, + author_id: null, + deleted_at: null, + encryption_password: null, + license: null, + userGroupMembers: null, }; const licensesRepositoryMockValue = null; const adb2cParam = makeDefaultAdB2cMockValue(); @@ -361,7 +397,7 @@ describe('UsersService.confirmUserAndInitPassword', () => { }); describe('UsersService.createUser', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -374,12 +410,15 @@ describe('UsersService.createUser', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('管理者権限のあるアクセストークンを使用して、新規ユーザが追加される(role:None)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(UsersService); const adminExternalId = 'ADMIN0001'; @@ -432,7 +471,7 @@ describe('UsersService.createUser', () => { expect( await service.createUser( makeContext('trackingId'), - token, + adminExternalId, name, role, email, @@ -445,16 +484,16 @@ describe('UsersService.createUser', () => { // 追加されたユーザーが正しくDBに登録されていることを確認 const user = await getUserFromExternalId(source, externalId); expect(user).not.toBeNull(); - expect(user.account_id).toEqual(accountId); - expect(user.role).toEqual(role); - expect(user.author_id).toEqual(null); - expect(user.email_verified).toEqual(false); - expect(user.auto_renew).toEqual(autoRenew); - expect(user.license_alert).toEqual(licenseAlert); - expect(user.notification).toEqual(notification); - expect(user.encryption).toEqual(false); - expect(user.encryption_password).toEqual(null); - expect(user.prompt).toEqual(false); + expect(user?.account_id).toEqual(accountId); + expect(user?.role).toEqual(role); + expect(user?.author_id).toEqual(null); + expect(user?.email_verified).toEqual(false); + expect(user?.auto_renew).toEqual(autoRenew); + expect(user?.license_alert).toEqual(licenseAlert); + expect(user?.notification).toEqual(notification); + expect(user?.encryption).toEqual(false); + expect(user?.encryption_password).toEqual(null); + expect(user?.prompt).toEqual(false); // 他にユーザーが登録されていないことを確認 const users = await getUsers(source); @@ -462,7 +501,9 @@ describe('UsersService.createUser', () => { }); it('管理者権限のあるアクセストークンを使用して、新規ユーザが追加される(role:Author; 暗号化あり)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(UsersService); const adminExternalId = 'ADMIN0001'; const { account, admin } = await makeTestAccount( @@ -518,7 +559,7 @@ describe('UsersService.createUser', () => { expect( await service.createUser( makeContext('trackingId'), - token, + adminExternalId, name, role, email, @@ -535,16 +576,16 @@ describe('UsersService.createUser', () => { // 追加されたユーザーが正しくDBに登録されていることを確認 const user = await getUserFromExternalId(source, externalId); expect(user).not.toBeNull(); - expect(user.account_id).toEqual(accountId); - expect(user.role).toEqual(role); - expect(user.author_id).toEqual(authorId); - expect(user.email_verified).toEqual(false); - expect(user.auto_renew).toEqual(autoRenew); - expect(user.license_alert).toEqual(licenseAlert); - expect(user.notification).toEqual(notification); - expect(user.encryption).toEqual(encryption); - expect(user.encryption_password).toEqual(encryptionPassword); - expect(user.prompt).toEqual(prompt); + expect(user?.account_id).toEqual(accountId); + expect(user?.role).toEqual(role); + expect(user?.author_id).toEqual(authorId); + expect(user?.email_verified).toEqual(false); + expect(user?.auto_renew).toEqual(autoRenew); + expect(user?.license_alert).toEqual(licenseAlert); + expect(user?.notification).toEqual(notification); + expect(user?.encryption).toEqual(encryption); + expect(user?.encryption_password).toEqual(encryptionPassword); + expect(user?.prompt).toEqual(prompt); // 他にユーザーが登録されていないことを確認 const users = await getUsers(source); @@ -552,7 +593,9 @@ describe('UsersService.createUser', () => { }); it('管理者権限のあるアクセストークンを使用して、新規ユーザが追加される(role:Author; 暗号化無し)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(UsersService); const adminExternalId = 'ADMIN0001'; const { account, admin } = await makeTestAccount( @@ -607,7 +650,7 @@ describe('UsersService.createUser', () => { expect( await service.createUser( makeContext('trackingId'), - token, + adminExternalId, name, role, email, @@ -624,16 +667,16 @@ describe('UsersService.createUser', () => { // 追加されたユーザーが正しくDBに登録されていることを確認 const user = await getUserFromExternalId(source, externalId); expect(user).not.toBeNull(); - expect(user.account_id).toEqual(accountId); - expect(user.role).toEqual(role); - expect(user.author_id).toEqual(authorId); - expect(user.email_verified).toEqual(false); - expect(user.auto_renew).toEqual(autoRenew); - expect(user.license_alert).toEqual(licenseAlert); - expect(user.notification).toEqual(notification); - expect(user.encryption).toEqual(encryption); - expect(user.encryption_password).toBeNull(); - expect(user.prompt).toEqual(prompt); + expect(user?.account_id).toEqual(accountId); + expect(user?.role).toEqual(role); + expect(user?.author_id).toEqual(authorId); + expect(user?.email_verified).toEqual(false); + expect(user?.auto_renew).toEqual(autoRenew); + expect(user?.license_alert).toEqual(licenseAlert); + expect(user?.notification).toEqual(notification); + expect(user?.encryption).toEqual(encryption); + expect(user?.encryption_password).toBeNull(); + expect(user?.prompt).toEqual(prompt); // 他にユーザーが登録されていないことを確認 const users = await getUsers(source); @@ -641,7 +684,9 @@ describe('UsersService.createUser', () => { }); it('管理者権限のあるアクセストークンを使用して、新規ユーザが追加される(role:Transcriptioninst)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(UsersService); const adminExternalId = 'ADMIN0001'; const { account, admin } = await makeTestAccount( @@ -693,7 +738,7 @@ describe('UsersService.createUser', () => { expect( await service.createUser( makeContext('trackingId'), - token, + adminExternalId, name, role, email, @@ -706,16 +751,16 @@ describe('UsersService.createUser', () => { // 追加されたユーザーが正しくDBに登録されていることを確認 const user = await getUserFromExternalId(source, externalId); expect(user).not.toBeNull(); - expect(user.account_id).toEqual(accountId); - expect(user.role).toEqual(role); - expect(user.author_id).toBeNull(); - expect(user.email_verified).toEqual(false); - expect(user.auto_renew).toEqual(autoRenew); - expect(user.license_alert).toEqual(licenseAlert); - expect(user.notification).toEqual(notification); - expect(user.encryption).toEqual(false); - expect(user.encryption_password).toBeNull(); - expect(user.prompt).toEqual(false); + expect(user?.account_id).toEqual(accountId); + expect(user?.role).toEqual(role); + expect(user?.author_id).toBeNull(); + expect(user?.email_verified).toEqual(false); + expect(user?.auto_renew).toEqual(autoRenew); + expect(user?.license_alert).toEqual(licenseAlert); + expect(user?.notification).toEqual(notification); + expect(user?.encryption).toEqual(false); + expect(user?.encryption_password).toBeNull(); + expect(user?.prompt).toEqual(false); // 他にユーザーが登録されていないことを確認 const users = await getUsers(source); @@ -723,7 +768,9 @@ describe('UsersService.createUser', () => { }); it('DBネットワークエラーとなる場合、リカバリ処理を実施し、ADB2Cに作成したユーザーを削除する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(UsersService); const b2cService = module.get(AdB2cService); const adminExternalId = 'ADMIN0001'; @@ -784,7 +831,7 @@ describe('UsersService.createUser', () => { try { await service.createUser( makeContext('trackingId'), - token, + adminExternalId, name, role, email, @@ -808,7 +855,9 @@ describe('UsersService.createUser', () => { }); it('DBネットワークエラーとなる場合、リカバリ処理を実施されるが、そのリカバリ処理に失敗した場合、ADB2Cのユーザーは削除されない', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(UsersService); const b2cService = module.get(AdB2cService); const adminExternalId = 'ADMIN0001'; @@ -869,7 +918,7 @@ describe('UsersService.createUser', () => { try { await service.createUser( makeContext('trackingId'), - token, + adminExternalId, name, role, email, @@ -898,7 +947,9 @@ describe('UsersService.createUser', () => { }); it('Azure ADB2Cでネットワークエラーとなる場合、エラーとなる。', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(UsersService); const adminExternalId = 'ADMIN0001'; const { account, admin } = await makeTestAccount( @@ -948,7 +999,7 @@ describe('UsersService.createUser', () => { try { await service.createUser( makeContext('trackingId'), - token, + adminExternalId, name, role, email, @@ -971,7 +1022,9 @@ describe('UsersService.createUser', () => { }); it('Azure AD B2C内でメールアドレスが重複している場合、エラーとなる。', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(UsersService); const adminExternalId = 'ADMIN0001'; const { account, admin } = await makeTestAccount( @@ -1025,7 +1078,7 @@ describe('UsersService.createUser', () => { try { await service.createUser( makeContext('trackingId'), - token, + adminExternalId, name, role, email, @@ -1048,7 +1101,9 @@ describe('UsersService.createUser', () => { }); it('AuthorIDが重複している場合、エラーとなる。(AuthorID重複チェックでエラー)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(UsersService); const adminExternalId = 'ADMIN0001'; const { account, admin } = await makeTestAccount( @@ -1104,7 +1159,7 @@ describe('UsersService.createUser', () => { expect( await service.createUser( makeContext('trackingId'), - token, + adminExternalId, name, role, email_1, @@ -1145,7 +1200,7 @@ describe('UsersService.createUser', () => { try { await service.createUser( makeContext('trackingId'), - token, + adminExternalId, name, role, email_2, @@ -1174,7 +1229,9 @@ describe('UsersService.createUser', () => { }); it('AuthorIDが重複している場合、エラー(insert失敗)となり、リカバリ処理が実行され、ADB2Cに追加したユーザーが削除される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(UsersService); const b2cService = module.get(AdB2cService); const adminExternalId = 'ADMIN0001'; @@ -1239,7 +1296,7 @@ describe('UsersService.createUser', () => { try { await service.createUser( makeContext('trackingId'), - token, + adminExternalId, name, role, email, @@ -1271,7 +1328,9 @@ describe('UsersService.createUser', () => { }); it('メール送信に失敗した場合、リカバリ処理が実行され、ADB2C,DBのユーザーが削除される', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(UsersService); const b2cService = module.get(AdB2cService); @@ -1326,7 +1385,7 @@ describe('UsersService.createUser', () => { try { await service.createUser( makeContext('trackingId'), - token, + adminExternalId, name, role, email, @@ -1356,7 +1415,9 @@ describe('UsersService.createUser', () => { }); it('メール送信に失敗した場合、リカバリ処理が実行されるが、そのリカバリ処理に失敗した場合、ADB2C,DBのユーザーが削除されない', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const service = module.get(UsersService); const b2cService = module.get(AdB2cService); @@ -1416,7 +1477,7 @@ describe('UsersService.createUser', () => { try { await service.createUser( makeContext('trackingId'), - token, + adminExternalId, name, role, email, @@ -1445,7 +1506,7 @@ describe('UsersService.createUser', () => { }); describe('UsersService.getUsers', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -1458,13 +1519,16 @@ describe('UsersService.getUsers', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('ユーザーの一覧を取得できる(ライセンス未割当)', async () => { const adb2cParam = makeDefaultAdB2cMockValue(); + if (!source) fail(); const module = await makeTestingModuleWithAdb2c(source, adb2cParam); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId_author, id: authorUserId } = @@ -1564,7 +1628,9 @@ describe('UsersService.getUsers', () => { it('ユーザーの一覧を取得できること(ライセンス割当済み)', async () => { const adb2cParam = makeDefaultAdB2cMockValue(); + if (!source) fail(); const module = await makeTestingModuleWithAdb2c(source, adb2cParam); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { id: user1, external_id: external_id1 } = await makeTestUser( @@ -1678,7 +1744,9 @@ describe('UsersService.getUsers', () => { it('DBからのユーザーの取得に失敗した場合、エラーとなる', async () => { const adb2cParam = makeDefaultAdB2cMockValue(); + if (!source) fail(); const module = await makeTestingModuleWithAdb2c(source, adb2cParam); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); await makeTestUser(source, { @@ -1701,7 +1769,9 @@ describe('UsersService.getUsers', () => { it('ADB2Cからのユーザーの取得に失敗した場合、エラーとなる', async () => { const adb2cParam = makeDefaultAdB2cMockValue(); adb2cParam.getUsers = new Error('ADB2C error'); + if (!source) fail(); const module = await makeTestingModuleWithAdb2c(source, adb2cParam); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: externalId_author } = await makeTestUser(source, { @@ -1741,11 +1811,7 @@ describe('UsersService.updateSortCriteria', () => { ); expect( - await service.updateSortCriteria('AUTHOR_ID', 'ASC', { - role: 'none admin', - userId: 'xxxxxxxxxxxx', - tier: 5, - }), + await service.updateSortCriteria('AUTHOR_ID', 'ASC', 'external_id'), ).toEqual(undefined); }); @@ -1770,11 +1836,7 @@ describe('UsersService.updateSortCriteria', () => { ); await expect( - service.updateSortCriteria('AUTHOR_ID', 'ASC', { - role: 'none admin', - userId: 'xxxxxxxxxxxx', - tier: 5, - }), + service.updateSortCriteria('AUTHOR_ID', 'ASC', 'external_id'), ).rejects.toEqual( new HttpException( makeErrorResponse('E009999'), @@ -1805,11 +1867,7 @@ describe('UsersService.updateSortCriteria', () => { ); await expect( - service.updateSortCriteria('AUTHOR_ID', 'ASC', { - role: 'none admin', - userId: 'xxxxxxxxxxxx', - tier: 5, - }), + service.updateSortCriteria('AUTHOR_ID', 'ASC', 'external_id'), ).rejects.toEqual( new HttpException( makeErrorResponse('E009999'), @@ -1837,13 +1895,10 @@ describe('UsersService.getSortCriteria', () => { sortCriteriaRepositoryMockValue, ); - expect( - await service.getSortCriteria({ - role: 'none admin', - userId: 'xxxxxxxxxxxx', - tier: 5, - }), - ).toEqual({ direction: 'ASC', paramName: 'JOB_NUMBER' }); + expect(await service.getSortCriteria('external_id')).toEqual({ + direction: 'ASC', + paramName: 'JOB_NUMBER', + }); }); it('ソート条件が存在せず、ソート条件を取得できない', async () => { @@ -1868,13 +1923,7 @@ describe('UsersService.getSortCriteria', () => { sortCriteriaRepositoryMockValue, ); - await expect( - service.getSortCriteria({ - role: 'none admin', - userId: 'xxxxxxxxxxxx', - tier: 5, - }), - ).rejects.toEqual( + await expect(service.getSortCriteria('external_id')).rejects.toEqual( new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, @@ -1906,13 +1955,7 @@ describe('UsersService.getSortCriteria', () => { sortCriteriaRepositoryMockValue, ); - await expect( - service.getSortCriteria({ - role: 'none admin', - userId: 'xxxxxxxxxxxx', - tier: 5, - }), - ).rejects.toEqual( + await expect(service.getSortCriteria('external_id')).rejects.toEqual( new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, @@ -1922,7 +1965,7 @@ describe('UsersService.getSortCriteria', () => { }); describe('UsersService.updateUser', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -1935,12 +1978,15 @@ describe('UsersService.updateUser', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('ユーザー情報を更新できる(None)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: external_id } = await makeTestUser(source, { @@ -1984,19 +2030,21 @@ describe('UsersService.updateUser', () => { const createdUser = await getUser(source, user1); - expect(createdUser.id).toBe(user1); - expect(createdUser.role).toBe(USER_ROLES.NONE); - expect(createdUser.author_id).toBeNull(); - expect(createdUser.auto_renew).toBe(false); - expect(createdUser.license_alert).toBe(false); - expect(createdUser.notification).toBe(false); - expect(createdUser.encryption).toBe(false); - expect(createdUser.encryption_password).toBeNull(); - expect(createdUser.prompt).toBe(false); + expect(createdUser?.id).toBe(user1); + expect(createdUser?.role).toBe(USER_ROLES.NONE); + expect(createdUser?.author_id).toBeNull(); + expect(createdUser?.auto_renew).toBe(false); + expect(createdUser?.license_alert).toBe(false); + expect(createdUser?.notification).toBe(false); + expect(createdUser?.encryption).toBe(false); + expect(createdUser?.encryption_password).toBeNull(); + expect(createdUser?.prompt).toBe(false); }); it('ユーザー情報を更新できる(Typist)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: external_id } = await makeTestUser(source, { @@ -2040,19 +2088,21 @@ describe('UsersService.updateUser', () => { const createdUser = await getUser(source, user1); - expect(createdUser.id).toBe(user1); - expect(createdUser.role).toBe(USER_ROLES.TYPIST); - expect(createdUser.author_id).toBeNull(); - expect(createdUser.auto_renew).toBe(false); - expect(createdUser.license_alert).toBe(false); - expect(createdUser.notification).toBe(false); - expect(createdUser.encryption).toBe(false); - expect(createdUser.encryption_password).toBeNull(); - expect(createdUser.prompt).toBe(false); + expect(createdUser?.id).toBe(user1); + expect(createdUser?.role).toBe(USER_ROLES.TYPIST); + expect(createdUser?.author_id).toBeNull(); + expect(createdUser?.auto_renew).toBe(false); + expect(createdUser?.license_alert).toBe(false); + expect(createdUser?.notification).toBe(false); + expect(createdUser?.encryption).toBe(false); + expect(createdUser?.encryption_password).toBeNull(); + expect(createdUser?.prompt).toBe(false); }); it('ユーザー情報を更新できる(Author)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: external_id } = await makeTestUser(source, { @@ -2096,19 +2146,21 @@ describe('UsersService.updateUser', () => { const createdUser = await getUser(source, user1); - expect(createdUser.id).toBe(user1); - expect(createdUser.role).toBe(USER_ROLES.AUTHOR); - expect(createdUser.author_id).toBe('AUTHOR_ID'); - expect(createdUser.auto_renew).toBe(false); - expect(createdUser.license_alert).toBe(false); - expect(createdUser.notification).toBe(false); - expect(createdUser.encryption).toBe(true); - expect(createdUser.encryption_password).toBe('new_password'); - expect(createdUser.prompt).toBe(true); + expect(createdUser?.id).toBe(user1); + expect(createdUser?.role).toBe(USER_ROLES.AUTHOR); + expect(createdUser?.author_id).toBe('AUTHOR_ID'); + expect(createdUser?.auto_renew).toBe(false); + expect(createdUser?.license_alert).toBe(false); + expect(createdUser?.notification).toBe(false); + expect(createdUser?.encryption).toBe(true); + expect(createdUser?.encryption_password).toBe('new_password'); + expect(createdUser?.prompt).toBe(true); }); it('ユーザーのRoleを更新できる(None⇒Typist)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: external_id } = await makeTestUser(source, { @@ -2152,19 +2204,21 @@ describe('UsersService.updateUser', () => { const createdUser = await getUser(source, user1); - expect(createdUser.id).toBe(user1); - expect(createdUser.role).toBe(USER_ROLES.TYPIST); - expect(createdUser.author_id).toBeNull(); - expect(createdUser.auto_renew).toBe(false); - expect(createdUser.license_alert).toBe(false); - expect(createdUser.notification).toBe(false); - expect(createdUser.encryption).toBe(false); - expect(createdUser.encryption_password).toBeNull(); - expect(createdUser.prompt).toBe(false); + expect(createdUser?.id).toBe(user1); + expect(createdUser?.role).toBe(USER_ROLES.TYPIST); + expect(createdUser?.author_id).toBeNull(); + expect(createdUser?.auto_renew).toBe(false); + expect(createdUser?.license_alert).toBe(false); + expect(createdUser?.notification).toBe(false); + expect(createdUser?.encryption).toBe(false); + expect(createdUser?.encryption_password).toBeNull(); + expect(createdUser?.prompt).toBe(false); }); it('ユーザーのRoleを更新できる(None⇒Author)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: external_id } = await makeTestUser(source, { @@ -2208,19 +2262,21 @@ describe('UsersService.updateUser', () => { const createdUser = await getUser(source, user1); - expect(createdUser.id).toBe(user1); - expect(createdUser.role).toBe(USER_ROLES.AUTHOR); - expect(createdUser.author_id).toBe('AUTHOR_ID'); - expect(createdUser.auto_renew).toBe(false); - expect(createdUser.license_alert).toBe(false); - expect(createdUser.notification).toBe(false); - expect(createdUser.encryption).toBe(false); - expect(createdUser.encryption_password).toBeNull(); - expect(createdUser.prompt).toBe(false); + expect(createdUser?.id).toBe(user1); + expect(createdUser?.role).toBe(USER_ROLES.AUTHOR); + expect(createdUser?.author_id).toBe('AUTHOR_ID'); + expect(createdUser?.auto_renew).toBe(false); + expect(createdUser?.license_alert).toBe(false); + expect(createdUser?.notification).toBe(false); + expect(createdUser?.encryption).toBe(false); + expect(createdUser?.encryption_password).toBeNull(); + expect(createdUser?.prompt).toBe(false); }); it('None以外からRoleを変更した場合、エラーとなる(Typist⇒None)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: external_id } = await makeTestUser(source, { @@ -2266,7 +2322,9 @@ describe('UsersService.updateUser', () => { }); it('Authorがパスワードundefinedで渡されたとき、元のパスワードを維持する(Encryptionがtrue)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: external_id } = await makeTestUser(source, { @@ -2310,19 +2368,21 @@ describe('UsersService.updateUser', () => { const createdUser = await getUser(source, user1); - expect(createdUser.id).toBe(user1); - expect(createdUser.role).toBe(USER_ROLES.AUTHOR); - expect(createdUser.author_id).toBe('AUTHOR_ID'); - expect(createdUser.auto_renew).toBe(false); - expect(createdUser.license_alert).toBe(false); - expect(createdUser.notification).toBe(false); - expect(createdUser.encryption).toBe(true); - expect(createdUser.encryption_password).toBe('password'); - expect(createdUser.prompt).toBe(true); + expect(createdUser?.id).toBe(user1); + expect(createdUser?.role).toBe(USER_ROLES.AUTHOR); + expect(createdUser?.author_id).toBe('AUTHOR_ID'); + expect(createdUser?.auto_renew).toBe(false); + expect(createdUser?.license_alert).toBe(false); + expect(createdUser?.notification).toBe(false); + expect(createdUser?.encryption).toBe(true); + expect(createdUser?.encryption_password).toBe('password'); + expect(createdUser?.prompt).toBe(true); }); it('Authorが暗号化なしで更新した場合、パスワードをNULLにする(Encryptionがfalse)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: external_id } = await makeTestUser(source, { @@ -2366,19 +2426,21 @@ describe('UsersService.updateUser', () => { const createdUser = await getUser(source, user1); - expect(createdUser.id).toBe(user1); - expect(createdUser.role).toBe(USER_ROLES.AUTHOR); - expect(createdUser.author_id).toBe('AUTHOR_ID'); - expect(createdUser.auto_renew).toBe(false); - expect(createdUser.license_alert).toBe(false); - expect(createdUser.notification).toBe(false); - expect(createdUser.encryption).toBe(false); - expect(createdUser.encryption_password).toBeNull(); - expect(createdUser.prompt).toBe(true); + expect(createdUser?.id).toBe(user1); + expect(createdUser?.role).toBe(USER_ROLES.AUTHOR); + expect(createdUser?.author_id).toBe('AUTHOR_ID'); + expect(createdUser?.auto_renew).toBe(false); + expect(createdUser?.license_alert).toBe(false); + expect(createdUser?.notification).toBe(false); + expect(createdUser?.encryption).toBe(false); + expect(createdUser?.encryption_password).toBeNull(); + expect(createdUser?.prompt).toBe(true); }); it('AuthorのDBにパスワードが設定されていない場合、パスワードundefinedでわたすとエラーとなる(Encryptionがtrue)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: external_id } = await makeTestUser(source, { @@ -2424,7 +2486,9 @@ describe('UsersService.updateUser', () => { }); it('AuthorIdが既存のユーザーと重複した場合、エラーとなる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); const { id: accountId } = await makeTestSimpleAccount(source); const { external_id: external_id } = await makeTestUser(source, { @@ -2480,3 +2544,83 @@ describe('UsersService.updateUser', () => { ); }); }); + +describe('UsersService.updateAcceptedVersion', () => { + 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(); + }); + + afterEach(async () => { + if (!source) return; + await source.destroy(); + source = null; + }); + + it('同意済み利用規約バージョンを更新できる(第五)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const { admin } = await makeTestAccount(source, { + tier: 5, + }); + const context = makeContext(uuidv4()); + + const service = module.get(UsersService); + await service.updateAcceptedVersion(context, admin.external_id, 'v2.0'); + const user = await getUser(source, admin.id); + + expect(user?.accepted_eula_version).toBe('v2.0'); + }); + + it('同意済み利用規約バージョンを更新できる(第一~第四)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const { admin } = await makeTestAccount(source, { + tier: 4, + }); + const context = makeContext(uuidv4()); + + const service = module.get(UsersService); + await service.updateAcceptedVersion( + context, + admin.external_id, + 'v2.0', + 'v3.0', + ); + const user = await getUser(source, admin.id); + + expect(user?.accepted_eula_version).toBe('v2.0'); + expect(user?.accepted_dpa_version).toBe('v3.0'); + }); + + it('パラメータが不在のときエラーとなる(第一~第四)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const { admin } = await makeTestAccount(source, { + tier: 4, + }); + const context = makeContext(uuidv4()); + + const service = module.get(UsersService); + await expect( + service.updateAcceptedVersion( + context, + admin.external_id, + 'v2.0', + undefined, + ), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010001'), HttpStatus.BAD_REQUEST), + ); + }); +}); diff --git a/dictation_server/src/features/users/users.service.ts b/dictation_server/src/features/users/users.service.ts index a3e9521..5ba3c84 100644 --- a/dictation_server/src/features/users/users.service.ts +++ b/dictation_server/src/features/users/users.service.ts @@ -4,7 +4,7 @@ import { makeErrorResponse } from '../../common/error/makeErrorResponse'; import { isVerifyError, verify } from '../../common/jwt'; import { getPublicKey } from '../../common/jwt/jwt'; import { makePassword } from '../../common/password/password'; -import { AccessToken } from '../../common/token'; +import { AccessToken, IDToken } from '../../common/token'; import { SortDirection, TaskListSortableAttribute, @@ -30,6 +30,7 @@ import { EmailAlreadyVerifiedError, EncryptionPasswordNeedError, InvalidRoleChangeError, + UpdateTermsVersionNotSetError, UserNotFoundError, } from '../../repositories/users/errors/types'; import { @@ -47,9 +48,13 @@ import { LicenseExpiredError, LicenseUnavailableError, } from '../../repositories/licenses/errors/types'; +import { AccountNotFoundError } from '../../repositories/accounts/errors/types'; @Injectable() export class UsersService { + private readonly logger = new Logger(UsersService.name); + private readonly mailFrom: string; + private readonly appDomain: string; constructor( private readonly usersRepository: UsersRepositoryService, private readonly licensesRepository: LicensesRepositoryService, @@ -57,8 +62,10 @@ export class UsersService { private readonly adB2cService: AdB2cService, private readonly configService: ConfigService, private readonly sendgridService: SendGridService, - ) {} - private readonly logger = new Logger(UsersService.name); + ) { + this.mailFrom = this.configService.getOrThrow('MAIL_FROM'); + this.appDomain = this.configService.getOrThrow('APP_DOMAIN'); + } /** * Confirms user @@ -127,7 +134,7 @@ export class UsersService { */ async createUser( context: Context, - accessToken: AccessToken, + externalId: string, name: string, role: UserRoles, email: string, @@ -144,9 +151,7 @@ export class UsersService { //DBよりアクセス者の所属するアカウントIDを取得する let adminUser: EntityUser; try { - adminUser = await this.usersRepository.findUserByExternalId( - accessToken.userId, - ); + adminUser = await this.usersRepository.findUserByExternalId(externalId); } catch (e) { this.logger.error(`error=${e}`); throw new HttpException( @@ -253,9 +258,6 @@ export class UsersService { //Email送信用のコンテンツを作成する try { - // メールの送信元を取得 - const from = this.configService.get('MAIL_FROM') ?? ''; - // メールの内容を構成 const { subject, text, html } = await this.sendgridService.createMailContentFromEmailConfirmForNormalUser( @@ -268,7 +270,7 @@ export class UsersService { await this.sendgridService.sendMail( context, email, - from, + this.mailFrom, subject, text, html, @@ -342,6 +344,12 @@ export class UsersService { license_alert: licenseAlert, notification, role, + accepted_dpa_version: null, + accepted_eula_version: null, + encryption: false, + encryption_password: null, + prompt: false, + author_id: null, }; case USER_ROLES.AUTHOR: return { @@ -351,10 +359,12 @@ export class UsersService { license_alert: licenseAlert, notification, role, - author_id: authorId, - encryption, - encryption_password: encryptionPassword, - prompt, + author_id: authorId ?? null, + encryption: encryption ?? false, + encryption_password: encryptionPassword ?? null, + prompt: prompt ?? false, + accepted_dpa_version: null, + accepted_eula_version: null, }; default: //不正なroleが指定された場合はログを出力してエラーを返す @@ -404,19 +414,16 @@ export class UsersService { await this.adB2cService.changePassword(extarnalId, ramdomPassword); // ユーザを認証済みにする await this.usersRepository.updateUserVerified(userId); - // メールの送信元を取得 - const from = this.configService.get('MAIL_FROM') ?? ''; // TODO [Task2163] ODMS側が正式にメッセージを決めるまで仮のメール内容とする const subject = 'A temporary password has been issued.'; const text = 'temporary password: ' + ramdomPassword; - const domains = this.configService.get('APP_DOMAIN'); - const html = `

      OMDS TOP PAGE URL.

      ${domains}"
      temporary password: ${ramdomPassword}`; + const html = `

      OMDS TOP PAGE URL.

      ${this.appDomain}"
      temporary password: ${ramdomPassword}`; // メールを送信 await this.sendgridService.sendMail( context, email, - from, + this.mailFrom, subject, text, html, @@ -463,17 +470,29 @@ export class UsersService { ); // DBから取得した各ユーザーをもとにADB2C情報をマージしライセンス情報を算出 - const users = dbUsers.map((x) => { + const users = dbUsers.map((dbUser): User => { // ユーザーの所属グループ名を取得する - const groupNames = - x.userGroupMembers?.map((group) => group.userGroup?.name) ?? []; + const userGroupMembers = + dbUser.userGroupMembers !== null ? dbUser.userGroupMembers : []; - const adb2cUser = adb2cUsers.find((user) => user.id === x.external_id); + //所属グループ名の配列にする + const groupNames = userGroupMembers.flatMap((userGroupMember) => + userGroupMember.userGroup ? [userGroupMember.userGroup.name] : [], + ); + + const adb2cUser = adb2cUsers.find( + (user) => user.id === dbUser.external_id, + ); // メールアドレスを取得する - const mail = adb2cUser.identities.find( - (identity) => identity.signInType === ADB2C_SIGN_IN_TYPE.EAMILADDRESS, - ).issuerAssignedId; + const mail = adb2cUser?.identities?.find( + (identity) => identity.signInType === ADB2C_SIGN_IN_TYPE.EMAILADDRESS, + )?.issuerAssignedId; + + //メールアドレスが取得できない場合はエラー + if (!mail) { + throw new Error('mail not found.'); + } let status = USER_LICENSE_STATUS.NORMAL; @@ -482,21 +501,30 @@ export class UsersService { let expiration: string | undefined = undefined; let remaining: number | undefined = undefined; - if (x.license) { + if (dbUser.license) { // 有効期限日付 YYYY/MM/DD - const expiry_date = x.license.expiry_date; - expiration = `${expiry_date.getFullYear()}/${ - expiry_date.getMonth() + 1 - }/${expiry_date.getDate()}`; + const expiry_date = dbUser.license.expiry_date ?? undefined; + expiration = + expiry_date !== undefined + ? `${expiry_date.getFullYear()}/${ + expiry_date.getMonth() + 1 + }/${expiry_date.getDate()}` + : undefined; const currentDate = new DateWithZeroTime(); // 有効期限までの日数 - remaining = Math.floor( - (expiry_date.getTime() - currentDate.getTime()) / - (1000 * 60 * 60 * 24), - ); - if (remaining <= LICENSE_EXPIRATION_THRESHOLD_DAYS) { - status = x.auto_renew + remaining = + expiry_date !== undefined + ? Math.floor( + (expiry_date.getTime() - currentDate.getTime()) / + (1000 * 60 * 60 * 24), + ) + : undefined; + if ( + remaining !== undefined && + remaining <= LICENSE_EXPIRATION_THRESHOLD_DAYS + ) { + status = dbUser.auto_renew ? USER_LICENSE_STATUS.RENEW : USER_LICENSE_STATUS.ALERT; } @@ -505,18 +533,18 @@ export class UsersService { } return { - id: x.id, + id: dbUser.id, name: adb2cUser.displayName, - role: x.role, - authorId: x.author_id ?? undefined, + role: dbUser.role, + authorId: dbUser.author_id ?? undefined, typistGroupName: groupNames, email: mail, - emailVerified: x.email_verified, - autoRenew: x.auto_renew, - licenseAlert: x.license_alert, - notification: x.notification, - encryption: x.encryption, - prompt: x.prompt, + emailVerified: dbUser.email_verified, + autoRenew: dbUser.auto_renew, + licenseAlert: dbUser.license_alert, + notification: dbUser.notification, + encryption: dbUser.encryption, + prompt: dbUser.prompt, expiration: expiration, remaining: remaining, licenseStatus: status, @@ -544,14 +572,13 @@ export class UsersService { async updateSortCriteria( paramName: TaskListSortableAttribute, direction: SortDirection, - token: AccessToken, + externalId: string, ): Promise { this.logger.log(`[IN] ${this.updateSortCriteria.name}`); let user: EntityUser; try { // ユーザー情報を取得 - const sub = token.userId; - user = await this.usersRepository.findUserByExternalId(sub); + user = await this.usersRepository.findUserByExternalId(externalId); } catch (e) { this.logger.error(`error=${e}`); @@ -583,7 +610,7 @@ export class UsersService { * @param token * @returns sort criteria */ - async getSortCriteria(token: AccessToken): Promise<{ + async getSortCriteria(externalId: string): Promise<{ paramName: TaskListSortableAttribute; direction: SortDirection; }> { @@ -591,8 +618,7 @@ export class UsersService { let user: EntityUser; try { // ユーザー情報を取得 - const sub = token.userId; - user = await this.usersRepository.findUserByExternalId(sub); + user = await this.usersRepository.findUserByExternalId(externalId); } catch (e) { this.logger.error(`error=${e}`); @@ -642,8 +668,8 @@ export class UsersService { // TODO: PBI2105 本実装時に修正すること return { - authorId: user.author_id, - authorIdList: [user.author_id, 'XXX'], + authorId: user.author_id ?? '', + authorIdList: [user.author_id ?? '', 'XXX'], isEncrypted: true, encryptionPassword: 'abcd@123?dcba', audioFormat: 'DS2(QP)', @@ -967,4 +993,63 @@ export class UsersService { ); } } + + /** + * 同意済み利用規約バージョンを更新する + * @param context + * @param idToken + * @param eulaVersion + * @param dpaVersion + */ + async updateAcceptedVersion( + context: Context, + externalId: string, + eulaVersion: string, + dpaVersion?: string, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.updateAcceptedVersion.name} | params: { ` + + `externalId: ${externalId}, ` + + `eulaVersion: ${eulaVersion}, ` + + `dpaVersion: ${dpaVersion}, };`, + ); + + try { + await this.usersRepository.updateAcceptedTermsVersion( + externalId, + eulaVersion, + dpaVersion, + ); + } catch (e) { + this.logger.error(`[${context.trackingId}] error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case UserNotFoundError: + throw new HttpException( + makeErrorResponse('E010204'), + HttpStatus.BAD_REQUEST, + ); + case AccountNotFoundError: + throw new HttpException( + makeErrorResponse('E010501'), + HttpStatus.BAD_REQUEST, + ); + case UpdateTermsVersionNotSetError: + throw new HttpException( + makeErrorResponse('E010001'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + } finally { + this.logger.log( + `[OUT] [${context.trackingId}] ${this.updateAcceptedVersion.name}`, + ); + } + } } diff --git a/dictation_server/src/features/workflows/test/utility.ts b/dictation_server/src/features/workflows/test/utility.ts index 473f9df..d29d8fb 100644 --- a/dictation_server/src/features/workflows/test/utility.ts +++ b/dictation_server/src/features/workflows/test/utility.ts @@ -37,12 +37,19 @@ export const getWorkflows = async ( }); }; +// Workflow一覧全体を取得する +export const getAllWorkflows = async ( + datasource: DataSource, +): Promise => { + return await datasource.getRepository(Workflow).find(); +}; + // Workflowを取得する export const getWorkflow = async ( datasource: DataSource, accountId: number, id: number, -): Promise => { +): Promise => { return await datasource.getRepository(Workflow).findOne({ where: { account_id: accountId, diff --git a/dictation_server/src/features/workflows/workflows.controller.ts b/dictation_server/src/features/workflows/workflows.controller.ts index c571842..4c6ac32 100644 --- a/dictation_server/src/features/workflows/workflows.controller.ts +++ b/dictation_server/src/features/workflows/workflows.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, + HttpException, HttpStatus, Param, Post, @@ -34,6 +35,7 @@ import { retrieveAuthorizationToken } from '../../common/http/helper'; import { Request } from 'express'; import { makeContext } from '../../common/log'; import { WorkflowsService } from './workflows.service'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; @ApiTags('workflows') @Controller('workflows') @@ -64,8 +66,21 @@ export class WorkflowsController { @UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] })) @Get() async getWorkflows(@Req() req: Request): Promise { - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + const accessToken = retrieveAuthorizationToken(req); + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); @@ -107,17 +122,31 @@ export class WorkflowsController { @Body() body: CreateWorkflowsRequest, ): Promise { const { authorId, worktypeId, templateId, typists } = body; - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req); + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); await this.workflowsService.createWorkflow( context, userId, authorId, + typists, worktypeId, templateId, - typists, ); return {}; @@ -158,8 +187,22 @@ export class WorkflowsController { ): Promise { const { authorId, worktypeId, templateId, typists } = body; const { workflowId } = param; - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req); + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); await this.workflowsService.updateWorkflow( @@ -167,9 +210,9 @@ export class WorkflowsController { userId, workflowId, authorId, + typists, worktypeId, templateId, - typists, ); return {}; @@ -180,6 +223,11 @@ export class WorkflowsController { type: DeleteWorkflowResponse, description: '成功時のレスポンス', }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'パラメータ不正エラー', + type: ErrorResponse, + }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: '認証エラー', @@ -203,12 +251,25 @@ export class WorkflowsController { @Param() param: DeleteWorkflowRequestParam, ): Promise { const { workflowId } = param; - const token = retrieveAuthorizationToken(req); - const { userId } = jwt.decode(token, { json: true }) as AccessToken; + + const accessToken = retrieveAuthorizationToken(req); + if (!accessToken) { + throw new HttpException( + makeErrorResponse('E000107'), + HttpStatus.UNAUTHORIZED, + ); + } + const decodedAccessToken = jwt.decode(accessToken, { json: true }); + if (!decodedAccessToken) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.UNAUTHORIZED, + ); + } + const { userId } = decodedAccessToken as AccessToken; const context = makeContext(userId); - console.log(workflowId); - console.log(context.trackingId); + await this.workflowsService.deleteWorkflow(context, userId, workflowId); return {}; } } diff --git a/dictation_server/src/features/workflows/workflows.service.spec.ts b/dictation_server/src/features/workflows/workflows.service.spec.ts index 10e5714..3d4a65d 100644 --- a/dictation_server/src/features/workflows/workflows.service.spec.ts +++ b/dictation_server/src/features/workflows/workflows.service.spec.ts @@ -10,6 +10,7 @@ import { createWorkflow, createWorkflowTypist, getAllWorkflowTypists, + getAllWorkflows, getWorkflowTypists, getWorkflows, } from './test/utility'; @@ -20,7 +21,7 @@ import { HttpException, HttpStatus } from '@nestjs/common'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; describe('getWorkflows', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -33,12 +34,15 @@ describe('getWorkflows', () => { }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('アカウント内のWorkflow一覧を取得できる', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId1 } = await makeTestUser(source, { @@ -148,10 +152,10 @@ describe('getWorkflows', () => { expect(resWorkflows[0].id).toBe(workflow1.id); expect(resWorkflows[0].author.id).toBe(authorId1); expect(resWorkflows[0].author.authorId).toBe('AUTHOR1'); - expect(resWorkflows[0].worktype.id).toBe(worktypeId1); - expect(resWorkflows[0].worktype.worktypeId).toBe('worktype1'); - expect(resWorkflows[0].template.id).toBe(templateId1); - expect(resWorkflows[0].template.fileName).toBe('fileName1'); + expect(resWorkflows[0].worktype?.id).toBe(worktypeId1); + expect(resWorkflows[0].worktype?.worktypeId).toBe('worktype1'); + expect(resWorkflows[0].template?.id).toBe(templateId1); + expect(resWorkflows[0].template?.fileName).toBe('fileName1'); expect(resWorkflows[0].typists.length).toBe(1); expect(resWorkflows[0].typists[0].typistUserId).toBe(typistId); expect(resWorkflows[0].typists[0].typistName).toBe('typist1'); @@ -160,8 +164,8 @@ describe('getWorkflows', () => { expect(resWorkflows[1].author.id).toBe(authorId2); expect(resWorkflows[1].author.authorId).toBe('AUTHOR2'); expect(resWorkflows[1].worktype).toBe(undefined); - expect(resWorkflows[1].template.id).toBe(templateId1); - expect(resWorkflows[1].template.fileName).toBe('fileName1'); + expect(resWorkflows[1].template?.id).toBe(templateId1); + expect(resWorkflows[1].template?.fileName).toBe('fileName1'); expect(resWorkflows[1].typists.length).toBe(1); expect(resWorkflows[1].typists[0].typistGroupId).toBe(userGroupId); expect(resWorkflows[1].typists[0].typistName).toBe('group1'); @@ -169,8 +173,8 @@ describe('getWorkflows', () => { expect(resWorkflows[2].id).toBe(workflow3.id); expect(resWorkflows[2].author.id).toBe(authorId3); expect(resWorkflows[2].author.authorId).toBe('AUTHOR3'); - expect(resWorkflows[2].worktype.id).toBe(worktypeId1); - expect(resWorkflows[2].worktype.worktypeId).toBe('worktype1'); + expect(resWorkflows[2].worktype?.id).toBe(worktypeId1); + expect(resWorkflows[2].worktype?.worktypeId).toBe('worktype1'); expect(resWorkflows[2].template).toBe(undefined); expect(resWorkflows[2].typists.length).toBe(1); expect(resWorkflows[2].typists[0].typistGroupId).toBe(userGroupId); @@ -179,7 +183,9 @@ describe('getWorkflows', () => { }); it('アカウント内のWorkflow一覧を取得できる(0件)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { admin } = await makeTestAccount(source, { tier: 5 }); @@ -199,7 +205,9 @@ describe('getWorkflows', () => { }); it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -227,7 +235,7 @@ describe('getWorkflows', () => { }); describe('createWorkflows', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -239,12 +247,15 @@ describe('createWorkflows', () => { return source.initialize(); }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('アカウント内にWorkflowを作成できる(WorktypeIDあり、テンプレートファイルあり)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId } = await makeTestUser(source, { @@ -287,13 +298,13 @@ describe('createWorkflows', () => { context, admin.external_id, authorId, - worktypeId, - templateId, [ { typistId: typistId, }, ], + worktypeId, + templateId, ); //実行結果を確認 @@ -313,7 +324,9 @@ describe('createWorkflows', () => { }); it('アカウント内にWorkflowを作成できる(WorktypeIDなし、テンプレートファイルあり)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId } = await makeTestUser(source, { @@ -350,13 +363,13 @@ describe('createWorkflows', () => { context, admin.external_id, authorId, - undefined, - templateId, [ { typistId: typistId, }, ], + undefined, + templateId, ); //実行結果を確認 @@ -376,7 +389,9 @@ describe('createWorkflows', () => { }); it('アカウント内にWorkflowを作成できる(WorktypeIDあり、テンプレートファイルなし)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId } = await makeTestUser(source, { @@ -412,13 +427,13 @@ describe('createWorkflows', () => { context, admin.external_id, authorId, - worktypeId, - undefined, [ { typistId: typistId, }, ], + worktypeId, + undefined, ); //実行結果を確認 @@ -438,7 +453,9 @@ describe('createWorkflows', () => { }); it('アカウント内にWorkflowを作成できる(WorktypeIDなし、テンプレートファイルなし)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId } = await makeTestUser(source, { @@ -468,13 +485,13 @@ describe('createWorkflows', () => { context, admin.external_id, authorId, - undefined, - undefined, [ { typistId: typistId, }, ], + undefined, + undefined, ); //実行結果を確認 @@ -494,7 +511,9 @@ describe('createWorkflows', () => { }); it('アカウント内にWorkflowを作成できる(WorktypeIDなし、テンプレートファイルなし、同一AuthorIDのワークフローあり)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId } = await makeTestUser(source, { @@ -531,26 +550,26 @@ describe('createWorkflows', () => { context, admin.external_id, authorId, - worktypeId, - undefined, [ { typistId: typistId, }, ], + worktypeId, + undefined, ); await service.createWorkflow( context, admin.external_id, authorId, - undefined, - undefined, [ { typistId: typistId, }, ], + undefined, + undefined, ); //実行結果を確認 @@ -570,7 +589,9 @@ describe('createWorkflows', () => { }); it('DBにAuthorが存在しない場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); @@ -610,13 +631,13 @@ describe('createWorkflows', () => { context, admin.external_id, 0, - worktypeId, - templateId, [ { typistId: typistId, }, ], + worktypeId, + templateId, ); } catch (e) { if (e instanceof HttpException) { @@ -629,7 +650,9 @@ describe('createWorkflows', () => { }); it('DBにWorktypeIDが存在しない場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId } = await makeTestUser(source, { @@ -668,13 +691,13 @@ describe('createWorkflows', () => { context, admin.external_id, authorId, - 9999, - templateId, [ { typistId: typistId, }, ], + 9999, + templateId, ); } catch (e) { if (e instanceof HttpException) { @@ -687,7 +710,9 @@ describe('createWorkflows', () => { }); it('DBにテンプレートファイルが存在しない場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId } = await makeTestUser(source, { @@ -725,13 +750,13 @@ describe('createWorkflows', () => { context, admin.external_id, authorId, - worktypeId, - 9999, [ { typistId: typistId, }, ], + worktypeId, + 9999, ); } catch (e) { if (e instanceof HttpException) { @@ -744,7 +769,9 @@ describe('createWorkflows', () => { }); it('DBにルーティング候補ユーザーが存在しない場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId } = await makeTestUser(source, { @@ -784,13 +811,13 @@ describe('createWorkflows', () => { context, admin.external_id, authorId, - worktypeId, - templateId, [ { typistId: 9999, }, ], + worktypeId, + templateId, ); } catch (e) { if (e instanceof HttpException) { @@ -803,7 +830,9 @@ describe('createWorkflows', () => { }); it('DBにルーティング候補グループが存在しない場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId } = await makeTestUser(source, { @@ -843,13 +872,13 @@ describe('createWorkflows', () => { context, admin.external_id, authorId, - worktypeId, - templateId, [ { typistGroupId: 9999, }, ], + worktypeId, + templateId, ); } catch (e) { if (e instanceof HttpException) { @@ -862,7 +891,9 @@ describe('createWorkflows', () => { }); it('DBにAuthorIDとWorktypeIDのペアがすでに存在する場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId } = await makeTestUser(source, { @@ -911,13 +942,13 @@ describe('createWorkflows', () => { context, admin.external_id, authorId, - worktypeId, - templateId, [ { typistId: typistId, }, ], + worktypeId, + templateId, ); } catch (e) { if (e instanceof HttpException) { @@ -930,7 +961,9 @@ describe('createWorkflows', () => { }); it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId } = await makeTestUser(source, { @@ -983,13 +1016,13 @@ describe('createWorkflows', () => { context, admin.external_id, authorId, - worktypeId, - templateId, [ { typistId: typistId, }, ], + worktypeId, + templateId, ); } catch (e) { if (e instanceof HttpException) { @@ -1003,7 +1036,7 @@ describe('createWorkflows', () => { }); describe('updateWorkflow', () => { - let source: DataSource = null; + let source: DataSource | null = null; beforeEach(async () => { source = new DataSource({ type: 'sqlite', @@ -1015,12 +1048,15 @@ describe('updateWorkflow', () => { return source.initialize(); }); afterEach(async () => { + if (!source) return; await source.destroy(); source = null; }); it('アカウント内のWorkflowを更新できる(WorktypeIDあり、テンプレートファイルあり)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId1 } = await makeTestUser(source, { @@ -1089,13 +1125,13 @@ describe('updateWorkflow', () => { admin.external_id, preWorkflow.id, authorId2, - worktypeId, - templateId, [ { typistId: typistId2, }, ], + worktypeId, + templateId, ); //実行結果を確認 @@ -1114,7 +1150,9 @@ describe('updateWorkflow', () => { }); it('アカウント内にWorkflowを作成できる(WorktypeIDなし、テンプレートファイルあり)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId1 } = await makeTestUser(source, { @@ -1177,13 +1215,13 @@ describe('updateWorkflow', () => { admin.external_id, preWorkflow.id, authorId2, - undefined, - templateId, [ { typistId: typistId2, }, ], + undefined, + templateId, ); //実行結果を確認 @@ -1202,7 +1240,9 @@ describe('updateWorkflow', () => { }); it('アカウント内にWorkflowを作成できる(WorktypeIDあり、テンプレートファイルなし)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId1 } = await makeTestUser(source, { @@ -1264,13 +1304,13 @@ describe('updateWorkflow', () => { admin.external_id, preWorkflow.id, authorId2, - worktypeId, - undefined, [ { typistId: typistId2, }, ], + worktypeId, + undefined, ); //実行結果を確認 @@ -1289,7 +1329,9 @@ describe('updateWorkflow', () => { }); it('アカウント内にWorkflowを作成できる(WorktypeIDなし、テンプレートファイルなし)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId1 } = await makeTestUser(source, { @@ -1345,13 +1387,13 @@ describe('updateWorkflow', () => { admin.external_id, preWorkflow.id, authorId2, - undefined, - undefined, [ { typistId: typistId2, }, ], + undefined, + undefined, ); //実行結果を確認 @@ -1370,7 +1412,9 @@ describe('updateWorkflow', () => { }); it('アカウント内にWorkflowを作成できる(WorktypeIDなし、テンプレートファイルなし、同一AuthorIDのワークフローあり)', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId1 } = await makeTestUser(source, { @@ -1446,13 +1490,13 @@ describe('updateWorkflow', () => { admin.external_id, preWorkflow1.id, authorId2, - undefined, - undefined, [ { typistId: typistId2, }, ], + undefined, + undefined, ); //実行結果を確認 @@ -1471,7 +1515,9 @@ describe('updateWorkflow', () => { }); it('DBにWorkflowが存在しない場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId1 } = await makeTestUser(source, { @@ -1496,13 +1542,13 @@ describe('updateWorkflow', () => { admin.external_id, 9999, authorId1, - undefined, - undefined, [ { typistId: typistId1, }, ], + undefined, + undefined, ); } catch (e) { if (e instanceof HttpException) { @@ -1514,7 +1560,9 @@ describe('updateWorkflow', () => { } }); it('DBにAuthorが存在しない場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId1 } = await makeTestUser(source, { @@ -1567,13 +1615,13 @@ describe('updateWorkflow', () => { admin.external_id, preWorkflow.id, 9999, - worktypeId, - templateId, [ { typistId: typistId1, }, ], + worktypeId, + templateId, ); } catch (e) { if (e instanceof HttpException) { @@ -1586,7 +1634,9 @@ describe('updateWorkflow', () => { }); it('DBにWorktypeIDが存在しない場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId1 } = await makeTestUser(source, { @@ -1633,13 +1683,13 @@ describe('updateWorkflow', () => { admin.external_id, preWorkflow.id, authorId1, - 9999, - templateId, [ { typistId: typistId1, }, ], + 9999, + templateId, ); } catch (e) { if (e instanceof HttpException) { @@ -1652,7 +1702,9 @@ describe('updateWorkflow', () => { }); it('DBにテンプレートファイルが存在しない場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId1 } = await makeTestUser(source, { @@ -1698,13 +1750,13 @@ describe('updateWorkflow', () => { admin.external_id, preWorkflow.id, authorId1, - worktypeId, - 9999, [ { typistId: typistId1, }, ], + worktypeId, + 9999, ); } catch (e) { if (e instanceof HttpException) { @@ -1717,7 +1769,9 @@ describe('updateWorkflow', () => { }); it('DBにルーティング候補ユーザーが存在しない場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId1 } = await makeTestUser(source, { @@ -1770,13 +1824,13 @@ describe('updateWorkflow', () => { admin.external_id, preWorkflow.id, authorId1, - worktypeId, - templateId, [ { typistId: 9999, }, ], + worktypeId, + templateId, ); } catch (e) { if (e instanceof HttpException) { @@ -1789,7 +1843,9 @@ describe('updateWorkflow', () => { }); it('DBにルーティング候補グループが存在しない場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId1 } = await makeTestUser(source, { @@ -1842,13 +1898,13 @@ describe('updateWorkflow', () => { admin.external_id, preWorkflow.id, authorId1, - worktypeId, - templateId, [ { typistGroupId: 9999, }, ], + worktypeId, + templateId, ); } catch (e) { if (e instanceof HttpException) { @@ -1861,7 +1917,9 @@ describe('updateWorkflow', () => { }); it('DBにAuthorIDとWorktypeIDのペアがすでに存在する場合、400エラーとなること', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId1 } = await makeTestUser(source, { @@ -1908,13 +1966,13 @@ describe('updateWorkflow', () => { admin.external_id, preWorkflow.id, authorId1, - worktypeId1, - undefined, [ { typistId: typistId1, }, ], + worktypeId1, + undefined, ); } catch (e) { if (e instanceof HttpException) { @@ -1927,7 +1985,9 @@ describe('updateWorkflow', () => { }); it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + if (!source) fail(); const module = await makeTestingModule(source); + if (!module) fail(); // 第五階層のアカウント作成 const { account, admin } = await makeTestAccount(source, { tier: 5 }); const { id: authorId1 } = await makeTestUser(source, { @@ -1982,13 +2042,13 @@ describe('updateWorkflow', () => { admin.external_id, preWorkflow.id, authorId1, - undefined, - undefined, [ { typistId: typistId1, }, ], + undefined, + undefined, ); } catch (e) { if (e instanceof HttpException) { @@ -2000,3 +2060,313 @@ describe('updateWorkflow', () => { } }); }); + +describe('deleteWorkflows', () => { + 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(); + }); + afterEach(async () => { + if (!source) return; + await source.destroy(); + source = null; + }); + + it('アカウント内のWorkflowを削除できる', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const { id: authorId } = await makeTestUser(source, { + external_id: 'author1', + author_id: 'AUTHOR1', + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + const { id: typistId } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const workflow = await createWorkflow( + source, + account.id, + authorId, + undefined, + undefined, + ); + await createWorkflowTypist(source, workflow.id, typistId); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(1); + expect(workflowTypists.length).toBe(1); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + await service.deleteWorkflow(context, admin.external_id, workflow.id); + + //実行結果を確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(0); + expect(workflowTypists.length).toBe(0); + } + }); + + it('アカウント内のWorkflowを削除できる(複数ワークフローがある場合)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const { id: authorId1 } = await makeTestUser(source, { + external_id: 'author1', + author_id: 'AUTHOR1', + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + const { id: authorId2 } = await makeTestUser(source, { + external_id: 'author2', + author_id: 'AUTHOR2', + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + const { id: typistId } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const workflow1 = await createWorkflow(source, account.id, authorId1); + await createWorkflowTypist(source, workflow1.id, typistId); + const workflow2 = await createWorkflow(source, account.id, authorId2); + await createWorkflowTypist(source, workflow2.id, typistId); + + //作成したデータを確認 + { + const workflows = await getAllWorkflows(source); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(2); + expect(workflowTypists.length).toBe(2); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + await service.deleteWorkflow(context, admin.external_id, workflow1.id); + + //実行結果を確認 + { + const workflows = await getAllWorkflows(source); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(1); + expect(workflows[0].id).toBe(workflow2.id); + expect(workflowTypists.length).toBe(1); + expect(workflowTypists[0].workflow_id).toBe(workflow2.id); + } + }); + + it('指定されたワークフローが存在しない場合、400エラーを返却する', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const { id: authorId } = await makeTestUser(source, { + external_id: 'author1', + author_id: 'AUTHOR1', + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + const { id: typistId } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const workflow = await createWorkflow( + source, + account.id, + authorId, + undefined, + undefined, + ); + await createWorkflowTypist(source, workflow.id, typistId); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(1); + expect(workflowTypists.length).toBe(1); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.deleteWorkflow(context, admin.external_id, 9999); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E013002')); + } else { + fail(); + } + } + }); + + it('指定されたワークフローが存在しない場合、400エラーを返却する(ログインユーザーのアカウント外)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const { id: authorId } = await makeTestUser(source, { + external_id: 'author1', + author_id: 'AUTHOR1', + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + const { id: typistId } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const workflow = await createWorkflow( + source, + account.id, + authorId, + undefined, + undefined, + ); + await createWorkflowTypist(source, workflow.id, typistId); + + const { account: otherAccount } = await makeTestAccount(source, { + tier: 5, + }); + const { id: otherAauthorId } = await makeTestUser(source, { + external_id: 'author1', + author_id: 'AUTHOR1', + account_id: otherAccount.id, + role: USER_ROLES.AUTHOR, + }); + const { id: otherTypistId } = await makeTestUser(source, { + external_id: 'typist1', + account_id: otherAccount.id, + role: USER_ROLES.TYPIST, + }); + + const otherWorkflow = await createWorkflow( + source, + otherAccount.id, + otherAauthorId, + undefined, + undefined, + ); + await createWorkflowTypist(source, otherWorkflow.id, otherTypistId); + + //作成したデータを確認 + { + const workflows = await getAllWorkflows(source); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(2); + expect(workflowTypists.length).toBe(2); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.deleteWorkflow( + context, + admin.external_id, + otherWorkflow.id, + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E013002')); + } else { + fail(); + } + } + }); + + it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + const { id: authorId } = await makeTestUser(source, { + external_id: 'author1', + author_id: 'AUTHOR1', + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + const { id: typistId } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const workflow = await createWorkflow( + source, + account.id, + authorId, + undefined, + undefined, + ); + await createWorkflowTypist(source, workflow.id, typistId); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(1); + expect(workflowTypists.length).toBe(1); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //DBアクセスに失敗するようにする + const workflowsRepositoryService = module.get( + WorkflowsRepositoryService, + ); + workflowsRepositoryService.deleteWorkflow = jest + .fn() + .mockRejectedValue('DB failed'); + + //実行結果を確認 + try { + await service.deleteWorkflow(context, admin.external_id, workflow.id); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + }); +}); diff --git a/dictation_server/src/features/workflows/workflows.service.ts b/dictation_server/src/features/workflows/workflows.service.ts index 288629a..6f98c95 100644 --- a/dictation_server/src/features/workflows/workflows.service.ts +++ b/dictation_server/src/features/workflows/workflows.service.ts @@ -11,8 +11,10 @@ import { WorktypeIdNotFoundError } from '../../repositories/worktypes/errors/typ import { TemplateFileNotExistError } from '../../repositories/template_files/errors/types'; import { AuthorIdAndWorktypeIdPairAlreadyExistsError, - WorkflowIdNotFoundError, + WorkflowNotFoundError, } from '../../repositories/workflows/errors/types'; +import { AccountNotFoundError } from '../../repositories/accounts/errors/types'; +import { Assignee } from '../tasks/types/types'; @Injectable() export class WorkflowsService { @@ -46,15 +48,20 @@ export class WorkflowsService { // ワークフロー一覧からtypistのexternalIdを取得 const externalIds = workflowRecords.flatMap((workflow) => { - const workflowTypists = workflow.workflowTypists.flatMap( + const workflowTypists = workflow.workflowTypists?.flatMap( (workflowTypist) => { const { typist } = workflowTypist; - return typist ? [typist?.external_id] : []; + return typist ? [typist.external_id] : []; }, ); return workflowTypists; }); - const distinctedExternalIds = [...new Set(externalIds)]; + // externalIdsからundefinedを除外 + const filteredExternalIds = externalIds.flatMap((externalId) => + externalId ? [externalId] : [], + ); + // externalIdsから重複を除外 + const distinctedExternalIds = [...new Set(filteredExternalIds)]; // ADB2Cからユーザー一覧を取得 const adb2cUsers = await this.adB2cService.getUsers( @@ -63,8 +70,11 @@ export class WorkflowsService { ); // DBから取得したワークフロー一覧を整形 - const workflows = workflowRecords.map((workflow) => { + const workflows = workflowRecords.map((workflow): Workflow => { const { id, author, worktype, template, workflowTypists } = workflow; + if (!author || !author.id || !author.author_id) { + throw new Error('author is undefined'); + } const authorId = { id: author.id, authorId: author.author_id }; const worktypeId = worktype @@ -74,16 +84,24 @@ export class WorkflowsService { ? { id: template.id, fileName: template.file_name } : undefined; + if (!workflowTypists) { + throw new Error('workflowTypists is undefined'); + } + // ルーティング候補を整形 - const typists = workflowTypists.map((workflowTypist) => { + const typists = workflowTypists.map((workflowTypist): Assignee => { const { typist, typistGroup } = workflowTypist; // typistがユーザーの場合はADB2Cからユーザー名を取得 const typistName = typist ? adb2cUsers.find( (adb2cUser) => adb2cUser.id === typist.external_id, - ).displayName - : typistGroup.name; + )?.displayName + : typistGroup?.name; + + if (!typistName) { + throw new Error('typistName is undefined'); + } return { typistUserId: typist?.id, @@ -129,9 +147,9 @@ export class WorkflowsService { context: Context, externalId: string, authorId: number, + typists: WorkflowTypist[], worktypeId?: number | undefined, templateId?: number | undefined, - typists?: WorkflowTypist[], ): Promise { this.logger.log( `[IN] [${context.trackingId}] ${this.createWorkflow.name} | | params: { ` + @@ -148,9 +166,9 @@ export class WorkflowsService { await this.workflowsRepository.createtWorkflows( accountId, authorId, + typists, worktypeId, templateId, - typists, ); } catch (e) { this.logger.error(`[${context.trackingId}] error=${e}`); @@ -214,9 +232,9 @@ export class WorkflowsService { externalId: string, workflowId: number, authorId: number, + typists: WorkflowTypist[], worktypeId?: number | undefined, templateId?: number | undefined, - typists?: WorkflowTypist[], ): Promise { this.logger.log( `[IN] [${context.trackingId}] ${this.updateWorkflow.name} | params: { ` + @@ -235,15 +253,15 @@ export class WorkflowsService { accountId, workflowId, authorId, + typists, worktypeId, templateId, - typists, ); } catch (e) { this.logger.error(`[${context.trackingId}] error=${e}`); if (e instanceof Error) { switch (e.constructor) { - case WorkflowIdNotFoundError: + case WorkflowNotFoundError: throw new HttpException( makeErrorResponse('E013002'), HttpStatus.BAD_REQUEST, @@ -290,4 +308,70 @@ export class WorkflowsService { ); } } + + /** + * ワークフローを削除する + * @param context + * @param externalId + * @param workflowId + * @returns workflow + */ + async deleteWorkflow( + context: Context, + externalId: string, + workflowId: number, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.deleteWorkflow.name} | | params: { ` + + `externalId: ${externalId}, ` + + `workflowId: ${workflowId} };`, + ); + try { + const { account } = await this.usersRepository.findUserByExternalId( + externalId, + ); + + if (!account) { + throw new AccountNotFoundError( + `account not found. externalId: ${externalId}`, + ); + } + + await this.workflowsRepository.deleteWorkflow(account.id, workflowId); + } catch (e) { + this.logger.error(`[${context.trackingId}] error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case UserNotFoundError: + throw new HttpException( + makeErrorResponse('E010204'), + HttpStatus.BAD_REQUEST, + ); + case AccountNotFoundError: + throw new HttpException( + makeErrorResponse('E010501'), + HttpStatus.BAD_REQUEST, + ); + case WorkflowNotFoundError: + throw new HttpException( + makeErrorResponse('E013002'), + 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.trackingId}] ${this.deleteWorkflow.name}`, + ); + } + } } diff --git a/dictation_server/src/gateways/adb2c/adb2c.service.ts b/dictation_server/src/gateways/adb2c/adb2c.service.ts index 254acea..e7dcce6 100644 --- a/dictation_server/src/gateways/adb2c/adb2c.service.ts +++ b/dictation_server/src/gateways/adb2c/adb2c.service.ts @@ -30,17 +30,18 @@ export const isConflictError = (arg: unknown): arg is ConflictError => { @Injectable() export class AdB2cService { private readonly logger = new Logger(AdB2cService.name); - private readonly tenantName = this.configService.get('TENANT_NAME'); + private readonly tenantName = + this.configService.getOrThrow('TENANT_NAME'); private readonly flowName = - this.configService.get('SIGNIN_FLOW_NAME'); + this.configService.getOrThrow('SIGNIN_FLOW_NAME'); private graphClient: Client; constructor(private readonly configService: ConfigService) { // ADB2Cへの認証情報 const credential = new ClientSecretCredential( - this.configService.get('ADB2C_TENANT_ID'), - this.configService.get('ADB2C_CLIENT_ID'), - this.configService.get('ADB2C_CLIENT_SECRET'), + this.configService.getOrThrow('ADB2C_TENANT_ID'), + this.configService.getOrThrow('ADB2C_CLIENT_ID'), + this.configService.getOrThrow('ADB2C_CLIENT_SECRET'), ); const authProvider = new TokenCredentialAuthenticationProvider(credential, { scopes: ['https://graph.microsoft.com/.default'], @@ -75,7 +76,7 @@ export class AdB2cService { }, identities: [ { - signinType: ADB2C_SIGN_IN_TYPE.EAMILADDRESS, + signinType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS, issuer: `${this.tenantName}.onmicrosoft.com`, issuerAssignedId: email, }, diff --git a/dictation_server/src/gateways/blobstorage/blobstorage.service.ts b/dictation_server/src/gateways/blobstorage/blobstorage.service.ts index 57dd908..8d8b91e 100644 --- a/dictation_server/src/gateways/blobstorage/blobstorage.service.ts +++ b/dictation_server/src/gateways/blobstorage/blobstorage.service.ts @@ -28,31 +28,31 @@ export class BlobstorageService { private readonly sasTokenExpireHour: number; constructor(private readonly configService: ConfigService) { this.sharedKeyCredentialUS = new StorageSharedKeyCredential( - this.configService.get('STORAGE_ACCOUNT_NAME_US'), - this.configService.get('STORAGE_ACCOUNT_KEY_US'), + this.configService.getOrThrow('STORAGE_ACCOUNT_NAME_US'), + this.configService.getOrThrow('STORAGE_ACCOUNT_KEY_US'), ); this.sharedKeyCredentialAU = new StorageSharedKeyCredential( - this.configService.get('STORAGE_ACCOUNT_NAME_AU'), - this.configService.get('STORAGE_ACCOUNT_KEY_AU'), + this.configService.getOrThrow('STORAGE_ACCOUNT_NAME_AU'), + this.configService.getOrThrow('STORAGE_ACCOUNT_KEY_AU'), ); this.sharedKeyCredentialEU = new StorageSharedKeyCredential( - this.configService.get('STORAGE_ACCOUNT_NAME_EU'), - this.configService.get('STORAGE_ACCOUNT_KEY_EU'), + this.configService.getOrThrow('STORAGE_ACCOUNT_NAME_EU'), + this.configService.getOrThrow('STORAGE_ACCOUNT_KEY_EU'), ); this.blobServiceClientUS = new BlobServiceClient( - this.configService.get('STORAGE_ACCOUNT_ENDPOINT_US'), + this.configService.getOrThrow('STORAGE_ACCOUNT_ENDPOINT_US'), this.sharedKeyCredentialUS, ); this.blobServiceClientAU = new BlobServiceClient( - this.configService.get('STORAGE_ACCOUNT_ENDPOINT_AU'), + this.configService.getOrThrow('STORAGE_ACCOUNT_ENDPOINT_AU'), this.sharedKeyCredentialAU, ); this.blobServiceClientEU = new BlobServiceClient( - this.configService.get('STORAGE_ACCOUNT_ENDPOINT_EU'), + this.configService.getOrThrow('STORAGE_ACCOUNT_ENDPOINT_EU'), this.sharedKeyCredentialEU, ); - this.sasTokenExpireHour = Number( - this.configService.get('STORAGE_TOKEN_EXPIRE_TIME'), + this.sasTokenExpireHour = this.configService.getOrThrow( + 'STORAGE_TOKEN_EXPIRE_TIME', ); } diff --git a/dictation_server/src/gateways/notificationhub/notificationhub.service.ts b/dictation_server/src/gateways/notificationhub/notificationhub.service.ts index 2f8fe90..22038cc 100644 --- a/dictation_server/src/gateways/notificationhub/notificationhub.service.ts +++ b/dictation_server/src/gateways/notificationhub/notificationhub.service.ts @@ -18,8 +18,8 @@ export class NotificationhubService { private readonly client: NotificationHubsClient; constructor(private readonly configService: ConfigService) { this.client = new NotificationHubsClient( - this.configService.get('NOTIFICATION_HUB_CONNECT_STRING'), - this.configService.get('NOTIFICATION_HUB_NAME'), + this.configService.getOrThrow('NOTIFICATION_HUB_CONNECT_STRING'), + this.configService.getOrThrow('NOTIFICATION_HUB_NAME'), ); } diff --git a/dictation_server/src/gateways/sendgrid/sendgrid.service.ts b/dictation_server/src/gateways/sendgrid/sendgrid.service.ts index 3fb3e66..20f68d8 100644 --- a/dictation_server/src/gateways/sendgrid/sendgrid.service.ts +++ b/dictation_server/src/gateways/sendgrid/sendgrid.service.ts @@ -8,8 +8,14 @@ import { Context } from '../../common/log'; @Injectable() export class SendGridService { private readonly logger = new Logger(SendGridService.name); + private readonly emailConfirmLifetime: number; + private readonly appDomain: string; constructor(private readonly configService: ConfigService) { - const key = this.configService.get('SENDGRID_API_KEY'); + this.appDomain = this.configService.getOrThrow('APP_DOMAIN'); + this.emailConfirmLifetime = this.configService.getOrThrow( + 'EMAIL_CONFIRM_LIFETIME', + ); + const key = this.configService.getOrThrow('SENDGRID_API_KEY'); sendgrid.setApiKey(key); } @@ -30,8 +36,6 @@ export class SendGridService { `[IN] [${context.trackingId}] ${this.createMailContentFromEmailConfirm.name}`, ); - const lifetime = - this.configService.get('EMAIL_CONFIRM_LIFETIME') ?? 0; const privateKey = getPrivateKey(this.configService); const token = sign<{ accountId: number; userId: number; email: string }>( { @@ -39,10 +43,9 @@ export class SendGridService { userId, email, }, - lifetime, + this.emailConfirmLifetime, privateKey, ); - const domains = this.configService.get('APP_DOMAIN'); const path = 'mail-confirm/'; this.logger.log( @@ -50,8 +53,8 @@ export class SendGridService { ); return { subject: 'Verify your new account', - text: `The verification URL. ${domains}${path}?verify=${token}`, - html: `

      The verification URL.

      ${domains}${path}?verify=${token}"`, + text: `The verification URL. ${this.appDomain}${path}?verify=${token}`, + html: `

      The verification URL.

      ${this.appDomain}${path}?verify=${token}"`, }; } @@ -68,9 +71,6 @@ export class SendGridService { userId: number, email: string, ): Promise<{ subject: string; text: string; html: string }> { - const lifetime = - this.configService.get('EMAIL_CONFIRM_LIFETIME') ?? 0; - const privateKey = getPrivateKey(this.configService); const token = sign<{ accountId: number; userId: number; email: string }>( @@ -79,16 +79,15 @@ export class SendGridService { userId, email, }, - lifetime, + this.emailConfirmLifetime, privateKey, ); - const domains = this.configService.get('APP_DOMAIN'); const path = 'mail-confirm/user/'; return { subject: 'Verify your new account', - text: `The verification URL. ${domains}${path}?verify=${token}`, - html: `

      The verification URL.

      ${domains}${path}?verify=${token}"`, + text: `The verification URL. ${this.appDomain}${path}?verify=${token}`, + html: `

      The verification URL.

      ${this.appDomain}${path}?verify=${token}"`, }; } diff --git a/dictation_server/src/main.ts b/dictation_server/src/main.ts index dc7cb5c..eec7370 100644 --- a/dictation_server/src/main.ts +++ b/dictation_server/src/main.ts @@ -5,14 +5,15 @@ import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; import helmet from 'helmet'; const helmetDirectives = helmet.contentSecurityPolicy.getDefaultDirectives(); + helmetDirectives['connect-src'] = process.env.STAGE === 'local' ? [ "'self'", - process.env.ADB2C_ORIGIN, - process.env.STORAGE_ACCOUNT_ENDPOINT_US, - process.env.STORAGE_ACCOUNT_ENDPOINT_AU, - process.env.STORAGE_ACCOUNT_ENDPOINT_EU, + process.env.ADB2C_ORIGIN ?? '', + process.env.STORAGE_ACCOUNT_ENDPOINT_US ?? '', + process.env.STORAGE_ACCOUNT_ENDPOINT_AU ?? '', + process.env.STORAGE_ACCOUNT_ENDPOINT_EU ?? '', ] : ["'self'"]; @@ -20,6 +21,7 @@ helmetDirectives['navigate-to'] = ["'self'"]; helmetDirectives['style-src'] = ["'self'", 'https:']; helmetDirectives['report-uri'] = ["'self'"]; async function bootstrap() { + console.log(`BUILD_VERSION: ${process.env.BUILD_VERSION}`); const app = await NestFactory.create(AppModule); app.use( diff --git a/dictation_server/src/repositories/accounts/accounts.repository.service.ts b/dictation_server/src/repositories/accounts/accounts.repository.service.ts index 39fe067..8c582f0 100644 --- a/dictation_server/src/repositories/accounts/accounts.repository.service.ts +++ b/dictation_server/src/repositories/accounts/accounts.repository.service.ts @@ -13,6 +13,7 @@ import { import { User, UserArchive } from '../users/entity/user.entity'; import { Account } from './entity/account.entity'; import { + CardLicense, License, LicenseAllocationHistory, LicenseAllocationHistoryArchive, @@ -48,6 +49,14 @@ import { import { DateWithZeroTime } from '../../features/licenses/types/types'; import { Worktype } from '../worktypes/entity/worktype.entity'; import { WorktypeIdNotFoundError } from '../worktypes/errors/types'; +import { OptionItem } from '../worktypes/entity/option_item.entity'; +import { Task } from '../tasks/entity/task.entity'; +import { CheckoutPermission } from '../checkout_permissions/entity/checkout_permission.entity'; +import { AudioFile } from '../audio_files/entity/audio_file.entity'; +import { AudioOptionItem } from '../audio_option_items/entity/audio_option_item.entity'; +import { UserGroup } from '../user_groups/entity/user_group.entity'; +import { UserGroupMember } from '../user_groups/entity/user_group_member.entity'; +import { TemplateFile } from '../template_files/entity/template_file.entity'; @Injectable() export class AccountsRepositoryService { @@ -117,13 +126,13 @@ export class AccountsRepositoryService { tier: number, adminExternalUserId: string, adminUserRole: string, - adminUserAcceptedEulaVersion: string, - adminUserAcceptedDpaVersion: string, + adminUserAcceptedEulaVersion?: string, + adminUserAcceptedDpaVersion?: string, ): Promise<{ newAccount: Account; adminUser: User }> { return await this.dataSource.transaction(async (entityManager) => { const account = new Account(); { - account.parent_account_id = dealerAccountId; + account.parent_account_id = dealerAccountId ?? null; account.company_name = companyName; account.country = country; account.tier = tier; @@ -138,8 +147,8 @@ export class AccountsRepositoryService { user.account_id = persistedAccount.id; user.external_id = adminExternalUserId; user.role = adminUserRole; - user.accepted_eula_version = adminUserAcceptedEulaVersion; - user.accepted_dpa_version = adminUserAcceptedDpaVersion; + user.accepted_eula_version = adminUserAcceptedEulaVersion ?? null; + user.accepted_dpa_version = adminUserAcceptedDpaVersion ?? null; } const usersRepo = entityManager.getRepository(User); const newUser = usersRepo.create(user); @@ -490,6 +499,9 @@ export class AccountsRepositoryService { id: id, }, }); + if (!ownAccount) { + throw new AccountNotFoundError(); + } // 自アカウントのライセンス注文状況を取得する const ownLicenseOrderStatus = await this.getAccountLicenseOrderStatus( @@ -532,8 +544,8 @@ export class AccountsRepositoryService { ); // 第五の不足数を算出するためのライセンス数情報を取得する - let expiringSoonLicense: number; - let allocatableLicenseWithMargin: number; + let expiringSoonLicense: number = 0; + let allocatableLicenseWithMargin: number = 0; if (childAccount.tier === TIERS.TIER5) { expiringSoonLicense = await this.getExpiringSoonLicense( entityManager, @@ -606,7 +618,7 @@ export class AccountsRepositoryService { return await this.dataSource.transaction(async (entityManager) => { const accountRepository = entityManager.getRepository(Account); const maxTierDifference = TIERS.TIER5 - TIERS.TIER1; - const parentAccountIds = []; + const parentAccountIds: number[] = []; let currentAccountId = targetAccountId; // システム的な最大の階層差異分、親を参照する @@ -619,6 +631,9 @@ export class AccountsRepositoryService { if (!account) { break; } + if (!account.parent_account_id) { + throw new Error("Parent account doesn't exist."); + } parentAccountIds.push(account.parent_account_id); currentAccountId = account.parent_account_id; @@ -740,11 +755,13 @@ export class AccountsRepositoryService { }); // ADB2Cから情報を取得するための外部ユーザIDを取得する(念のためプライマリ管理者IDが存在しない場合を考慮) - const primaryUserIds = partnerAccounts.map((x) => { + const primaryUserIds = partnerAccounts.flatMap((x) => { if (x.primary_admin_user_id) { - return x.primary_admin_user_id; + return [x.primary_admin_user_id]; } else if (x.secondary_admin_user_id) { - return x.secondary_admin_user_id; + return [x.secondary_admin_user_id]; + } else { + return []; } }); const userRepo = entityManager.getRepository(User); @@ -761,15 +778,18 @@ export class AccountsRepositoryService { user.id === account.primary_admin_user_id || user.id === account.secondary_admin_user_id, ); - const primaryAccountExternalId = primaryUser - ? primaryUser.external_id - : undefined; + if (!primaryUser) { + throw new AdminUserNotFoundError( + `Primary admin user is not found. id: ${account.primary_admin_user_id}, account_id: ${account.id}`, + ); + } + return { name: account.company_name, tier: account.tier, accountId: account.id, country: account.country, - primaryAccountExternalId: primaryAccountExternalId, + primaryAccountExternalId: primaryUser.external_id, dealerManagement: account.delegation_permission, }; }); @@ -790,7 +810,7 @@ export class AccountsRepositoryService { async getOneUpperTierAccount( accountId: number, tier: number, - ): Promise { + ): Promise { return await this.dataSource.transaction(async (entityManager) => { const accountRepo = entityManager.getRepository(Account); return await accountRepo.findOne({ @@ -869,10 +889,10 @@ export class AccountsRepositoryService { await accountRepo.update( { id: myAccountId }, { - parent_account_id: parentAccountId || null, + parent_account_id: parentAccountId ?? null, delegation_permission: delegationPermission, primary_admin_user_id: primaryAdminUserId, - secondary_admin_user_id: secondryAdminUserId || null, + secondary_admin_user_id: secondryAdminUserId ?? null, }, ); }); @@ -966,9 +986,107 @@ export class AccountsRepositoryService { .execute(); // アカウントを削除 - // アカウントを削除することで、外部キー制約がで紐づいている関連テーブルのデータも削除される const accountRepo = entityManager.getRepository(Account); await accountRepo.delete({ id: accountId }); + + // ライセンス系(card_license_issue以外)のテーブルのレコードを削除する + const orderRepo = entityManager.getRepository(LicenseOrder); + await orderRepo.delete({ + from_account_id: accountId, + }); + const licenseRepo = entityManager.getRepository(License); + const targetLicenses = await licenseRepo.find({ + where: { + account_id: accountId, + }, + }); + const cardLicenseRepo = entityManager.getRepository(CardLicense); + await cardLicenseRepo.delete({ + license_id: In(targetLicenses.map((license) => license.id)), + }); + await licenseRepo.delete({ + account_id: accountId, + }); + const LicenseAllocationHistoryRepo = entityManager.getRepository( + LicenseAllocationHistory, + ); + await LicenseAllocationHistoryRepo.delete({ + account_id: accountId, + }); + + // ワークタイプ系のテーブルのレコードを削除する + const worktypeRepo = entityManager.getRepository(Worktype); + const taggerWorktypes = await worktypeRepo.find({ + where: { account_id: accountId }, + }); + + const optionItemRepo = entityManager.getRepository(OptionItem); + await optionItemRepo.delete({ + worktype_id: In(taggerWorktypes.map((worktype) => worktype.id)), + }); + await worktypeRepo.delete({ account_id: accountId }); + + // タスク系のテーブルのレコードを削除する + const taskRepo = entityManager.getRepository(Task); + const targetTasks = await taskRepo.find({ + where: { + account_id: accountId, + }, + }); + const checkoutPermissionRepo = + entityManager.getRepository(CheckoutPermission); + await checkoutPermissionRepo.delete({ + task_id: In(targetTasks.map((task) => task.id)), + }); + await taskRepo.delete({ + account_id: accountId, + }); + + // オーディオファイル系のテーブルのレコードを削除する + const audioFileRepo = entityManager.getRepository(AudioFile); + const targetaudioFiles = await audioFileRepo.find({ + where: { + account_id: accountId, + }, + }); + const audioOptionItemsRepo = entityManager.getRepository(AudioOptionItem); + await audioOptionItemsRepo.delete({ + audio_file_id: In(targetaudioFiles.map((audioFile) => audioFile.id)), + }); + await audioFileRepo.delete({ + account_id: accountId, + }); + + // ユーザーグループ系のテーブルのレコードを削除する + const userGroupRepo = entityManager.getRepository(UserGroup); + const targetUserGroup = await userGroupRepo.find({ + where: { + account_id: accountId, + }, + }); + const userGroupMemberRepo = entityManager.getRepository(UserGroupMember); + await userGroupMemberRepo.delete({ + user_group_id: In(targetUserGroup.map((userGroup) => userGroup.id)), + }); + await userGroupRepo.delete({ + account_id: accountId, + }); + + // テンプレートファイルテーブルのレコードを削除する + const templateFileRepo = entityManager.getRepository(TemplateFile); + await templateFileRepo.delete({ account_id: accountId }); + + // ユーザテーブルのレコードを削除する + const userRepo = entityManager.getRepository(User); + await userRepo.delete({ + account_id: accountId, + }); + + // ソート条件のテーブルのレコードを削除する + const sortCriteriaRepo = entityManager.getRepository(SortCriteria); + await sortCriteriaRepo.delete({ + user_id: In(users.map((user) => user.id)), + }); return users; }); } diff --git a/dictation_server/src/repositories/accounts/entity/account.entity.ts b/dictation_server/src/repositories/accounts/entity/account.entity.ts index f6787d9..74a0a0a 100644 --- a/dictation_server/src/repositories/accounts/entity/account.entity.ts +++ b/dictation_server/src/repositories/accounts/entity/account.entity.ts @@ -13,8 +13,8 @@ export class Account { @PrimaryGeneratedColumn() id: number; - @Column({ nullable: true }) - parent_account_id?: number; + @Column({ nullable: true, type: 'unsigned big int' }) + parent_account_id: number | null; @Column() tier: number; @@ -34,30 +34,36 @@ export class Account { @Column({ default: false }) verified: boolean; - @Column({ nullable: true }) - primary_admin_user_id?: number; + @Column({ nullable: true, type: 'unsigned big int' }) + primary_admin_user_id: number | null; - @Column({ nullable: true }) - secondary_admin_user_id?: number; + @Column({ nullable: true, type: 'unsigned big int' }) + secondary_admin_user_id: number | null; - @Column({ nullable: true }) - active_worktype_id?: number; + @Column({ nullable: true, type: 'unsigned big int' }) + active_worktype_id: number | null; - @Column({ nullable: true }) - deleted_at?: Date; + @Column({ nullable: true, type: 'datetime' }) + deleted_at: Date | null; - @Column({ nullable: true }) - created_by?: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; - @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 created_at: Date; - @Column({ nullable: true }) - updated_by?: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; - @UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 updated_at: Date; @OneToMany(() => User, (user) => user.id) - user?: User[]; + user: User[] | null; } diff --git a/dictation_server/src/repositories/audio_files/entity/audio_file.entity.ts b/dictation_server/src/repositories/audio_files/entity/audio_file.entity.ts index 244f955..5a0d9f5 100644 --- a/dictation_server/src/repositories/audio_files/entity/audio_file.entity.ts +++ b/dictation_server/src/repositories/audio_files/entity/audio_file.entity.ts @@ -32,12 +32,12 @@ export class AudioFile { priority: string; @Column() audio_format: string; - @Column({ nullable: true }) - comment?: string; - @Column({ nullable: true }) - deleted_at?: Date; + @Column({ nullable: true, type: 'varchar' }) + comment: string | null; + @Column({ nullable: true, type: 'datetime' }) + deleted_at: Date | null; @Column() is_encrypted: boolean; @OneToOne(() => Task, (task) => task.file) - task?: Task; + task: Task | null; } diff --git a/dictation_server/src/repositories/audio_option_items/entity/audio_option_item.entity.ts b/dictation_server/src/repositories/audio_option_items/entity/audio_option_item.entity.ts index ca39f80..665ac69 100644 --- a/dictation_server/src/repositories/audio_option_items/entity/audio_option_item.entity.ts +++ b/dictation_server/src/repositories/audio_option_items/entity/audio_option_item.entity.ts @@ -19,5 +19,5 @@ export class AudioOptionItem { value: string; @ManyToOne(() => Task, (task) => task.audio_file_id) @JoinColumn({ name: 'audio_file_id' }) - task?: Task; + task: Task | null; } diff --git a/dictation_server/src/repositories/checkout_permissions/entity/checkout_permission.entity.ts b/dictation_server/src/repositories/checkout_permissions/entity/checkout_permission.entity.ts index cd6b116..1269c1f 100644 --- a/dictation_server/src/repositories/checkout_permissions/entity/checkout_permission.entity.ts +++ b/dictation_server/src/repositories/checkout_permissions/entity/checkout_permission.entity.ts @@ -18,21 +18,21 @@ export class CheckoutPermission { @Column({}) task_id: number; - @Column({ nullable: true }) - user_id?: number; + @Column({ nullable: true, type: 'unsigned big int' }) + user_id: number | null; - @Column({ nullable: true }) - user_group_id?: number; + @Column({ nullable: true, type: 'unsigned big int' }) + user_group_id: number | null; @OneToOne(() => User, (user) => user.id) @JoinColumn({ name: 'user_id' }) - user?: User; + user: User | null; @OneToOne(() => UserGroup, (group) => group.id) @JoinColumn({ name: 'user_group_id' }) - user_group?: UserGroup; + user_group: UserGroup | null; @ManyToOne(() => Task, (task) => task.id) @JoinColumn({ name: 'task_id' }) - task?: Task; + task: Task | null; } diff --git a/dictation_server/src/repositories/licenses/entity/license.entity.ts b/dictation_server/src/repositories/licenses/entity/license.entity.ts index dc39e6e..b8e2cd2 100644 --- a/dictation_server/src/repositories/licenses/entity/license.entity.ts +++ b/dictation_server/src/repositories/licenses/entity/license.entity.ts @@ -25,11 +25,14 @@ export class LicenseOrder { @Column() to_account_id: number; - @CreateDateColumn() + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) ordered_at: Date; - @Column({ nullable: true }) - issued_at?: Date; + @Column({ nullable: true, type: 'datetime' }) + issued_at: Date | null; @Column() quantity: number; @@ -37,19 +40,25 @@ export class LicenseOrder { @Column() status: string; - @Column({ nullable: true }) - canceled_at?: Date; + @Column({ nullable: true, type: 'datetime' }) + canceled_at: Date | null; - @Column({ nullable: true }) - created_by: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; - @CreateDateColumn() + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) created_at: Date; - @Column({ nullable: true }) - updated_by: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; - @UpdateDateColumn() + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) updated_at: Date; } @@ -58,8 +67,8 @@ export class License { @PrimaryGeneratedColumn() id: number; - @Column({ nullable: true }) - expiry_date: Date; + @Column({ nullable: true, type: 'datetime' }) + expiry_date: Date | null; @Column() account_id: number; @@ -70,33 +79,41 @@ export class License { @Column() status: string; - @Column({ nullable: true }) - allocated_user_id: number; + @Column({ nullable: true, type: 'unsigned big int' }) + allocated_user_id: number | null; - @Column({ nullable: true }) - order_id: number; + @Column({ nullable: true, type: 'unsigned big int' }) + order_id: number | null; - @Column({ nullable: true }) - deleted_at: Date; + @Column({ nullable: true, type: 'datetime' }) + deleted_at: Date | null; - @Column({ nullable: true }) - delete_order_id: number; + @Column({ nullable: true, type: 'unsigned big int' }) + delete_order_id: number | null; - @Column({ nullable: true }) - created_by: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; - @CreateDateColumn() + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) created_at: Date; - @Column({ nullable: true }) - updated_by: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; - @UpdateDateColumn() + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) updated_at: Date; - @OneToOne(() => User, (user) => user.license) + @OneToOne(() => User, (user) => user.license, { + createForeignKeyConstraints: false, + }) // createForeignKeyConstraintsはSQLite用設定値.本番用は別途migrationで設定 @JoinColumn({ name: 'allocated_user_id' }) - user?: User; + user: User | null; } @Entity({ name: 'card_license_issue' }) @@ -107,16 +124,22 @@ export class CardLicenseIssue { @Column() issued_at: Date; - @Column({ nullable: true }) - created_by: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; - @CreateDateColumn() + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) created_at: Date; - @Column({ nullable: true }) - updated_by: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; - @UpdateDateColumn() + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) updated_at: Date; } @@ -131,19 +154,25 @@ export class CardLicense { @Column() card_license_key: string; - @Column({ nullable: true }) - activated_at: Date; + @Column({ nullable: true, type: 'datetime' }) + activated_at: Date | null; - @Column({ nullable: true }) - created_by: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; - @CreateDateColumn() + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) created_at: Date; - @Column({ nullable: true }) - updated_by: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; - @UpdateDateColumn({}) + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) updated_at: Date; } @@ -170,24 +199,32 @@ export class LicenseAllocationHistory { @Column() switch_from_type: string; - @Column({ nullable: true }) - deleted_at: Date; + @Column({ nullable: true, type: 'datetime' }) + deleted_at: Date | null; - @Column({ nullable: true }) - created_by: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; - @CreateDateColumn() + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) created_at: Date; - @Column({ nullable: true }) - updated_by: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; - @UpdateDateColumn() + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) updated_at: Date; - @ManyToOne(() => License, (licenses) => licenses.id) + @ManyToOne(() => License, (licenses) => licenses.id, { + createForeignKeyConstraints: false, + }) // createForeignKeyConstraintsはSQLite用設定値.本番用は別途migrationで設定 @JoinColumn({ name: 'license_id' }) - license?: License; + license: License | null; } @Entity({ name: 'licenses_archive' }) @@ -195,8 +232,8 @@ export class LicenseArchive { @PrimaryColumn() id: number; - @Column({ nullable: true }) - expiry_date: Date; + @Column({ nullable: true, type: 'datetime' }) + expiry_date: Date | null; @Column() account_id: number; @@ -207,31 +244,34 @@ export class LicenseArchive { @Column() status: string; - @Column({ nullable: true }) - allocated_user_id: number; + @Column({ nullable: true, type: 'unsigned big int' }) + allocated_user_id: number | null; - @Column({ nullable: true }) - order_id: number; + @Column({ nullable: true, type: 'unsigned big int' }) + order_id: number | null; - @Column({ nullable: true }) - deleted_at: Date; + @Column({ nullable: true, type: 'datetime' }) + deleted_at: Date | null; - @Column({ nullable: true }) - delete_order_id: number; + @Column({ nullable: true, type: 'unsigned big int' }) + delete_order_id: number | null; - @Column({ nullable: true }) - created_by: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; @Column() created_at: Date; - @Column({ nullable: true }) - updated_by: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; @Column() updated_at: Date; - @CreateDateColumn() + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) archived_at: Date; } @@ -258,21 +298,24 @@ export class LicenseAllocationHistoryArchive { @Column() switch_from_type: string; - @Column({ nullable: true }) - deleted_at: Date; + @Column({ nullable: true, type: 'datetime' }) + deleted_at: Date | null; - @Column({ nullable: true }) - created_by: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; @Column() created_at: Date; - @Column({ nullable: true }) - updated_by: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; @Column() updated_at: Date; - @CreateDateColumn() + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) archived_at: Date; } diff --git a/dictation_server/src/repositories/licenses/licenses.repository.service.ts b/dictation_server/src/repositories/licenses/licenses.repository.service.ts index 67496bf..c43fa47 100644 --- a/dictation_server/src/repositories/licenses/licenses.repository.service.ts +++ b/dictation_server/src/repositories/licenses/licenses.repository.service.ts @@ -444,7 +444,7 @@ export class LicensesRepositoryService { const allocatableLicenses = await queryBuilder.getMany(); return allocatableLicenses.map((license) => ({ licenseId: license.id, - expiryDate: license.expiry_date, + expiryDate: license.expiry_date ?? undefined, })); } /** @@ -469,6 +469,13 @@ export class LicensesRepositoryService { }, }); + // ライセンスが存在しない場合はエラー + if (!targetLicense) { + throw new LicenseNotExistError( + `License not exist. licenseId: ${newLicenseId}`, + ); + } + // 期限切れの場合はエラー if (targetLicense.expiry_date) { const currentDay = new Date(); @@ -533,7 +540,7 @@ export class LicensesRepositoryService { }); let switchFromType = ''; - if (oldLicenseType) { + if (oldLicenseType && oldLicenseType.license) { switch (oldLicenseType.license.type) { case LICENSE_TYPE.CARD: switchFromType = SWITCH_FROM_TYPE.CARD; diff --git a/dictation_server/src/repositories/tasks/entity/task.entity.ts b/dictation_server/src/repositories/tasks/entity/task.entity.ts index 1252620..c734e1f 100644 --- a/dictation_server/src/repositories/tasks/entity/task.entity.ts +++ b/dictation_server/src/repositories/tasks/entity/task.entity.ts @@ -20,33 +20,33 @@ export class Task { job_number: string; @Column() account_id: number; - @Column({ nullable: true }) - is_job_number_enabled?: boolean; + @Column({ nullable: true, type: 'tinyint' }) + is_job_number_enabled: boolean | null; @Column() audio_file_id: number; @Column() status: string; - @Column({ nullable: true }) - typist_user_id?: number; + @Column({ nullable: true, type: 'unsigned big int' }) + typist_user_id: number | null; @Column() priority: string; - @Column({ nullable: true }) - template_file_id?: number; - @Column({ nullable: true }) - started_at?: Date; - @Column({ nullable: true }) - finished_at?: Date; + @Column({ nullable: true, type: 'unsigned big int' }) + template_file_id: number | null; + @Column({ nullable: true, type: 'datetime' }) + started_at: Date | null; + @Column({ nullable: true, type: 'datetime' }) + finished_at: Date | null; @Column({}) created_at: Date; @OneToOne(() => AudioFile, (audiofile) => audiofile.task) @JoinColumn({ name: 'audio_file_id' }) - file?: AudioFile; + file: AudioFile | null; @OneToMany(() => AudioOptionItem, (option) => option.task) - option_items?: AudioOptionItem[]; + option_items: AudioOptionItem[] | null; @OneToOne(() => User, (user) => user.id) @JoinColumn({ name: 'typist_user_id' }) - typist_user?: User; + typist_user: User | null; @ManyToOne(() => TemplateFile, (templateFile) => templateFile.id) @JoinColumn({ name: 'template_file_id' }) - template_file?: TemplateFile; + template_file: TemplateFile | null; } diff --git a/dictation_server/src/repositories/tasks/tasks.repository.service.ts b/dictation_server/src/repositories/tasks/tasks.repository.service.ts index 1e207ca..b6edf11 100644 --- a/dictation_server/src/repositories/tasks/tasks.repository.service.ts +++ b/dictation_server/src/repositories/tasks/tasks.repository.service.ts @@ -336,7 +336,7 @@ export class TasksRepositoryService { await taskRepo.update( { audio_file_id: audio_file_id }, { - typist_user: null, + typist_user_id: null, status: TASK_STATUS.UPLOADED, }, ); @@ -757,7 +757,7 @@ export class TasksRepositoryService { */ async changeCheckoutPermission( audio_file_id: number, - author_id: string, + author_id: string | undefined, account_id: number, roles: Roles[], assignees: Assignee[], @@ -844,8 +844,8 @@ export class TasksRepositoryService { (assignee) => { const checkoutPermission = new CheckoutPermission(); checkoutPermission.task_id = taskRecord.id; - checkoutPermission.user_id = assignee.typistUserId; - checkoutPermission.user_group_id = assignee.typistGroupId; + checkoutPermission.user_id = assignee.typistUserId ?? null; + checkoutPermission.user_group_id = assignee.typistGroupId ?? null; return checkoutPermission; }, ); diff --git a/dictation_server/src/repositories/template_files/entity/template_file.entity.ts b/dictation_server/src/repositories/template_files/entity/template_file.entity.ts index f5f9064..b49b998 100644 --- a/dictation_server/src/repositories/template_files/entity/template_file.entity.ts +++ b/dictation_server/src/repositories/template_files/entity/template_file.entity.ts @@ -18,14 +18,14 @@ export class TemplateFile { url: string; @Column() file_name: string; - @Column({ nullable: true }) - created_by?: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; @CreateDateColumn() created_at: Date; - @Column({ nullable: true }) - updated_by?: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; @UpdateDateColumn() updated_at: Date; @OneToMany(() => Task, (task) => task.template_file) - tasks?: Task[]; + tasks: Task[] | null; } diff --git a/dictation_server/src/repositories/terms/entity/term.entity.ts b/dictation_server/src/repositories/terms/entity/term.entity.ts new file mode 100644 index 0000000..7a2097b --- /dev/null +++ b/dictation_server/src/repositories/terms/entity/term.entity.ts @@ -0,0 +1,37 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity({ name: 'terms' }) +export class Term { + @PrimaryGeneratedColumn() + id: number; + + @Column() + document_type: string; + + @Column() + version: string; + + @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: 'varchar' }) + updated_by: string | null; + + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + updated_at: Date; +} diff --git a/dictation_server/src/repositories/terms/terms.repository.module.ts b/dictation_server/src/repositories/terms/terms.repository.module.ts new file mode 100644 index 0000000..f88c52c --- /dev/null +++ b/dictation_server/src/repositories/terms/terms.repository.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Term } from './entity/term.entity'; +import { TermsRepositoryService } from './terms.repository.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Term])], + providers: [TermsRepositoryService], + exports: [TermsRepositoryService], +}) +export class TermsRepositoryModule {} diff --git a/dictation_server/src/repositories/terms/terms.repository.service.ts b/dictation_server/src/repositories/terms/terms.repository.service.ts new file mode 100644 index 0000000..7c79f24 --- /dev/null +++ b/dictation_server/src/repositories/terms/terms.repository.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { TermsVersion } from '../../features/terms/types/types'; +import { Term } from './entity/term.entity'; +import { TERM_TYPE } from '../../constants'; +import { TermInfoNotFoundError } from '../users/errors/types'; + +@Injectable() +export class TermsRepositoryService { + constructor(private dataSource: DataSource) {} + + /* + * 利用規約の最新バージョンを取得する + * @returns Term[] + */ + async getLatestTermsInfo(): Promise { + return await this.dataSource.transaction(async (entityManager) => { + const termRepo = entityManager.getRepository(Term); + const latestEulaInfo = await termRepo.findOne({ + where: { + document_type: TERM_TYPE.EULA, + }, + order: { + id: 'DESC', + }, + }); + const latestDpaInfo = await termRepo.findOne({ + where: { + document_type: TERM_TYPE.DPA, + }, + order: { + id: 'DESC', + }, + }); + + if (!latestEulaInfo || !latestDpaInfo) { + throw new TermInfoNotFoundError( + `Terms info is not found. latestEulaInfo: ${latestEulaInfo}, latestDpaInfo: ${latestDpaInfo}`, + ); + } + return { + eulaVersion: latestEulaInfo.version, + dpaVersion: latestDpaInfo.version, + }; + }); + } +} diff --git a/dictation_server/src/repositories/user_groups/entity/user_group.entity.ts b/dictation_server/src/repositories/user_groups/entity/user_group.entity.ts index 42e14fa..2a1fbce 100644 --- a/dictation_server/src/repositories/user_groups/entity/user_group.entity.ts +++ b/dictation_server/src/repositories/user_groups/entity/user_group.entity.ts @@ -19,24 +19,30 @@ export class UserGroup { @Column() name: string; - @Column({ nullable: true }) - deleted_at?: Date; + @Column({ nullable: true, type: 'datetime' }) + deleted_at: Date | null; - @Column({ nullable: true }) - created_by?: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; - @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 - created_at?: Date; + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + created_at: Date | null; - @Column({ nullable: true }) - updated_by?: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; - @UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 - updated_at?: Date; + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + updated_at: Date | null; @OneToMany( () => UserGroupMember, (userGroupMember) => userGroupMember.userGroup, ) - userGroupMembers?: UserGroupMember[]; + userGroupMembers: UserGroupMember[] | null; } diff --git a/dictation_server/src/repositories/user_groups/entity/user_group_member.entity.ts b/dictation_server/src/repositories/user_groups/entity/user_group_member.entity.ts index 93f2484..1530102 100644 --- a/dictation_server/src/repositories/user_groups/entity/user_group_member.entity.ts +++ b/dictation_server/src/repositories/user_groups/entity/user_group_member.entity.ts @@ -21,26 +21,32 @@ export class UserGroupMember { @Column() user_id: number; - @Column({ nullable: true }) - deleted_at?: Date; + @Column({ nullable: true, type: 'datetime' }) + deleted_at: Date | null; - @Column({ nullable: true }) - created_by?: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; - @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 - created_at?: Date; + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + created_at: Date | null; - @Column({ nullable: true }) - updated_by?: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; - @UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 - updated_at?: Date; + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + updated_at: Date | null; @ManyToOne(() => User, (user) => user.id) @JoinColumn({ name: 'user_id' }) - user?: User; + user: User | null; @ManyToOne(() => UserGroup, (userGroup) => userGroup.id) @JoinColumn({ name: 'user_group_id' }) - userGroup?: UserGroup; + userGroup: UserGroup | null; } diff --git a/dictation_server/src/repositories/users/entity/user.entity.ts b/dictation_server/src/repositories/users/entity/user.entity.ts index e7399aa..34d0bca 100644 --- a/dictation_server/src/repositories/users/entity/user.entity.ts +++ b/dictation_server/src/repositories/users/entity/user.entity.ts @@ -28,14 +28,14 @@ export class User { @Column() role: string; - @Column({ nullable: true }) - author_id?: string; + @Column({ nullable: true, type: 'varchar' }) + author_id: string | null; - @Column({ nullable: true }) - accepted_eula_version?: string; + @Column({ nullable: true, type: 'varchar' }) + accepted_eula_version: string | null; - @Column({ nullable: true }) - accepted_dpa_version?: string; + @Column({ nullable: true, type: 'varchar' }) + accepted_dpa_version: string | null; @Column({ default: false }) email_verified: boolean; @@ -50,38 +50,46 @@ export class User { notification: boolean; @Column({ default: false }) - encryption?: boolean; + encryption: boolean; - @Column({ nullable: true }) - encryption_password?: string; + @Column({ nullable: true, type: 'varchar' }) + encryption_password: string | null; @Column({ default: false }) - prompt?: boolean; + prompt: boolean; - @Column({ nullable: true }) - deleted_at?: Date; + @Column({ nullable: true, type: 'datetime' }) + deleted_at: Date | null; - @Column({ nullable: true }) - created_by: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; - @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 created_at: Date; - @Column({ nullable: true }) - updated_by?: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; - @UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 updated_at: Date; - @ManyToOne(() => Account, (account) => account.user, { onDelete: 'CASCADE' }) // onDeleteはSQLite用設定値.本番用は別途migrationで設定 + @ManyToOne(() => Account, (account) => account.user, { + createForeignKeyConstraints: false, + }) // createForeignKeyConstraintsはSQLite用設定値.本番用は別途migrationで設定 @JoinColumn({ name: 'account_id' }) - account?: Account; + account: Account | null; @OneToOne(() => License, (license) => license.user) - license?: License; + license: License | null; @OneToMany(() => UserGroupMember, (userGroupMember) => userGroupMember.user) - userGroupMembers?: UserGroupMember[]; + userGroupMembers: UserGroupMember[] | null; } @Entity({ name: 'users_archive' }) @@ -98,14 +106,14 @@ export class UserArchive { @Column() role: string; - @Column({ nullable: true }) - author_id?: string; + @Column({ nullable: true, type: 'varchar' }) + author_id: string | null; - @Column({ nullable: true }) - accepted_eula_version?: string; + @Column({ nullable: true, type: 'varchar' }) + accepted_eula_version: string | null; - @Column({ nullable: true }) - accepted_dpa_version?: string; + @Column({ nullable: true, type: 'varchar' }) + accepted_dpa_version: string | null; @Column() email_verified: boolean; @@ -125,22 +133,25 @@ export class UserArchive { @Column() prompt: boolean; - @Column({ nullable: true }) - deleted_at?: Date; + @Column({ nullable: true, type: 'datetime' }) + deleted_at: Date | null; - @Column({ nullable: true }) - created_by: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; @Column() created_at: Date; - @Column({ nullable: true }) - updated_by?: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; @Column() updated_at: Date; - @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 archived_at: Date; } diff --git a/dictation_server/src/repositories/users/errors/types.ts b/dictation_server/src/repositories/users/errors/types.ts index 6f78b9a..faee0b1 100644 --- a/dictation_server/src/repositories/users/errors/types.ts +++ b/dictation_server/src/repositories/users/errors/types.ts @@ -8,3 +8,7 @@ export class AuthorIdAlreadyExistsError extends Error {} export class InvalidRoleChangeError extends Error {} // 暗号化パスワード不足エラー export class EncryptionPasswordNeedError extends Error {} +// 利用規約バージョン情報不在エラー +export class TermInfoNotFoundError extends Error {} +// 利用規約バージョンパラメータ不在エラー +export class UpdateTermsVersionNotSetError extends Error {} diff --git a/dictation_server/src/repositories/users/users.repository.service.ts b/dictation_server/src/repositories/users/users.repository.service.ts index cab67bd..0a9dcab 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -12,15 +12,22 @@ import { AuthorIdAlreadyExistsError, InvalidRoleChangeError, EncryptionPasswordNeedError, + TermInfoNotFoundError, + UpdateTermsVersionNotSetError, } from './errors/types'; import { LICENSE_ALLOCATED_STATUS, LICENSE_TYPE, + TERM_TYPE, + TIERS, TRIAL_LICENSE_ISSUE_NUM, USER_ROLES, } from '../../constants'; import { License } from '../licenses/entity/license.entity'; import { NewTrialLicenseExpirationDate } from '../../features/licenses/types/types'; +import { Term } from '../terms/entity/term.entity'; +import { TermsCheckInfo } from '../../features/auth/types/types'; +import { AccountNotFoundError } from '../accounts/errors/types'; @Injectable() export class UsersRepositoryService { @@ -114,7 +121,7 @@ export class UsersRepositoryService { return user; } - async findUserById(id: number): Promise { + async findUserById(id: number): Promise { const user = await this.dataSource.getRepository(User).findOne({ where: { id: id, @@ -122,7 +129,7 @@ export class UsersRepositoryService { }); if (!user) { - return undefined; + throw new UserNotFoundError(); } return user; } @@ -132,10 +139,7 @@ export class UsersRepositoryService { * @param user * @returns 存在する:true 存在しない:false */ - async existsAuthorId( - accountId: number, - authorId: string, - ): Promise { + async existsAuthorId(accountId: number, authorId: string): Promise { const user = await this.dataSource.getRepository(User).findOne({ where: [ { @@ -213,9 +217,9 @@ export class UsersRepositoryService { } // Author用項目を更新 - targetUser.author_id = authorId; - targetUser.encryption = encryption; - targetUser.prompt = prompt; + targetUser.author_id = authorId ?? null; + targetUser.encryption = encryption ?? false; + targetUser.prompt = prompt ?? false; } else { // ユーザーのロールがAuthor以外の場合はAuthor用項目はundefinedにする targetUser.author_id = null; @@ -309,7 +313,7 @@ export class UsersRepositoryService { for (let i = 0; i < TRIAL_LICENSE_ISSUE_NUM; i++) { const license = new License(); license.expiry_date = expiryDate; - license.account_id = targetUser.account.id; + license.account_id = targetUser.account_id; license.type = LICENSE_TYPE.TRIAL; license.status = LICENSE_ALLOCATED_STATUS.UNALLOCATED; licenses.push(license); @@ -334,7 +338,11 @@ export class UsersRepositoryService { const repo = entityManager.getRepository(User); const accountId = (await repo.findOne({ where: { external_id } })) - .account_id; + ?.account_id; + + if (!accountId) { + throw new AccountNotFoundError('Account is Not Found.'); + } const dbUsers = await this.dataSource.getRepository(User).find({ relations: { @@ -365,6 +373,11 @@ export class UsersRepositoryService { }, }); + // 運用上ユーザがいないことはあり得ないが、プログラム上発生しうるのでエラーとして処理 + if (!user) { + throw new UserNotFoundError(); + } + const typists = await repo.find({ where: { account_id: user.account_id, @@ -413,4 +426,111 @@ export class UsersRepositoryService { await usersRepo.delete({ id: userId }); }); } + + /** + * 同意済み利用規約バージョンの情報を取得する + * @param externalId + * @returns TermsCheckInfo + */ + async getAcceptedAndLatestVersion( + externalId: string, + ): Promise { + return await this.dataSource.transaction(async (entityManager) => { + const userRepo = entityManager.getRepository(User); + const user = await userRepo.findOne({ + where: { + external_id: externalId, + }, + relations: { + account: true, + }, + }); + + if (!user) { + throw new UserNotFoundError(); + } + if (!user.account) { + throw new AccountNotFoundError('Account is Not Found.'); + } + + const termRepo = entityManager.getRepository(Term); + const latestEulaInfo = await termRepo.findOne({ + where: { + document_type: TERM_TYPE.EULA, + }, + order: { + id: 'DESC', + }, + }); + const latestDpaInfo = await termRepo.findOne({ + where: { + document_type: TERM_TYPE.DPA, + }, + order: { + id: 'DESC', + }, + }); + + if (!latestEulaInfo || !latestDpaInfo) { + throw new TermInfoNotFoundError(`Terms info is not found.`); + } + + return { + tier: user.account.tier, + acceptedEulaVersion: user.accepted_eula_version ?? undefined, + acceptedDpaVersion: user.accepted_dpa_version ?? undefined, + latestEulaVersion: latestEulaInfo.version, + latestDpaVersion: latestDpaInfo.version, + }; + }); + } + + /** + * 同意済み利用規約のバージョンを更新する + * @param externalId + * @param eulaVersion + * @param dpaVersion + * @returns update + */ + async updateAcceptedTermsVersion( + externalId: string, + eulaVersion: string, + dpaVersion: string | undefined, + ): Promise { + await this.dataSource.transaction(async (entityManager) => { + const userRepo = entityManager.getRepository(User); + const user = await userRepo.findOne({ + where: { + external_id: externalId, + }, + relations: { + account: true, + }, + }); + + if (!user) { + throw new UserNotFoundError( + `User not found. externalId: ${externalId}`, + ); + } + + if (!user.account) { + throw new AccountNotFoundError('Account is Not Found.'); + } + + // パラメータが不在の場合はエラーを返却 + if (!eulaVersion) { + throw new UpdateTermsVersionNotSetError(`EULA version param not set.`); + } + if (user.account.tier !== TIERS.TIER5 && !dpaVersion) { + throw new UpdateTermsVersionNotSetError( + `DPA version param not set. User's tier: ${user.account.tier}`, + ); + } + + user.accepted_eula_version = eulaVersion; + user.accepted_dpa_version = dpaVersion ?? user.accepted_dpa_version; + await userRepo.update({ id: user.id }, user); + }); + } } diff --git a/dictation_server/src/repositories/workflows/entity/workflow.entity.ts b/dictation_server/src/repositories/workflows/entity/workflow.entity.ts index e3bac8e..6c51d96 100644 --- a/dictation_server/src/repositories/workflows/entity/workflow.entity.ts +++ b/dictation_server/src/repositories/workflows/entity/workflow.entity.ts @@ -24,36 +24,42 @@ export class Workflow { @Column() author_id: number; - @Column({ nullable: true }) - worktype_id?: number; + @Column({ nullable: true, type: 'unsigned big int' }) + worktype_id: number | null; - @Column({ nullable: true }) - template_id?: number; + @Column({ nullable: true, type: 'unsigned big int' }) + template_id: number | null; - @Column({ nullable: true }) - created_by: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; - @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 created_at: Date; - @Column({ nullable: true }) - updated_by?: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; - @UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 updated_at: Date; @ManyToOne(() => User, (user) => user.id) @JoinColumn({ name: 'author_id' }) - author?: User; + author: User | null; @ManyToOne(() => Worktype, (worktype) => worktype.id) @JoinColumn({ name: 'worktype_id' }) - worktype?: Worktype; + worktype: Worktype | null; @ManyToOne(() => TemplateFile, (templateFile) => templateFile.id) @JoinColumn({ name: 'template_id' }) - template?: TemplateFile; + template: TemplateFile | null; @OneToMany(() => WorkflowTypist, (workflowTypist) => workflowTypist.workflow) - workflowTypists?: WorkflowTypist[]; + workflowTypists: WorkflowTypist[] | null; } diff --git a/dictation_server/src/repositories/workflows/entity/workflow_typists.entity.ts b/dictation_server/src/repositories/workflows/entity/workflow_typists.entity.ts index b3d7139..f92d02e 100644 --- a/dictation_server/src/repositories/workflows/entity/workflow_typists.entity.ts +++ b/dictation_server/src/repositories/workflows/entity/workflow_typists.entity.ts @@ -19,33 +19,39 @@ export class WorkflowTypist { @Column() workflow_id: number; - @Column({ nullable: true }) - typist_id?: number; + @Column({ nullable: true, type: 'unsigned big int' }) + typist_id: number | null; - @Column({ nullable: true }) - typist_group_id?: number; + @Column({ nullable: true, type: 'unsigned big int' }) + typist_group_id: number | null; - @Column({ nullable: true }) - created_by: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; - @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 created_at: Date; - @Column({ nullable: true }) - updated_by?: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; - @UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 updated_at: Date; @ManyToOne(() => Workflow, (workflow) => workflow.id) @JoinColumn({ name: 'workflow_id' }) - workflow?: Workflow; + workflow: Workflow | null; @ManyToOne(() => User, (user) => user.id) @JoinColumn({ name: 'typist_id' }) - typist?: User; + typist: User | null; @ManyToOne(() => UserGroup, (userGroup) => userGroup.id) @JoinColumn({ name: 'typist_group_id' }) - typistGroup?: UserGroup; + typistGroup: UserGroup | null; } diff --git a/dictation_server/src/repositories/workflows/errors/types.ts b/dictation_server/src/repositories/workflows/errors/types.ts index 8680e30..633bc00 100644 --- a/dictation_server/src/repositories/workflows/errors/types.ts +++ b/dictation_server/src/repositories/workflows/errors/types.ts @@ -1,4 +1,4 @@ // AuthorIDとWorktypeIDのペア重複エラー export class AuthorIdAndWorktypeIdPairAlreadyExistsError extends Error {} -// WorkflowID存在エラー -export class WorkflowIdNotFoundError extends Error {} +// Workflow存在エラー +export class WorkflowNotFoundError extends Error {} diff --git a/dictation_server/src/repositories/workflows/workflows.repository.service.ts b/dictation_server/src/repositories/workflows/workflows.repository.service.ts index 0437d79..8a4ccf3 100644 --- a/dictation_server/src/repositories/workflows/workflows.repository.service.ts +++ b/dictation_server/src/repositories/workflows/workflows.repository.service.ts @@ -13,7 +13,7 @@ import { WorktypeIdNotFoundError } from '../worktypes/errors/types'; import { TemplateFileNotExistError } from '../template_files/errors/types'; import { AuthorIdAndWorktypeIdPairAlreadyExistsError, - WorkflowIdNotFoundError, + WorkflowNotFoundError, } from './errors/types'; @Injectable() @@ -61,9 +61,9 @@ export class WorkflowsRepositoryService { async createtWorkflows( accountId: number, authorId: number, + typists: WorkflowTypist[], worktypeId?: number | undefined, templateId?: number | undefined, - typists?: WorkflowTypist[], ): Promise { return await this.dataSource.transaction(async (entityManager) => { // authorの存在確認 @@ -178,9 +178,9 @@ export class WorkflowsRepositoryService { accountId: number, workflowId: number, authorId: number, + typists: WorkflowTypist[], worktypeId?: number | undefined, templateId?: number | undefined, - typists?: WorkflowTypist[], ): Promise { return await this.dataSource.transaction(async (entityManager) => { const workflowRepo = entityManager.getRepository(Workflow); @@ -190,7 +190,7 @@ export class WorkflowsRepositoryService { where: { account_id: accountId, id: workflowId }, }); if (!targetWorkflow) { - throw new WorkflowIdNotFoundError( + throw new WorkflowNotFoundError( `workflow not found. id: ${workflowId}`, ); } @@ -300,6 +300,32 @@ export class WorkflowsRepositoryService { }); } + /** + * ワークフローを削除する + * @param accountId + * @param workflowId + * @returns workflow + */ + async deleteWorkflow(accountId: number, workflowId: number): Promise { + return await this.dataSource.transaction(async (entityManager) => { + const workflowRepo = entityManager.getRepository(Workflow); + const workflowTypistsRepo = entityManager.getRepository(DbWorkflowTypist); + + // ワークフローの存在確認 + const workflow = await workflowRepo.findOne({ + where: { account_id: accountId, id: workflowId }, + }); + if (!workflow) { + throw new WorkflowNotFoundError( + `workflow not found. id: ${workflowId}`, + ); + } + + await workflowTypistsRepo.delete({ workflow_id: workflowId }); + await workflowRepo.delete(workflowId); + }); + } + /** * DBに保存するワークフローデータを作成する * @param accountId @@ -311,14 +337,14 @@ export class WorkflowsRepositoryService { private makeWorkflow( accountId: number, authorId: number, - worktypeId?: number | undefined, - templateId?: number | undefined, + worktypeId?: number, + templateId?: number, ): Workflow { const workflow = new Workflow(); workflow.account_id = accountId; workflow.author_id = authorId; - workflow.worktype_id = worktypeId; - workflow.template_id = templateId; + workflow.worktype_id = worktypeId ?? null; + workflow.template_id = templateId ?? null; return workflow; } @@ -332,13 +358,13 @@ export class WorkflowsRepositoryService { */ private makeWorkflowTypist( workflowId: number, - typistId: number, - typistGroupId: number, + typistId?: number, + typistGroupId?: number, ): DbWorkflowTypist { const workflowTypist = new DbWorkflowTypist(); workflowTypist.workflow_id = workflowId; - workflowTypist.typist_id = typistId; - workflowTypist.typist_group_id = typistGroupId; + workflowTypist.typist_id = typistId ?? null; + workflowTypist.typist_group_id = typistGroupId ?? null; return workflowTypist; } diff --git a/dictation_server/src/repositories/worktypes/entity/option_item.entity.ts b/dictation_server/src/repositories/worktypes/entity/option_item.entity.ts index fd0a14e..1aa911a 100644 --- a/dictation_server/src/repositories/worktypes/entity/option_item.entity.ts +++ b/dictation_server/src/repositories/worktypes/entity/option_item.entity.ts @@ -18,12 +18,18 @@ export class OptionItem { default_value_type: string; @Column() initial_value: string; - @Column({ nullable: true }) - created_by?: string; - @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 - created_at?: Date; - @Column({ nullable: true }) - updated_by?: string; - @UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 - updated_at?: Date; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + created_at: Date | null; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + updated_at: Date | null; } diff --git a/dictation_server/src/repositories/worktypes/entity/worktype.entity.ts b/dictation_server/src/repositories/worktypes/entity/worktype.entity.ts index 67d055a..854e634 100644 --- a/dictation_server/src/repositories/worktypes/entity/worktype.entity.ts +++ b/dictation_server/src/repositories/worktypes/entity/worktype.entity.ts @@ -18,21 +18,27 @@ export class Worktype { @Column() custom_worktype_id: string; - @Column({ nullable: true }) - description?: string; + @Column({ nullable: true, type: 'varchar' }) + description: string | null; - @Column({ nullable: true }) - deleted_at?: Date; + @Column({ nullable: true, type: 'datetime' }) + deleted_at: Date | null; - @Column({ nullable: true }) - created_by: string; + @Column({ nullable: true, type: 'datetime' }) + created_by: string | null; - @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + @CreateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 created_at: Date; - @Column({ nullable: true }) - updated_by?: string; + @Column({ nullable: true, type: 'datetime' }) + updated_by: string | null; - @UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + @UpdateDateColumn({ + default: () => "datetime('now', 'localtime')", + type: 'datetime', + }) // defaultはSQLite用設定値.本番用は別途migrationで設定 updated_at: Date; } diff --git a/dictation_server/src/repositories/worktypes/errors/types.ts b/dictation_server/src/repositories/worktypes/errors/types.ts index af772a1..6fe60c1 100644 --- a/dictation_server/src/repositories/worktypes/errors/types.ts +++ b/dictation_server/src/repositories/worktypes/errors/types.ts @@ -4,3 +4,5 @@ export class WorktypeIdAlreadyExistsError extends Error {} export class WorktypeIdMaxCountError extends Error {} // WorktypeID不在エラー export class WorktypeIdNotFoundError extends Error {} +// WorktypeID使用中エラー +export class WorktypeIdInUseError extends Error {} diff --git a/dictation_server/src/repositories/worktypes/worktypes.repository.service.ts b/dictation_server/src/repositories/worktypes/worktypes.repository.service.ts index 7ce50da..616f9e0 100644 --- a/dictation_server/src/repositories/worktypes/worktypes.repository.service.ts +++ b/dictation_server/src/repositories/worktypes/worktypes.repository.service.ts @@ -8,6 +8,7 @@ import { } from '../../constants'; import { WorktypeIdAlreadyExistsError, + WorktypeIdInUseError, WorktypeIdMaxCountError, WorktypeIdNotFoundError, } from './errors/types'; @@ -15,6 +16,7 @@ import { OptionItem } from './entity/option_item.entity'; import { PostWorktypeOptionItem } from '../../features/accounts/types/types'; import { AccountNotFoundError } from '../accounts/errors/types'; import { Account } from '../accounts/entity/account.entity'; +import { Workflow } from '../workflows/entity/workflow.entity'; @Injectable() export class WorktypesRepositoryService { @@ -151,11 +153,63 @@ export class WorktypesRepositoryService { // ワークタイプを更新 worktype.custom_worktype_id = worktypeId; - worktype.description = description; + worktype.description = description ?? null; await worktypeRepo.save(worktype); }); } + /** + * ワークタイプを削除する + * @param accountId + * @param id + * @returns worktype + */ + async deleteWorktype(accountId: number, id: number): Promise { + await this.dataSource.transaction(async (entityManager) => { + const worktypeRepo = entityManager.getRepository(Worktype); + + const worktype = await worktypeRepo.findOne({ + where: { account_id: accountId, id: id }, + }); + // ワークタイプが存在しない場合はエラー + if (!worktype) { + throw new WorktypeIdNotFoundError(`Worktype is not found. id: ${id}`); + } + + // アカウントのActiveWorktypeIDが削除対象のワークタイプIDの場合はActiveWorktypeIDをnullに更新 + const accountRepo = entityManager.getRepository(Account); + const account = await accountRepo.findOne({ + where: { id: accountId }, + }); + + if (account?.active_worktype_id === id) { + await accountRepo.update( + { id: accountId }, + { active_worktype_id: null }, + ); + } + + // ワークタイプがワークフローに紐づいている場合はエラー + const workflowRepo = entityManager.getRepository(Workflow); + const workflows = await workflowRepo.find({ + where: { account_id: accountId, worktype_id: id }, + }); + if (workflows.length > 0) { + const workflowIds = workflows.map((workflow) => workflow.id); + throw new WorktypeIdInUseError( + `Worktype is in use by workflow. worktype id: ${id}, workflow ids: [${workflowIds}]`, + ); + } + + // ワークタイプに紐づくオプションアイテムを削除 + const optionItemRepo = entityManager.getRepository(OptionItem); + await optionItemRepo.delete({ worktype_id: id }); + + // ワークタイプを削除 + await worktypeRepo.delete({ id: id }); + }); + } + /** * オプションアイテム一覧を取得する * @param accountId diff --git a/dictation_server/tsconfig.json b/dictation_server/tsconfig.json index 0c28902..8b9d137 100644 --- a/dictation_server/tsconfig.json +++ b/dictation_server/tsconfig.json @@ -12,7 +12,7 @@ "baseUrl": "./", "incremental": true, "skipLibCheck": true, - "strictNullChecks": false, + "strictNullChecks": true, "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false,