diff --git a/dictation_client/src/AppRouter.tsx b/dictation_client/src/AppRouter.tsx index 8cfed31..cf6e1d3 100644 --- a/dictation_client/src/AppRouter.tsx +++ b/dictation_client/src/AppRouter.tsx @@ -21,6 +21,7 @@ import TypistGroupSettingPage from "pages/TypistGroupSettingPage"; import WorktypeIdSettingPage from "pages/WorkTypeIdSettingPage"; import AccountPage from "pages/AccountPage"; import { TemplateFilePage } from "pages/TemplateFilePage"; +import { AccountDeleteSuccess } from "pages/AccountPage/accountDeleteSuccess"; const AppRouter: React.FC = () => ( @@ -81,6 +82,8 @@ const AppRouter: React.FC = () => ( path="/partners" element={} />} /> + } /> + } /> ); diff --git a/dictation_client/src/api/api.ts b/dictation_client/src/api/api.ts index eb4d4e2..36a1050 100644 --- a/dictation_client/src/api/api.ts +++ b/dictation_client/src/api/api.ts @@ -335,6 +335,25 @@ export interface AudioUploadLocationResponse { */ 'url': string; } +/** + * + * @export + * @interface Author + */ +export interface Author { + /** + * Authorユーザーの内部ID + * @type {number} + * @memberof Author + */ + 'id': number; + /** + * AuthorID + * @type {string} + * @memberof Author + */ + 'authorId': string; +} /** * * @export @@ -504,6 +523,37 @@ export interface CreateTypistGroupRequest { */ 'typistIds': Array; } +/** + * + * @export + * @interface CreateWorkflowsRequest + */ +export interface CreateWorkflowsRequest { + /** + * Authornの内部ID + * @type {number} + * @memberof CreateWorkflowsRequest + */ + 'authorId': number; + /** + * Worktypeの内部ID + * @type {number} + * @memberof CreateWorkflowsRequest + */ + 'worktypeId'?: number; + /** + * テンプレートの内部ID + * @type {number} + * @memberof CreateWorkflowsRequest + */ + 'templateId'?: number; + /** + * ルーティング候補のタイピストユーザー/タイピストグループ + * @type {Array} + * @memberof CreateWorkflowsRequest + */ + 'typists': Array; +} /** * * @export @@ -606,6 +656,19 @@ export interface GetAllocatableLicensesResponse { */ 'allocatableLicenses': Array; } +/** + * + * @export + * @interface GetAuthorsResponse + */ +export interface GetAuthorsResponse { + /** + * + * @type {Array} + * @memberof GetAuthorsResponse + */ + 'authors': Array; +} /** * * @export @@ -989,6 +1052,19 @@ export interface GetUsersResponse { */ 'users': Array; } +/** + * + * @export + * @interface GetWorkflowsResponse + */ +export interface GetWorkflowsResponse { + /** + * ワークフローの一覧 + * @type {Array} + * @memberof GetWorkflowsResponse + */ + 'workflows': Array; +} /** * * @export @@ -1963,6 +2039,100 @@ export interface User { */ 'licenseStatus': string; } +/** + * + * @export + * @interface Workflow + */ +export interface Workflow { + /** + * ワークフローの内部ID + * @type {number} + * @memberof Workflow + */ + 'id': number; + /** + * + * @type {Author} + * @memberof Workflow + */ + 'author': Author; + /** + * + * @type {WorkflowWorktype} + * @memberof Workflow + */ + 'worktype'?: WorkflowWorktype; + /** + * + * @type {WorkflowTemplate} + * @memberof Workflow + */ + 'template'?: WorkflowTemplate; + /** + * ルーティング候補のタイピストユーザー/タイピストグループ + * @type {Array} + * @memberof Workflow + */ + 'typists': Array; +} +/** + * + * @export + * @interface WorkflowTemplate + */ +export interface WorkflowTemplate { + /** + * テンプレートの内部ID + * @type {number} + * @memberof WorkflowTemplate + */ + 'id': number; + /** + * テンプレートのファイル名 + * @type {string} + * @memberof WorkflowTemplate + */ + 'fileName': string; +} +/** + * + * @export + * @interface WorkflowTypist + */ +export interface WorkflowTypist { + /** + * タイピストユーザーの内部ID + * @type {number} + * @memberof WorkflowTypist + */ + 'typistId'?: number; + /** + * タイピストグループの内部ID + * @type {number} + * @memberof WorkflowTypist + */ + 'typistGroupId'?: number; +} +/** + * + * @export + * @interface WorkflowWorktype + */ +export interface WorkflowWorktype { + /** + * Worktypeの内部ID + * @type {number} + * @memberof WorkflowWorktype + */ + 'id': number; + /** + * WorktypeID + * @type {string} + * @memberof WorkflowWorktype + */ + 'worktypeId': string; +} /** * * @export @@ -2271,6 +2441,40 @@ export const AccountsApiAxiosParamCreator = function (configuration?: Configurat options: localVarRequestOptions, }; }, + /** + * ログインしているユーザーのアカウント配下のAuthor一覧を取得します + * @summary + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAuthors: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/accounts/authors`; + // 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; + + // 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 @@ -2980,6 +3184,16 @@ export const AccountsApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAccount(deleteAccountRequest, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * ログインしているユーザーのアカウント配下のAuthor一覧を取得します + * @summary + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAuthors(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAuthors(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @summary @@ -3235,6 +3449,15 @@ export const AccountsApiFactory = function (configuration?: Configuration, baseP deleteAccount(deleteAccountRequest: DeleteAccountRequest, options?: any): AxiosPromise { return localVarFp.deleteAccount(deleteAccountRequest, options).then((request) => request(axios, basePath)); }, + /** + * ログインしているユーザーのアカウント配下のAuthor一覧を取得します + * @summary + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAuthors(options?: any): AxiosPromise { + return localVarFp.getAuthors(options).then((request) => request(axios, basePath)); + }, /** * * @summary @@ -3488,6 +3711,17 @@ export class AccountsApi extends BaseAPI { return AccountsApiFp(this.configuration).deleteAccount(deleteAccountRequest, options).then((request) => request(this.axios, this.basePath)); } + /** + * ログインしているユーザーのアカウント配下のAuthor一覧を取得します + * @summary + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AccountsApi + */ + public getAuthors(options?: AxiosRequestConfig) { + return AccountsApiFp(this.configuration).getAuthors(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @summary @@ -6481,3 +6715,179 @@ export class UsersApi extends BaseAPI { +/** + * WorkflowsApi - axios parameter creator + * @export + */ +export const WorkflowsApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * アカウント内にワークフローを新規作成します + * @summary + * @param {CreateWorkflowsRequest} createWorkflowsRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createWorkflows: async (createWorkflowsRequest: CreateWorkflowsRequest, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'createWorkflowsRequest' is not null or undefined + assertParamExists('createWorkflows', 'createWorkflowsRequest', createWorkflowsRequest) + const localVarPath = `/workflows`; + // 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(createWorkflowsRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * アカウント内のワークフローの一覧を取得します + * @summary + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getWorkflows: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/workflows`; + // 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; + + // 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, + }; + }, + } +}; + +/** + * WorkflowsApi - functional programming interface + * @export + */ +export const WorkflowsApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = WorkflowsApiAxiosParamCreator(configuration) + return { + /** + * アカウント内にワークフローを新規作成します + * @summary + * @param {CreateWorkflowsRequest} createWorkflowsRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createWorkflows(createWorkflowsRequest: CreateWorkflowsRequest, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createWorkflows(createWorkflowsRequest, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * アカウント内のワークフローの一覧を取得します + * @summary + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getWorkflows(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getWorkflows(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * WorkflowsApi - factory interface + * @export + */ +export const WorkflowsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = WorkflowsApiFp(configuration) + return { + /** + * アカウント内にワークフローを新規作成します + * @summary + * @param {CreateWorkflowsRequest} createWorkflowsRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createWorkflows(createWorkflowsRequest: CreateWorkflowsRequest, options?: any): AxiosPromise { + return localVarFp.createWorkflows(createWorkflowsRequest, options).then((request) => request(axios, basePath)); + }, + /** + * アカウント内のワークフローの一覧を取得します + * @summary + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getWorkflows(options?: any): AxiosPromise { + return localVarFp.getWorkflows(options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * WorkflowsApi - object-oriented interface + * @export + * @class WorkflowsApi + * @extends {BaseAPI} + */ +export class WorkflowsApi extends BaseAPI { + /** + * アカウント内にワークフローを新規作成します + * @summary + * @param {CreateWorkflowsRequest} createWorkflowsRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof WorkflowsApi + */ + public createWorkflows(createWorkflowsRequest: CreateWorkflowsRequest, options?: AxiosRequestConfig) { + return WorkflowsApiFp(this.configuration).createWorkflows(createWorkflowsRequest, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * アカウント内のワークフローの一覧を取得します + * @summary + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof WorkflowsApi + */ + public getWorkflows(options?: AxiosRequestConfig) { + return WorkflowsApiFp(this.configuration).getWorkflows(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 d42c776..e48a4ba 100644 --- a/dictation_client/src/app/store.ts +++ b/dictation_client/src/app/store.ts @@ -17,6 +17,7 @@ import typistGroup from "features/workflow/typistGroup/typistGroupSlice"; 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"; export const store = configureStore({ reducer: { @@ -38,6 +39,7 @@ export const store = configureStore({ worktype, account, template, + workflow, }, }); diff --git a/dictation_client/src/assets/images/download.svg b/dictation_client/src/assets/images/download.svg new file mode 100644 index 0000000..504fce3 --- /dev/null +++ b/dictation_client/src/assets/images/download.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/dictation_client/src/assets/images/exit.svg b/dictation_client/src/assets/images/exit.svg new file mode 100644 index 0000000..242dd28 --- /dev/null +++ b/dictation_client/src/assets/images/exit.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/dictation_client/src/assets/images/group_setting.svg b/dictation_client/src/assets/images/group_setting.svg new file mode 100644 index 0000000..b86f803 --- /dev/null +++ b/dictation_client/src/assets/images/group_setting.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/dictation_client/src/assets/images/logout.svg b/dictation_client/src/assets/images/logout.svg new file mode 100644 index 0000000..7cc7e86 --- /dev/null +++ b/dictation_client/src/assets/images/logout.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/dictation_client/src/assets/images/rule_add.svg b/dictation_client/src/assets/images/rule_add.svg new file mode 100644 index 0000000..4d4d6e6 --- /dev/null +++ b/dictation_client/src/assets/images/rule_add.svg @@ -0,0 +1,12 @@ + + + + + + diff --git a/dictation_client/src/assets/images/template_setting.svg b/dictation_client/src/assets/images/template_setting.svg new file mode 100644 index 0000000..54c8955 --- /dev/null +++ b/dictation_client/src/assets/images/template_setting.svg @@ -0,0 +1,14 @@ + + + + + + diff --git a/dictation_client/src/assets/images/worktype_setting.svg b/dictation_client/src/assets/images/worktype_setting.svg new file mode 100644 index 0000000..ad5cd24 --- /dev/null +++ b/dictation_client/src/assets/images/worktype_setting.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/dictation_client/src/common/errors/code.ts b/dictation_client/src/common/errors/code.ts index 7d1b632..41368a5 100644 --- a/dictation_client/src/common/errors/code.ts +++ b/dictation_client/src/common/errors/code.ts @@ -55,4 +55,5 @@ export const errorCodes = [ "E011001", // ワークタイプ重複エラー "E011002", // ワークタイプ登録上限超過エラー "E011003", // ワークタイプ不在エラー + "E013001", // ワークフローのAuthorIDとWorktypeIDのペア重複エラー ] as const; diff --git a/dictation_client/src/features/dictation/dictationSlice.ts b/dictation_client/src/features/dictation/dictationSlice.ts index f90c5ed..fe97dff 100644 --- a/dictation_client/src/features/dictation/dictationSlice.ts +++ b/dictation_client/src/features/dictation/dictationSlice.ts @@ -117,6 +117,10 @@ export const dictationSlice = createSlice({ state.apps.direction = action.payload.direction; state.apps.paramName = action.payload.paramName; }); + // 画面起動時にgetSortColumnAsyncがrejectedするとisLoadingがtrueのままになるため + builder.addCase(getSortColumnAsync.rejected, (state) => { + state.apps.isLoading = false; + }); builder.addCase(listTypistsAsync.fulfilled, (state, action) => { state.domain.typists = action.payload.typists; }); diff --git a/dictation_client/src/features/user/operations.ts b/dictation_client/src/features/user/operations.ts index 225fbfe..bd3d750 100644 --- a/dictation_client/src/features/user/operations.ts +++ b/dictation_client/src/features/user/operations.ts @@ -41,6 +41,13 @@ export const listUsersAsync = createAsyncThunk< // e ⇒ errorObjectに変換 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/workflow/index.ts b/dictation_client/src/features/workflow/index.ts new file mode 100644 index 0000000..460a995 --- /dev/null +++ b/dictation_client/src/features/workflow/index.ts @@ -0,0 +1,4 @@ +export * from "./workflowSlice"; +export * from "./state"; +export * from "./selectors"; +export * from "./operations"; diff --git a/dictation_client/src/features/workflow/operations.ts b/dictation_client/src/features/workflow/operations.ts new file mode 100644 index 0000000..76e84a5 --- /dev/null +++ b/dictation_client/src/features/workflow/operations.ts @@ -0,0 +1,213 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { + AccountsApi, + Author, + Configuration, + GetWorkflowsResponse, + TemplateFile, + TemplatesApi, + Typist, + TypistGroup, + WorkflowTypist, + WorkflowsApi, + Worktype, +} from "api"; +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, + void, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("workflow/listWorkflowAsync", 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); + + try { + const { data } = await workflowsApi.getWorkflows({ + headers: { authorization: `Bearer ${accessToken}` }, + }); + + return 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 createWorkflowAsync = createAsyncThunk< + { + /* Empty Object */ + }, + void, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("workflow/createWorkflowAsync", 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 { selectedAssignees, authorId, templateId, worktypeId } = + state.workflow.apps; + + try { + if (authorId === undefined) { + throw new Error("authorId is not found"); + } + // 選択されたタイピストを取得し、リクエスト用の型に変換する + const typists = selectedAssignees.map( + (item): WorkflowTypist => ({ + typistId: item.typistUserId, + typistGroupId: item.typistGroupId, + }) + ); + await workflowsApi.createWorkflows( + { + 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; + // AuthorIDとWorktypeIDが一致するものが既に存在する場合 + if (code === "E013001") { + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID( + "workflowPage.message.workflowConflictError" + ), + }) + ); + return thunkApi.rejectWithValue({ error }); + } + // パラメータが存在しない場合 + if (statusCode === 400) { + 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[]; + typists: Typist[]; + typistGroups: TypistGroup[]; + templates: TemplateFile[]; + worktypes: Worktype[]; + }, + void, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("workflow/getworkflowRelationsAsync", async (args, thunkApi) => { + // 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); + const templatesApi = new TemplatesApi(config); + + try { + const { authors } = ( + await accountsApi.getAuthors({ + headers: { authorization: `Bearer ${accessToken}` }, + }) + ).data; + const { typists } = ( + await accountsApi.getTypists({ + headers: { authorization: `Bearer ${accessToken}` }, + }) + ).data; + const { typistGroups } = ( + await accountsApi.getTypistGroups({ + headers: { authorization: `Bearer ${accessToken}` }, + }) + ).data; + const { templates } = ( + await templatesApi.getTemplates({ + headers: { authorization: `Bearer ${accessToken}` }, + }) + ).data; + const { worktypes } = ( + await accountsApi.getWorktypes({ + headers: { authorization: `Bearer ${accessToken}` }, + }) + ).data; + + return { + authors, + typists, + typistGroups, + templates, + worktypes, + }; + } catch (e) { + // e ⇒ errorObjectに変換" + 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/workflow/selectors.ts b/dictation_client/src/features/workflow/selectors.ts new file mode 100644 index 0000000..a71ba4a --- /dev/null +++ b/dictation_client/src/features/workflow/selectors.ts @@ -0,0 +1,52 @@ +import { Assignee } from "api"; +import { RootState } from "app/store"; + +export const selectWorkflows = (state: RootState) => + state.workflow.domain.workflows; + +export const selectIsLoading = (state: RootState) => + state.workflow.apps.isLoading; + +export const selectWorkflowRelations = (state: RootState) => + state.workflow.domain.workflowRelations; + +export const selectWorkflowAssinee = (state: RootState) => { + // 選択されたassigneeを取得 + const { selectedAssignees } = state.workflow.apps; + // すべてのassigneeを取得 + const assignees = state.workflow.domain.workflowRelations?.assignees ?? []; + // assigneeが選択されているかどうかを判定する + const isAssigneeSelected = (assignee: Assignee) => + selectedAssignees.some( + (sa) => + sa.typistUserId === assignee.typistUserId && + sa.typistGroupId === assignee.typistGroupId + ); + // 未選択のassigneeを取得する + const poolAssignees = assignees.filter( + (assignee) => !isAssigneeSelected(assignee) + ); + // selectedAssigneesとpoolAssigneesをtypistNameでソートして返す + return { + selectedAssignees: [...selectedAssignees].sort((a, b) => + a.typistName.localeCompare(b.typistName) + ), + poolAssignees: poolAssignees.sort((a, b) => + a.typistName.localeCompare(b.typistName) + ), + }; +}; +export const selectIsAddLoading = (state: RootState) => + state.workflow.apps.isAddLoading; + +export const selectWorkflowError = (state: RootState) => { + // authorIdがundefinedの場合はエラーを返す + const hasAuthorIdEmptyError = state.workflow.apps.authorId === undefined; + // workflowAssineeのselectedが空の場合はエラーを返す + const hasSelectedWorkflowAssineeEmptyError = + state.workflow.apps.selectedAssignees.length === 0; + return { + hasAuthorIdEmptyError, + hasSelectedWorkflowAssineeEmptyError, + }; +}; diff --git a/dictation_client/src/features/workflow/state.ts b/dictation_client/src/features/workflow/state.ts new file mode 100644 index 0000000..e99a186 --- /dev/null +++ b/dictation_client/src/features/workflow/state.ts @@ -0,0 +1,27 @@ +import { Assignee, Author, TemplateFile, Workflow, Worktype } from "api"; + +export interface WorkflowState { + apps: Apps; + domain: Domain; +} + +export interface Apps { + isLoading: boolean; + isAddLoading: boolean; + selectedAssignees: Assignee[]; + authorId?: number; + worktypeId?: number; + templateId?: number; +} + +export interface Domain { + workflows?: Workflow[]; + workflowRelations?: WorkflowRelations; +} + +export interface WorkflowRelations { + authors: Author[]; + assignees: Assignee[]; + templates: TemplateFile[]; + worktypes: Worktype[]; +} diff --git a/dictation_client/src/features/workflow/workflowSlice.ts b/dictation_client/src/features/workflow/workflowSlice.ts new file mode 100644 index 0000000..bb6aa9f --- /dev/null +++ b/dictation_client/src/features/workflow/workflowSlice.ts @@ -0,0 +1,142 @@ +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { Assignee } from "api"; +import { + createWorkflowAsync, + getworkflowRelationsAsync, + listWorkflowAsync, +} from "./operations"; +import { WorkflowState } from "./state"; + +const initialState: WorkflowState = { + apps: { + isLoading: false, + isAddLoading: false, + selectedAssignees: [], + }, + domain: {}, +}; + +export const workflowSlice = createSlice({ + name: "workflow", + initialState, + reducers: { + clearWorkflow: (state) => { + state.apps.selectedAssignees = []; + state.apps.authorId = undefined; + state.apps.worktypeId = undefined; + state.apps.templateId = undefined; + state.domain.workflowRelations = undefined; + }, + addAssignee: (state, action: PayloadAction<{ assignee: Assignee }>) => { + const { assignee } = action.payload; + const { selectedAssignees } = state.apps; + + // assigneeがselectedAssigneesに存在するか確認する + const isDuplicate = selectedAssignees.some( + (x) => + x.typistUserId === assignee.typistUserId && + x.typistGroupId === assignee.typistGroupId + ); + + // 重複していなければ追加する + if (!isDuplicate) { + const newSelectedAssignees = [...selectedAssignees, assignee]; + // stateに保存する + state.apps.selectedAssignees = newSelectedAssignees; + } + }, + removeAssignee: (state, action: PayloadAction<{ assignee: Assignee }>) => { + const { assignee } = action.payload; + const { selectedAssignees } = state.apps; + // selectedAssigneeの要素からassigneeを削除する + state.apps.selectedAssignees = selectedAssignees.filter( + (x) => + x.typistUserId !== assignee.typistUserId || + x.typistGroupId !== assignee.typistGroupId + ); + }, + changeAuthor: (state, action: PayloadAction<{ authorId: number }>) => { + const { authorId } = action.payload; + state.apps.authorId = authorId; + }, + changeWorktype: (state, action: PayloadAction<{ worktypeId?: number }>) => { + const { worktypeId } = action.payload; + state.apps.worktypeId = worktypeId; + }, + changeTemplate: (state, action: PayloadAction<{ templateId?: number }>) => { + const { templateId } = action.payload; + state.apps.templateId = templateId; + }, + }, + extraReducers: (builder) => { + builder.addCase(listWorkflowAsync.pending, (state) => { + state.apps.isLoading = true; + }); + builder.addCase(listWorkflowAsync.fulfilled, (state, action) => { + const { workflows } = action.payload; + + state.domain.workflows = workflows; + state.apps.isLoading = false; + }); + builder.addCase(listWorkflowAsync.rejected, (state) => { + state.apps.isLoading = false; + }); + builder.addCase(getworkflowRelationsAsync.pending, (state) => { + state.apps.isAddLoading = true; + }); + builder.addCase(getworkflowRelationsAsync.fulfilled, (state, action) => { + const { authors, typistGroups, typists, templates, worktypes } = + action.payload; + + // 取得したtypistsとtypistGroupsを型変換 + const assineeTypists = typists.map( + (typist): Assignee => ({ + typistUserId: typist.id, + typistGroupId: undefined, + typistName: typist.name, + }) + ); + const assineeTypistGroups = typistGroups.map( + (typistGroup): Assignee => ({ + typistUserId: undefined, + typistGroupId: typistGroup.id, + typistName: typistGroup.name, + }) + ); + // 取得したtypistsとtypistGroupsを結合 + const assinees = [...assineeTypists, ...assineeTypistGroups]; + // storeに保存 + state.domain.workflowRelations = { + authors, + assignees: assinees, + templates, + worktypes, + }; + + state.apps.isAddLoading = false; + }); + builder.addCase(getworkflowRelationsAsync.rejected, (state) => { + state.apps.isAddLoading = false; + }); + builder.addCase(createWorkflowAsync.pending, (state) => { + state.apps.isAddLoading = true; + }); + builder.addCase(createWorkflowAsync.fulfilled, (state) => { + state.apps.isAddLoading = false; + }); + builder.addCase(createWorkflowAsync.rejected, (state) => { + state.apps.isAddLoading = false; + }); + }, +}); + +export const { + addAssignee, + removeAssignee, + changeAuthor, + changeWorktype, + changeTemplate, + clearWorkflow, +} = workflowSlice.actions; + +export default workflowSlice.reducer; diff --git a/dictation_client/src/pages/AccountPage/accountDeleteSuccess.tsx b/dictation_client/src/pages/AccountPage/accountDeleteSuccess.tsx new file mode 100644 index 0000000..790af01 --- /dev/null +++ b/dictation_client/src/pages/AccountPage/accountDeleteSuccess.tsx @@ -0,0 +1,54 @@ +import React, { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { getTranslationID } from "translation"; +import Header from "components/header"; +import Footer from "components/footer"; +import styles from "styles/app.module.scss"; +import { Link } from "react-router-dom"; +import { clearToken } from "features/auth"; +import { AppDispatch } from "app/store"; +import { useDispatch } from "react-redux"; + +export const AccountDeleteSuccess: React.FC = (): JSX.Element => { + const { t } = useTranslation(); + const dispatch: AppDispatch = useDispatch(); + + // アカウントの削除完了時に遷移するページなので、遷移と同時にログアウト状態とする + useEffect(() => { + dispatch(clearToken()); + }, [dispatch]); + + return ( + + + + + + + {t(getTranslationID("accountDeleteSuccess.label.title"))} + + + + + + + + {t(getTranslationID("accountDeleteSuccess.label.message"))} + + + + {t( + getTranslationID( + "accountDeleteSuccess.label.backToTopPageLink" + ) + )} + + + + + + + + + ); +}; diff --git a/dictation_client/src/pages/AccountPage/deleteAccountPopup.tsx b/dictation_client/src/pages/AccountPage/deleteAccountPopup.tsx index 0d94684..2fcb13c 100644 --- a/dictation_client/src/pages/AccountPage/deleteAccountPopup.tsx +++ b/dictation_client/src/pages/AccountPage/deleteAccountPopup.tsx @@ -2,18 +2,19 @@ import React, { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { AppDispatch } from "app/store"; import { useDispatch, useSelector } from "react-redux"; +import { selectAccountInfo, selectIsLoading } from "features/account"; +import { deleteAccountAsync } from "features/account/operations"; +import { useMsal } from "@azure/msal-react"; import styles from "../../styles/app.module.scss"; import { getTranslationID } from "../../translation"; import close from "../../assets/images/close.svg"; import deleteButton from "../../assets/images/delete.svg"; -import { selectAccountInfo, selectIsLoading } from "features/account"; -import { deleteAccountAsync } from "features/account/operations"; -interface deleteAccountPopupProps { +interface DeleteAccountPopupProps { onClose: () => void; } -export const DeleteAccountPopup: React.FC = ( +export const DeleteAccountPopup: React.FC = ( props ) => { const { onClose } = props; @@ -21,6 +22,8 @@ export const DeleteAccountPopup: React.FC = ( const dispatch: AppDispatch = useDispatch(); const isLoading = useSelector(selectIsLoading); + const { instance } = useMsal(); + const accountInfo = useSelector(selectAccountInfo); // ポップアップを閉じる処理 @@ -31,13 +34,21 @@ export const DeleteAccountPopup: React.FC = ( onClose(); }, [isLoading, onClose]); - const onDeleteAccount = useCallback(() => { - dispatch( + const onDeleteAccount = useCallback(async () => { + const { meta } = await dispatch( deleteAccountAsync({ accountId: accountInfo.account.accountId, }) ); - }, [dispatch]); + + // 削除成功後にAccountDeleteSuccess ページに遷移 + if (meta.requestStatus === "fulfilled") { + instance.logoutRedirect({ + postLogoutRedirectUri: "/accountDeleteSuccess", + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [instance]); // HTML return ( @@ -74,7 +85,7 @@ export const DeleteAccountPopup: React.FC = ( = ( ))} + {!isLoading && licenseOrderHistory.length === 0 && ( + + {t(getTranslationID("common.message.listEmpty"))} + + )} {isLoading && ( { ))} + {!isLoading && childrenPartnerLicensesInfo.length === 0 && ( + + {t(getTranslationID("common.message.listEmpty"))} + + )} {/* pagenation */} diff --git a/dictation_client/src/pages/PartnerPage/index.tsx b/dictation_client/src/pages/PartnerPage/index.tsx index 38adaca..0de9559 100644 --- a/dictation_client/src/pages/PartnerPage/index.tsx +++ b/dictation_client/src/pages/PartnerPage/index.tsx @@ -189,6 +189,16 @@ const PartnerPage: React.FC = (): JSX.Element => { ))} + {!isLoading && partnerInfo.partners.length === 0 && ( + + {t(getTranslationID("common.message.listEmpty"))} + + )} {/** pagenation */} diff --git a/dictation_client/src/pages/TemplateFilePage/index.tsx b/dictation_client/src/pages/TemplateFilePage/index.tsx index 54ab353..117ba01 100644 --- a/dictation_client/src/pages/TemplateFilePage/index.tsx +++ b/dictation_client/src/pages/TemplateFilePage/index.tsx @@ -101,16 +101,17 @@ export const TemplateFilePage: React.FC = () => { ))} - {!isLoading && templates?.length === 0 && ( - - {t(getTranslationID("common.message.listEmpty"))} - - )} + {!isLoading && + (templates === undefined || templates.length === 0) && ( + + {t(getTranslationID("common.message.listEmpty"))} + + )} {isLoading && ( { })} + {!isLoading && users.length === 0 && ( + + {t(getTranslationID("common.message.listEmpty"))} + + )} {isLoading && ( { ))} - {!isLoading && worktypes?.length === 0 && ( - - {t(getTranslationID("common.message.listEmpty"))} - - )} + {!isLoading && + (worktypes === undefined || worktypes.length === 0) && ( + + {t(getTranslationID("common.message.listEmpty"))} + + )} {isLoading && ( void; +} + +export const AddWorkflowPopup: React.FC = ( + props +): JSX.Element => { + const { onClose } = props; + const dispatch: AppDispatch = useDispatch(); + const [t] = useTranslation(); + // 保存ボタンを押したかどうか + const [isPushAddButton, setIsPushAddButton] = useState(false); + + const workflowRelations = useSelector(selectWorkflowRelations); + const { poolAssignees, selectedAssignees } = useSelector( + selectWorkflowAssinee + ); + const isLoading = useSelector(selectIsAddLoading); + const { hasAuthorIdEmptyError, hasSelectedWorkflowAssineeEmptyError } = + useSelector(selectWorkflowError); + useEffect(() => { + dispatch(getworkflowRelationsAsync()); + // ポップアップのアンマウント時に初期化を行う + return () => { + dispatch(clearWorkflow()); + setIsPushAddButton(false); + }; + }, [dispatch]); + + 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 handleAdd = useCallback(async () => { + setIsPushAddButton(true); + // エラーチェック + if (hasAuthorIdEmptyError || hasSelectedWorkflowAssineeEmptyError) { + return; + } + const { meta } = await dispatch(createWorkflowAsync()); + if (meta.requestStatus === "fulfilled") { + onClose(); + dispatch(listWorkflowAsync()); + } + }, [ + dispatch, + hasAuthorIdEmptyError, + hasSelectedWorkflowAssineeEmptyError, + onClose, + ]); + + return ( + + + + {t(getTranslationID("workflowPage.label.addRoutingRule"))} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */} + + + + + + {t(getTranslationID("workflowPage.label.authorID"))} + + { + changeAuthorId(e.target.value); + }} + > + + {`-- ${t( + getTranslationID("workflowPage.label.selectAuthor") + )} --`} + + {workflowRelations?.authors.map((author) => ( + + {author.authorId} + + ))} + + {isPushAddButton && hasAuthorIdEmptyError && ( + + {t(getTranslationID("workflowPage.message.inputEmptyError"))} + + )} + + + {t(getTranslationID("workflowPage.label.worktypeOptional"))} + + + { + changeWorktypeId(e.target.value); + }} + > + + {`-- ${t( + getTranslationID("workflowPage.label.selectWorktypeId") + )} --`} + + + {`-- ${t(getTranslationID("common.label.notSelected"))} --`} + + {workflowRelations?.worktypes.map((worktype) => ( + + {worktype.worktypeId} + + ))} + + + + {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 })); + }} + /> + + {x.typistName} + + + ); + })} + + + + + {t(getTranslationID("workflowPage.label.pool"))} + + {poolAssignees?.map((x) => { + const key = `${x.typistName}_${ + x.typistUserId ?? x.typistGroupId + }`; + return ( + + dispatch(addAssignee({ assignee: x }))} + /> + + {x.typistName} + + + ); + })} + + {isPushAddButton && hasSelectedWorkflowAssineeEmptyError && ( + + {t( + getTranslationID( + "workflowPage.message.selectedTypistEmptyError" + ) + )} + + )} + + + {t(getTranslationID("workflowPage.label.templateOptional"))} + + + { + changeTemplateId(e.target.value); + }} + > + + {`-- ${t( + getTranslationID("workflowPage.label.selectTemplate") + )} --`} + + + {`-- ${t(getTranslationID("common.label.notSelected"))} --`} + + {workflowRelations?.templates.map((template) => ( + + {template.name} + + ))} + + + + + {isLoading && ( + + )} + + + + + + ); +}; diff --git a/dictation_client/src/pages/WorkflowPage/index.tsx b/dictation_client/src/pages/WorkflowPage/index.tsx index 0df685b..0ea3e51 100644 --- a/dictation_client/src/pages/WorkflowPage/index.tsx +++ b/dictation_client/src/pages/WorkflowPage/index.tsx @@ -1,34 +1,195 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import Header from "components/header"; import Footer from "components/footer"; import styles from "styles/app.module.scss"; import { UpdateTokenTimer } from "components/auth/updateTokenTimer"; +import ruleAddImg from "assets/images/rule_add.svg"; +import templateSettingImg from "assets/images/template_setting.svg"; +import worktypeSettingImg from "assets/images/worktype_setting.svg"; +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 progress_activit from "assets/images/progress_activit.svg"; +import { getTranslationID } from "translation"; +import { AddWorkflowPopup } from "./addworkflowPopup"; -const WorkflowPage: React.FC = (): JSX.Element => ( - - - - - - - - Transcriptionist Group Setting - - - - - Worktype ID Setting - - - - - Template File - - +const WorkflowPage: React.FC = (): JSX.Element => { + const dispatch: AppDispatch = useDispatch(); + const [t] = useTranslation(); + // 追加Popupの表示制御 + const [isShowAddPopup, setIsShowAddPopup] = useState(false); + const workflows = useSelector(selectWorkflows); + const isLoading = useSelector(selectIsLoading); + + useEffect(() => { + dispatch(listWorkflowAsync()); + }, [dispatch]); + return ( + <> + {isShowAddPopup && ( + { + setIsShowAddPopup(false); + }} + /> + )} + + + + + + + + {t(getTranslationID("workflowPage.label.title"))} + + + + + + + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} + { + setIsShowAddPopup(true); + }} + > + + {t(getTranslationID("workflowPage.label.addRoutingRule"))} + + + + + + {t( + getTranslationID("workflowPage.label.templateSetting") + )} + + + + + + {t( + getTranslationID("workflowPage.label.worktypeIdSetting") + )} + + + + + + {t( + getTranslationID( + "workflowPage.label.typistGroupSetting" + ) + )} + + + + + + {/** empty th */} + + {t(getTranslationID("workflowPage.label.authorID"))} + + + {t(getTranslationID("workflowPage.label.worktype"))} + + + {t( + getTranslationID("workflowPage.label.transcriptionist") + )} + + + {t(getTranslationID("workflowPage.label.template"))} + + + {workflows?.map((workflow) => ( + + + + + + {t( + getTranslationID("workflowPage.label.editRule") + )} + + + + + {t(getTranslationID("common.label.delete"))} + + + + + {workflow.author.authorId} + {workflow.worktype?.worktypeId ?? "-"} + + + {workflow.typists.map((typist, i) => ( + <> + {typist.typistName} + {i !== workflow.typists.length - 1 && } + > + ))} + + {workflow.template?.fileName ?? "-"} + + ))} + + {!isLoading && + (workflows === undefined || workflows.length === 0) && ( + + {t(getTranslationID("common.message.listEmpty"))} + + )} + {isLoading && ( + + )} + + + + + - - - -); + > + ); +}; export default WorkflowPage; diff --git a/dictation_client/src/translation/de.json b/dictation_client/src/translation/de.json index e88aa3f..35d433e 100644 --- a/dictation_client/src/translation/de.json +++ b/dictation_client/src/translation/de.json @@ -356,7 +356,29 @@ }, "workflowPage": { "label": { - "title": "Arbeitsablauf" + "title": "Arbeitsablauf", + "addRoutingRule": "(de)Add Routing Rule", + "templateSetting": "(de)Template Setting", + "worktypeIdSetting": "(de)WorktypeID Setting", + "typistGroupSetting": "(de)Transcriptionist Group Setting", + "authorID": "Autoren-ID", + "worktype": "Aufgabentypkennung", + "worktypeOptional": "(de)Worktype ID (Optional)", + "transcriptionist": "Transkriptionist", + "template": "(de)Template", + "templateOptional": "(de)Template (Optional)", + "editRule": "(de)Edit Rule", + "selected": "Ausgewählter transkriptionist", + "pool": "Transkriptionsliste", + "selectAuthor": "(de)Select Author ID", + "selectWorktypeId": "(de)Select Worktype ID", + "selectTemplate": "(de)Select Template" + }, + "message": { + "selectedTypistEmptyError": "(de)Transcriptionist,TranscriptionistGroupがいないルーティングルールは保存できません。ルーティング先を1つ以上選択してください。", + "workflowConflictError": "(de)指定したAuthorIDとWorktypeIDの組み合わせで既にルーティングルールが登録されています。他の組み合わせで登録してください。", + "inputEmptyError": "Pflichtfeld", + "saveFailedError": "(de)ルーティングルールの保存に失敗しました。画面を更新し、再度実行してください" } }, "typistGroupSetting": { @@ -468,5 +490,12 @@ "deleteButton": "(de)Delete account", "cancelButton": "(de)Cancel" } + }, + "accountDeleteSuccess": { + "label": { + "title": "(de)Account Delete Success", + "message": "(de)Your account has been deleted. Thank you for using our services.", + "backToTopPageLink": "(de)Back to TOP Page" + } } } diff --git a/dictation_client/src/translation/en.json b/dictation_client/src/translation/en.json index d6057b7..6e08976 100644 --- a/dictation_client/src/translation/en.json +++ b/dictation_client/src/translation/en.json @@ -356,7 +356,29 @@ }, "workflowPage": { "label": { - "title": "Workflow" + "title": "Workflow", + "addRoutingRule": "Add Routing Rule", + "templateSetting": "Template Setting", + "worktypeIdSetting": "WorktypeID Setting", + "typistGroupSetting": "Transcriptionist Group Setting", + "authorID": "Author ID", + "worktype": "Worktype ID", + "worktypeOptional": "Worktype ID (Optional)", + "transcriptionist": "Transcriptionist", + "template": "Template", + "templateOptional": "Template (Optional)", + "editRule": "Edit Rule", + "selected": "Selected Transcriptionist", + "pool": "Transcription List", + "selectAuthor": "Select Author ID", + "selectWorktypeId": "Select Worktype ID", + "selectTemplate": "Select Template" + }, + "message": { + "selectedTypistEmptyError": "Transcriptionist,TranscriptionistGroupがいないルーティングルールは保存できません。ルーティング先を1つ以上選択してください。", + "workflowConflictError": "指定したAuthorIDとWorktypeIDの組み合わせで既にルーティングルールが登録されています。他の組み合わせで登録してください。", + "inputEmptyError": "Mandatory Field", + "saveFailedError": "ルーティングルールの保存に失敗しました。画面を更新し、再度実行してください" } }, "typistGroupSetting": { @@ -468,5 +490,12 @@ "deleteButton": "Delete account", "cancelButton": "Cancel" } + }, + "accountDeleteSuccess": { + "label": { + "title": "Account Delete Success", + "message": "Your account has been deleted. Thank you for using our services.", + "backToTopPageLink": "Back to TOP Page" + } } } diff --git a/dictation_client/src/translation/es.json b/dictation_client/src/translation/es.json index b2e8681..0bb32b5 100644 --- a/dictation_client/src/translation/es.json +++ b/dictation_client/src/translation/es.json @@ -356,7 +356,29 @@ }, "workflowPage": { "label": { - "title": "flujo de trabajo" + "title": "flujo de trabajo", + "addRoutingRule": "(es)Add Routing Rule", + "templateSetting": "(es)Template Setting", + "worktypeIdSetting": "(es)WorktypeID Setting", + "typistGroupSetting": "(es)Transcriptionist Group Setting", + "authorID": "ID de autor", + "worktype": "ID de tipo de trabajo", + "worktypeOptional": "(es)Worktype ID (Optional)", + "transcriptionist": "Transcriptor", + "template": "(es)Template", + "templateOptional": "(es)Template (Optional)", + "editRule": "(es)Edit Rule", + "selected": "Transcriptor seleccionado", + "pool": "Lista de transcriptor", + "selectAuthor": "(es)Select Author ID", + "selectWorktypeId": "(es)Select Worktype ID", + "selectTemplate": "(es)Select Template" + }, + "message": { + "selectedTypistEmptyError": "(es)Transcriptionist,TranscriptionistGroupがいないルーティングルールは保存できません。ルーティング先を1つ以上選択してください。", + "workflowConflictError": "(es)指定したAuthorIDとWorktypeIDの組み合わせで既にルーティングルールが登録されています。他の組み合わせで登録してください。", + "inputEmptyError": "Campo obligatorio", + "saveFailedError": "(es)ルーティングルールの保存に失敗しました。画面を更新し、再度実行してください" } }, "typistGroupSetting": { @@ -468,5 +490,12 @@ "deleteButton": "(es)Delete account", "cancelButton": "(es)Cancel" } + }, + "accountDeleteSuccess": { + "label": { + "title": "(es)Account Delete Success", + "message": "(es)Your account has been deleted. Thank you for using our services.", + "backToTopPageLink": "(es)Back to TOP Page" + } } } diff --git a/dictation_client/src/translation/fr.json b/dictation_client/src/translation/fr.json index cc92802..f5bf23d 100644 --- a/dictation_client/src/translation/fr.json +++ b/dictation_client/src/translation/fr.json @@ -356,7 +356,29 @@ }, "workflowPage": { "label": { - "title": "Flux de travail" + "title": "Flux de travail", + "addRoutingRule": "(fr)Add Routing Rule", + "templateSetting": "(fr)Template Setting", + "worktypeIdSetting": "(fr)WorktypeID Setting", + "typistGroupSetting": "(fr)Transcriptionist Group Setting", + "authorID": "Identifiant Auteur", + "worktype": "Identifiant du Type de travail", + "worktypeOptional": "(fr)Worktype ID (Optional)", + "transcriptionist": "Transcriptionniste", + "template": "(fr)Template", + "templateOptional": "(fr)Template (Optional)", + "editRule": "(fr)Edit Rule", + "selected": "Transcriptionniste sélectionné", + "pool": "Liste de transcriptionniste", + "selectAuthor": "(fr)Select Author ID", + "selectWorktypeId": "(fr)Select Worktype ID", + "selectTemplate": "(fr)Select Template" + }, + "message": { + "selectedTypistEmptyError": "(fr)Transcriptionist,TranscriptionistGroupがいないルーティングルールは保存できません。ルーティング先を1つ以上選択してください。", + "workflowConflictError": "(fr)指定したAuthorIDとWorktypeIDの組み合わせで既にルーティングルールが登録されています。他の組み合わせで登録してください。", + "inputEmptyError": "Champ obligatoire", + "saveFailedError": "(fr)ルーティングルールの保存に失敗しました。画面を更新し、再度実行してください" } }, "typistGroupSetting": { @@ -468,5 +490,12 @@ "deleteButton": "(fr)Delete account", "cancelButton": "(fr)Cancel" } + }, + "accountDeleteSuccess": { + "label": { + "title": "(fr)Account Delete Success", + "message": "(fr)Your account has been deleted. Thank you for using our services.", + "backToTopPageLink": "(fr)Back to TOP Page" + } } } diff --git a/dictation_server/.devcontainer/docker-compose.yml b/dictation_server/.devcontainer/docker-compose.yml index c3de580..47cf396 100644 --- a/dictation_server/.devcontainer/docker-compose.yml +++ b/dictation_server/.devcontainer/docker-compose.yml @@ -2,6 +2,7 @@ version: '3' services: dictation_server: + env_file: ../.env build: . working_dir: /app/dictation_server ports: diff --git a/dictation_server/.env b/dictation_server/.env index eba8c6a..c104e29 100644 --- a/dictation_server/.env +++ b/dictation_server/.env @@ -1,16 +1,5 @@ DB_HOST=omds-mysql DB_PORT=3306 -DB_EXTERNAL_PORT=3306 DB_NAME=omds -DB_ROOT_PASS=omdsdbpass DB_USERNAME=omdsdbuser DB_PASSWORD=omdsdbpass -NO_COLOR=TRUE -ACCESS_TOKEN_LIFETIME_WEB=7200000 -REFRESH_TOKEN_LIFETIME_WEB=86400000 -REFRESH_TOKEN_LIFETIME_DEFAULT=2592000000 -TENANT_NAME=adb2codmsdev -SIGNIN_FLOW_NAME=b2c_1_signin_dev -EMAIL_CONFIRM_LIFETIME=86400000 -APP_DOMAIN=https://10.1.0.10:4443/ -STORAGE_TOKEN_EXPIRE_TIME=2 \ No newline at end of file diff --git a/dictation_server/.env.local.example b/dictation_server/.env.local.example index e54ab26..3aa12ac 100644 --- a/dictation_server/.env.local.example +++ b/dictation_server/.env.local.example @@ -1,15 +1,14 @@ STAGE=local +NO_COLOR=TRUE CORS=TRUE PORT=8081 -AZURE_TENANT_ID=xxxxxxxx -AZURE_CLIENT_ID=xxxxxxxx -AZURE_CLIENT_SECRET=xxxxxxxx # 開発環境ではADB2Cが別テナントになる都合上、環境変数を分けている +TENANT_NAME=adb2codmsdev +SIGNIN_FLOW_NAME=b2c_1_signin_dev ADB2C_TENANT_ID=xxxxxxxx ADB2C_CLIENT_ID=xxxxxxxx ADB2C_CLIENT_SECRET=xxxxxxxx ADB2C_ORIGIN=https://zzzzzzzzzz -KEY_VAULT_NAME=kv-odms-secret-dev JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA5IZZNgDew9eGmuFTezwdHYLSaJvUPPIKYoiOeVLD1paWNI51\n7Vkaoh0ngprcKOdv6T1N07V4igK7mOim2zY3yCTR6wcWR3PfFJrl9vh5SOo79koZ\noJb27YiM4jtxfx2dezzp0T2GoNR5rRolPUbWFJXnDe0DVXYXpJLb4LAlF2XAyYX0\nSYKUVUsJnzm5k4xbXtnwPwVbpm0EdswBE6qSfiL9zWk9dvHoKzSnfSDzDFoFcEoV\nchawzYXf/MM1YR4wo5XyzECc6Q5Ah4z522//mBNNaDHv83Yuw3mGShT73iJ0JQdk\nTturshv2Ecma38r6ftrIwNYXw4VVatJM8+GOOQIDAQABAoIBADrwp7u097+dK/tw\nWD61n3DIGAqg/lmFt8X4IH8MKLSE/FKr16CS1bqwOEuIM3ZdUtDeXd9Xs7IsyEPE\n5ZwuXK7DSF0M4+Mj8Ip49Q0Aww9aUoLQU9HGfgN/r4599GTrt31clZXA/6Mlighq\ncOZgCcEfdItz8OMu5SQuOIW4CKkCuaWnPOP26UqZocaXNZfpZH0iFLATMMH/TT8x\nay9ToHTQYE17ijdQ/EOLSwoeDV1CU1CIE3P4YfLJjvpKptly5dTevriHEzBi70Jx\n/KEPUn9Jj2gZafrUxRVhmMbm1zkeYxL3gsqRuTzRjEeeILuZhSJyCkQZyUNARxsg\nQY4DZfECgYEA+YLKUtmYTx60FS6DJ4s31TAsXY8kwhq/lB9E3GBZKDd0DPayXEeK\n4UWRQDTT6MI6fedW69FOZJ5sFLp8HQpcssb4Weq9PCpDhNTx8MCbdH3Um5QR3vfW\naKq/1XM8MDUnx5XcNYd87Aw3azvJAvOPr69as8IPnj6sKaRR9uQjbYUCgYEA6nfV\n5j0qmn0EJXZJblk4mvvjLLoWSs17j9YlrZJlJxXMDFRYtgnelv73xMxOMvcGoxn5\nifs7dpaM2x5EmA6jVU5sYaB/beZGEPWqPYGyjIwXPvUGAAv8Gbnvpp+xlSco/Dum\nIq0w+43ry5/xWh6CjfrvKV0J2bDOiJwPEdu/8iUCgYEAnBBSvL+dpN9vhFAzeOh7\nY71eAqcmNsLEUcG9MJqTKbSFwhYMOewF0iHRWHeylEPokhfBJn8kqYrtz4lVWFTC\n5o/Nh3BsLNXCpbMMIapXkeWiti1HgE9ErPMgSkJpwz18RDpYIqM8X+jEQS6D7HSr\nyxfDg+w+GJza0rEVE3hfMIECgYBw+KZ2VfhmEWBjEHhXE+QjQMR3s320MwebCUqE\nNCpKx8TWF/naVC0MwfLtvqbbBY0MHyLN6d//xpA9r3rLbRojqzKrY2KiuDYAS+3n\nzssRzxoQOozWju+8EYu30/ADdqfXyIHG6X3VZs87AGiQzGyJLmP3oR1y5y7MQa09\nJI16hQKBgHK5uwJhGa281Oo5/FwQ3uYLymbNwSGrsOJXiEu2XwJEXwVi2ELOKh4/\n03pBk3Kva3fIwEK+vCzDNnxShIQqBE76/2I1K1whOfoUehhYvKHGaXl2j70Zz9Ks\nrkGW1cx7p+yDqATDrwHBHTHFh5bUTTn8dN40n0e0W/llurpbBkJM\n-----END RSA PRIVATE KEY-----\n" JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5IZZNgDew9eGmuFTezwd\nHYLSaJvUPPIKYoiOeVLD1paWNI517Vkaoh0ngprcKOdv6T1N07V4igK7mOim2zY3\nyCTR6wcWR3PfFJrl9vh5SOo79koZoJb27YiM4jtxfx2dezzp0T2GoNR5rRolPUbW\nFJXnDe0DVXYXpJLb4LAlF2XAyYX0SYKUVUsJnzm5k4xbXtnwPwVbpm0EdswBE6qS\nfiL9zWk9dvHoKzSnfSDzDFoFcEoVchawzYXf/MM1YR4wo5XyzECc6Q5Ah4z522//\nmBNNaDHv83Yuw3mGShT73iJ0JQdkTturshv2Ecma38r6ftrIwNYXw4VVatJM8+GO\nOQIDAQAB\n-----END PUBLIC KEY-----\n" SENDGRID_API_KEY=xxxxxxxxxxxxxxxx @@ -17,6 +16,7 @@ MAIL_FROM=xxxxx@xxxxx.xxxx NOTIFICATION_HUB_NAME=ntf-odms-dev NOTIFICATION_HUB_CONNECT_STRING=XXXXXXXXXXXXXXXXXX APP_DOMAIN=http://localhost:8081/ +STORAGE_TOKEN_EXPIRE_TIME=30 STORAGE_ACCOUNT_NAME_US=saodmsusdev STORAGE_ACCOUNT_NAME_AU=saodmsaudev STORAGE_ACCOUNT_NAME_EU=saodmseudev @@ -25,4 +25,8 @@ STORAGE_ACCOUNT_KEY_AU=XXXXXXXXXXXXXXXXXXXXXXX STORAGE_ACCOUNT_KEY_EU=XXXXXXXXXXXXXXXXXXXXXXX STORAGE_ACCOUNT_ENDPOINT_US=https://AAAAAAAAAAAAA STORAGE_ACCOUNT_ENDPOINT_AU=https://AAAAAAAAAAAAA -STORAGE_ACCOUNT_ENDPOINT_EU=https://AAAAAAAAAAAAA \ No newline at end of file +STORAGE_ACCOUNT_ENDPOINT_EU=https://AAAAAAAAAAAAA +ACCESS_TOKEN_LIFETIME_WEB=7200000 +REFRESH_TOKEN_LIFETIME_WEB=86400000 +REFRESH_TOKEN_LIFETIME_DEFAULT=2592000000 +EMAIL_CONFIRM_LIFETIME=86400000 \ No newline at end of file diff --git a/dictation_server/db/migrations/041_create_workflow.sql b/dictation_server/db/migrations/041_create_workflow.sql new file mode 100644 index 0000000..3063281 --- /dev/null +++ b/dictation_server/db/migrations/041_create_workflow.sql @@ -0,0 +1,44 @@ +-- +migrate Up +CREATE TABLE IF NOT EXISTS `workflows` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY COMMENT 'workflowの内部ID', + `account_id` BIGINT UNSIGNED NOT NULL COMMENT 'アカウントID', + `author_id` BIGINT UNSIGNED NOT NULL COMMENT 'authorユーザーの内部ID', + `worktype_id`BIGINT UNSIGNED COMMENT 'Worktypeの内部ID', + `template_id` BIGINT UNSIGNED COMMENT 'テンプレートファイルの内部ID', + `created_by` VARCHAR(255) COMMENT '作成者', + `created_at` TIMESTAMP DEFAULT now() COMMENT '作成時刻', + `updated_by` VARCHAR(255) COMMENT '更新者', + `updated_at` TIMESTAMP DEFAULT now() on UPDATE now() COMMENT '更新時刻', + UNIQUE worktype_id_index (account_id, author_id, worktype_id), + CONSTRAINT `workflows_fk_account_id` FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, + CONSTRAINT `workflows_fk_author_id` FOREIGN KEY (author_id) REFERENCES users(id), + CONSTRAINT `workflows_fk_worktype_id` FOREIGN KEY (worktype_id) REFERENCES worktypes(id), + CONSTRAINT `workflows_fk_template_id` FOREIGN KEY (template_id) REFERENCES template_files(id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `workflow_typists` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY COMMENT 'worktypeの内部ID', + `workflow_id` BIGINT UNSIGNED NOT NULL COMMENT 'workflowの内部ID', + `typist_id` BIGINT UNSIGNED COMMENT 'タイピストユーザーの内部ID', + `typist_group_id` BIGINT UNSIGNED COMMENT 'タイピストグループの内部ID', + `created_by` VARCHAR(255) COMMENT '作成者', + `created_at` TIMESTAMP DEFAULT now() COMMENT '作成時刻', + `updated_by` VARCHAR(255) COMMENT '更新者', + `updated_at` TIMESTAMP DEFAULT now() on UPDATE now() COMMENT '更新時刻', + CONSTRAINT `workflow_typists_fk_workflow_id` FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE, + CONSTRAINT `workflow_typists_fk_typist_id` FOREIGN KEY (typist_id) REFERENCES users(id), + CONSTRAINT `workflow_typists_fk_typist_group_id` FOREIGN KEY (typist_group_id) REFERENCES user_group(id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; + + + +-- +migrate Down +ALTER TABLE `workflows` DROP FOREIGN KEY `workflows_fk_account_id`; +ALTER TABLE `workflows` DROP FOREIGN KEY `workflows_fk_author_id`; +ALTER TABLE `workflows` DROP FOREIGN KEY `workflows_fk_worktype_id`; +ALTER TABLE `workflows` DROP FOREIGN KEY `workflows_fk_template_id`; +ALTER TABLE `workflow_typists` DROP FOREIGN KEY `workflow_typists_fk_workflow_id`; +ALTER TABLE `workflow_typists` DROP FOREIGN KEY `workflow_typists_fk_typist_id`; +ALTER TABLE `workflow_typists` DROP FOREIGN KEY `workflow_typists_fk_typist_group_id`; +DROP TABLE IF EXISTS `workflows`; +DROP TABLE IF EXISTS `workflow_typists`; \ No newline at end of file diff --git a/dictation_server/db/migrations/042-add-foreign-key-for-account-delete.sql b/dictation_server/db/migrations/042-add-foreign-key-for-account-delete.sql new file mode 100644 index 0000000..98fb9ca --- /dev/null +++ b/dictation_server/db/migrations/042-add-foreign-key-for-account-delete.sql @@ -0,0 +1,33 @@ +-- +migrate Up +ALTER TABLE `users` ADD CONSTRAINT `users_fk_account_id` FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE `sort_criteria` ADD CONSTRAINT `sort_criteria_fk_user_id` FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE `license_orders` ADD CONSTRAINT `license_orders_fk_from_account_id` FOREIGN KEY(from_account_id) REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE `licenses` ADD CONSTRAINT `licenses_fk_account_id` FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE `card_licenses` ADD CONSTRAINT `card_licenses_fk_license_id` FOREIGN KEY(license_id) REFERENCES licenses(id) ON DELETE CASCADE; +ALTER TABLE `license_allocation_history` ADD CONSTRAINT `license_allocation_history_fk_account_id` FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE `user_group` ADD CONSTRAINT `user_group_fk_account_id` FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE 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; +ALTER TABLE `audio_files` ADD CONSTRAINT `audio_files_fk_account_id` FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE 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; +ALTER TABLE `worktypes` ADD CONSTRAINT `worktypes_fk_account_id` FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE `option_items` ADD CONSTRAINT `option_items_fk_worktype_id` FOREIGN KEY(worktype_id) REFERENCES worktypes(id) ON DELETE CASCADE; +ALTER TABLE `template_files` ADD CONSTRAINT `template_files_fk_account_id` FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE `tasks` ADD CONSTRAINT `tasks_fk_account_id` FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE; +ALTER TABLE `checkout_permission` ADD CONSTRAINT `checkout_permission_fk_task_id` FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE; + +-- +migrate Down +ALTER TABLE `users` DROP FOREIGN KEY `users_fk_account_id`; +ALTER TABLE `sort_criteria` DROP FOREIGN KEY `sort_criteria_fk_user_id`; +ALTER TABLE `license_orders` DROP FOREIGN KEY `license_orders_fk_from_account_id`; +ALTER TABLE `licenses` DROP FOREIGN KEY `licenses_fk_account_id`; +ALTER TABLE `card_licenses` DROP FOREIGN KEY `card_licenses_fk_license_id`; +ALTER TABLE `license_allocation_history` DROP FOREIGN KEY `license_allocation_history_fk_account_id`; +ALTER TABLE `user_group` DROP FOREIGN KEY `user_group_fk_account_id`; +ALTER TABLE `user_group_member` DROP FOREIGN KEY `user_group_member_fk_user_group_id`; +ALTER TABLE `audio_files` DROP FOREIGN KEY `audio_files_fk_account_id`; +ALTER TABLE `audio_option_items` DROP FOREIGN KEY `audio_option_items_fk_audio_file_id`; +ALTER TABLE `worktypes` DROP FOREIGN KEY `worktypes_fk_account_id`; +ALTER TABLE `option_items` DROP FOREIGN KEY `option_items_fk_worktype_id`; +ALTER TABLE `template_files` DROP FOREIGN KEY `template_files_fk_account_id`; +ALTER TABLE `tasks` DROP FOREIGN KEY `tasks_fk_account_id`; +ALTER TABLE `checkout_permission` DROP FOREIGN KEY `checkout_permission_fk_task_id`; diff --git a/dictation_server/db/migrations/043-create_terms.sql b/dictation_server/db/migrations/043-create_terms.sql new file mode 100644 index 0000000..bf36714 --- /dev/null +++ b/dictation_server/db/migrations/043-create_terms.sql @@ -0,0 +1,13 @@ +-- +migrate Up +CREATE TABLE IF NOT EXISTS `terms` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY COMMENT '通番', + `document_type` VARCHAR(255) NOT NULL COMMENT '規約種別(EULA/DPA)', + `version` VARCHAR(255) NOT NULL COMMENT 'バージョン', + `created_by` VARCHAR(255) COMMENT '作成者', + `created_at` TIMESTAMP DEFAULT now() COMMENT '作成時刻', + `updated_by` VARCHAR(255) COMMENT '更新者', + `updated_at` TIMESTAMP DEFAULT now() on UPDATE now() COMMENT '更新時刻' +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; + +-- +migrate Down +DROP TABLE IF EXISTS `terms`; \ No newline at end of file diff --git a/dictation_server/db/migrations/044-fix_users_accepted_terms_version.sql b/dictation_server/db/migrations/044-fix_users_accepted_terms_version.sql new file mode 100644 index 0000000..1b76465 --- /dev/null +++ b/dictation_server/db/migrations/044-fix_users_accepted_terms_version.sql @@ -0,0 +1,13 @@ +-- +migrate Up +ALTER TABLE `users` CHANGE COLUMN `accepted_terms_version` `accepted_eula_version` VARCHAR(255) COMMENT '同意済み利用規約バージョン(EULA)', +ADD COLUMN `accepted_dpa_version` VARCHAR(255) COMMENT '同意済み利用規約バージョン(DPA)' AFTER `accepted_eula_version`; + +ALTER TABLE `users_archive` CHANGE COLUMN `accepted_terms_version` `accepted_eula_version` VARCHAR(255) COMMENT '同意済み利用規約バージョン(EULA)', +ADD COLUMN `accepted_dpa_version` VARCHAR(255) COMMENT '同意済み利用規約バージョン(DPA)' AFTER `accepted_eula_version`; + +-- +migrate Down +ALTER TABLE `users` CHANGE COLUMN `accepted_eula_version` `accepted_terms_version` VARCHAR(255) COMMENT '同意済み利用規約バージョン(EULA)', +DROP COLUMN `accepted_dpa_version`; + +ALTER TABLE `users_archive` CHANGE COLUMN `accepted_eula_version` `accepted_terms_version` VARCHAR(255) COMMENT '同意済み利用規約バージョン(EULA)', +DROP COLUMN `accepted_dpa_version`; \ No newline at end of file diff --git a/dictation_server/src/api/odms/openapi.json b/dictation_server/src/api/odms/openapi.json index 4e50b27..7a56d16 100644 --- a/dictation_server/src/api/odms/openapi.json +++ b/dictation_server/src/api/odms/openapi.json @@ -33,7 +33,7 @@ } }, "401": { - "description": "認証エラー", + "description": "認証エラー/同意済み利用規約が最新でない場合", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } @@ -1219,7 +1219,7 @@ }, "/accounts/delete": { "post": { - "operationId": "deleteAccount", + "operationId": "deleteAccountAndData", "summary": "", "parameters": [], "requestBody": { @@ -1262,6 +1262,52 @@ "security": [{ "bearer": [] }] } }, + "/accounts/minimal-access": { + "post": { + "operationId": "getAccountInfoMinimalAccess", + "summary": "", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAccountInfoMinimalAccessRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功時のレスポンス", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAccountInfoMinimalAccessResponse" + } + } + } + }, + "400": { + "description": "対象のユーザーIDが存在しない場合", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + }, + "500": { + "description": "想定外のサーバーエラー", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + } + }, + "tags": ["accounts"] + } + }, "/users/confirm": { "post": { "operationId": "confirmUser", @@ -1728,6 +1774,53 @@ "security": [{ "bearer": [] }] } }, + "/users/accepted-version": { + "post": { + "operationId": "updateAcceptedVersion", + "summary": "", + "description": "利用規約同意バージョンを更新", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAcceptedVersionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功時のレスポンス", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAcceptedVersionResponse" + } + } + } + }, + "400": { + "description": "パラメータ不正/対象のユーザidが存在しない場合", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + }, + "500": { + "description": "想定外のサーバーエラー", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + } + }, + "tags": ["users"] + } + }, "/files/audio/upload-finished": { "post": { "operationId": "uploadFinished", @@ -2976,6 +3069,114 @@ "security": [{ "bearer": [] }] } }, + "/workflows/{workflowId}": { + "post": { + "operationId": "updateWorkflow", + "summary": "", + "description": "アカウント内のワークフローを編集します", + "parameters": [ + { + "name": "workflowId", + "required": true, + "in": "path", + "description": "ワークフローの内部ID", + "schema": { "type": "number" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateWorkflowRequest" } + } + } + }, + "responses": { + "200": { + "description": "成功時のレスポンス", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkflowResponse" + } + } + } + }, + "400": { + "description": "パラメータ不正エラー", + "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": ["workflows"], + "security": [{ "bearer": [] }] + } + }, + "/workflows/{workflowId}/delete": { + "post": { + "operationId": "deleteWorkflow", + "summary": "", + "description": "アカウント内のワークフローを削除します", + "parameters": [ + { + "name": "workflowId", + "required": true, + "in": "path", + "description": "ワークフローの内部ID", + "schema": { "type": "number" } + } + ], + "responses": { + "200": { + "description": "成功時のレスポンス", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteWorkflowResponse" + } + } + } + }, + "401": { + "description": "認証エラー", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + }, + "500": { + "description": "想定外のサーバーエラー", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + } + }, + "tags": ["workflows"], + "security": [{ "bearer": [] }] + } + }, "/notification/register": { "post": { "operationId": "register", @@ -3026,6 +3227,34 @@ "tags": ["notification"], "security": [{ "bearer": [] }] } + }, + "/terms": { + "post": { + "operationId": "getTermsInfo", + "summary": "", + "parameters": [], + "responses": { + "200": { + "description": "成功時のレスポンス", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTermsInfoResponse" + } + } + } + }, + "500": { + "description": "想定外のサーバーエラー", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + } + }, + "tags": ["terms"] + } } }, "info": { @@ -3087,9 +3316,13 @@ "adminName": { "type": "string" }, "adminMail": { "type": "string" }, "adminPassword": { "type": "string" }, - "acceptedTermsVersion": { + "acceptedEulaVersion": { "type": "string", - "description": "同意済み利用規約のバージョン" + "description": "同意済み利用規約のバージョン(EULA)" + }, + "acceptedDpaVersion": { + "type": "string", + "description": "同意済み利用規約のバージョン(DPA)" }, "token": { "type": "string", "description": "reCAPTCHA Token" } }, @@ -3099,7 +3332,8 @@ "adminName", "adminMail", "adminPassword", - "acceptedTermsVersion", + "acceptedEulaVersion", + "acceptedDpaVersion", "token" ] }, @@ -3597,6 +3831,18 @@ }, "required": ["accountId"] }, + "GetAccountInfoMinimalAccessRequest": { + "type": "object", + "properties": { + "idToken": { "type": "string", "description": "idトークン" } + }, + "required": ["idToken"] + }, + "GetAccountInfoMinimalAccessResponse": { + "type": "object", + "properties": { "tier": { "type": "number", "description": "階層" } }, + "required": ["tier"] + }, "ConfirmRequest": { "type": "object", "properties": { "token": { "type": "string" } }, @@ -3817,6 +4063,22 @@ "required": ["userId"] }, "DeallocateLicenseResponse": { "type": "object", "properties": {} }, + "UpdateAcceptedVersionRequest": { + "type": "object", + "properties": { + "idToken": { "type": "string", "description": "IDトークン" }, + "acceptedEULAVersion": { + "type": "string", + "description": "更新バージョン(EULA)" + }, + "acceptedDPAVersion": { + "type": "string", + "description": "更新バージョン(DPA)" + } + }, + "required": ["idToken", "acceptedEULAVersion"] + }, + "UpdateAcceptedVersionResponse": { "type": "object", "properties": {} }, "AudioOptionItem": { "type": "object", "properties": { @@ -4251,7 +4513,7 @@ "CreateWorkflowsRequest": { "type": "object", "properties": { - "authorId": { "type": "number", "description": "Authornの内部ID" }, + "authorId": { "type": "number", "description": "Authorの内部ID" }, "worktypeId": { "type": "number", "description": "Worktypeの内部ID" }, "templateId": { "type": "number", @@ -4267,6 +4529,26 @@ "required": ["authorId", "typists"] }, "CreateWorkflowsResponse": { "type": "object", "properties": {} }, + "UpdateWorkflowRequest": { + "type": "object", + "properties": { + "authorId": { "type": "number", "description": "Authorの内部ID" }, + "worktypeId": { "type": "number", "description": "Worktypeの内部ID" }, + "templateId": { + "type": "number", + "description": "テンプレートの内部ID" + }, + "typists": { + "description": "ルーティング候補のタイピストユーザー/タイピストグループ", + "minItems": 1, + "type": "array", + "items": { "$ref": "#/components/schemas/WorkflowTypist" } + } + }, + "required": ["authorId", "typists"] + }, + "UpdateWorkflowResponse": { "type": "object", "properties": {} }, + "DeleteWorkflowResponse": { "type": "object", "properties": {} }, "RegisterRequest": { "type": "object", "properties": { @@ -4278,7 +4560,8 @@ }, "required": ["pns", "handler"] }, - "RegisterResponse": { "type": "object", "properties": {} } + "RegisterResponse": { "type": "object", "properties": {} }, + "GetTermsInfoResponse": { "type": "object", "properties": {} } } } } diff --git a/dictation_server/src/app.module.ts b/dictation_server/src/app.module.ts index c99f882..f8c0664 100644 --- a/dictation_server/src/app.module.ts +++ b/dictation_server/src/app.module.ts @@ -48,6 +48,8 @@ import { WorkflowsModule } from './features/workflows/workflows.module'; import { WorkflowsController } from './features/workflows/workflows.controller'; import { WorkflowsService } from './features/workflows/workflows.service'; import { validate } from './common/validators/env.validator'; +import { WorkflowsRepositoryModule } from './repositories/workflows/workflows.repository.module'; +import { TermsModule } from './features/terms/terms.module'; @Module({ imports: [ @@ -86,6 +88,7 @@ import { validate } from './common/validators/env.validator'; CheckoutPermissionsRepositoryModule, UserGroupsRepositoryModule, TemplateFilesRepositoryModule, + WorkflowsRepositoryModule, TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ @@ -106,6 +109,7 @@ import { validate } from './common/validators/env.validator'; AuthGuardsModule, SortCriteriaRepositoryModule, WorktypesRepositoryModule, + TermsModule, ], controllers: [ HealthController, diff --git a/dictation_server/src/common/error/code.ts b/dictation_server/src/common/error/code.ts index c639807..448aa82 100644 --- a/dictation_server/src/common/error/code.ts +++ b/dictation_server/src/common/error/code.ts @@ -56,4 +56,7 @@ export const ErrorCodes = [ 'E011001', // ワークタイプ重複エラー 'E011002', // ワークタイプ登録上限超過エラー 'E011003', // ワークタイプ不在エラー + 'E012001', // テンプレートファイル不在エラー + 'E013001', // ワークフローのAuthorIDとWorktypeIDのペア重複エラー + 'E013002', // ワークフロー不在エラー ] as const; diff --git a/dictation_server/src/common/error/message.ts b/dictation_server/src/common/error/message.ts index 484e9a4..eeee5b3 100644 --- a/dictation_server/src/common/error/message.ts +++ b/dictation_server/src/common/error/message.ts @@ -45,4 +45,7 @@ export const errors: Errors = { E011001: 'This WorkTypeID already used Error', E011002: 'WorkTypeID create limit exceeded Error', E011003: 'WorkTypeID not found 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/test/overrides.ts b/dictation_server/src/common/test/overrides.ts index 31ce97c..7455cd7 100644 --- a/dictation_server/src/common/test/overrides.ts +++ b/dictation_server/src/common/test/overrides.ts @@ -29,6 +29,7 @@ export const overrideAdB2cService = ( username: string, ) => Promise<{ sub: string } | ConflictError>; deleteUser?: (externalId: string, context: Context) => Promise; + deleteUsers?: (externalIds: string[], context: Context) => Promise; getUsers?: ( context: Context, externalIds: string[], @@ -49,6 +50,12 @@ export const overrideAdB2cService = ( writable: true, }); } + if (overrides.deleteUsers) { + Object.defineProperty(obj, obj.deleteUsers.name, { + value: overrides.deleteUsers, + writable: true, + }); + } if (overrides.getUsers) { Object.defineProperty(obj, obj.getUsers.name, { value: overrides.getUsers, @@ -229,9 +236,11 @@ export const overrideAccountsRepositoryService = ( tier: number, adminExternalUserId: string, adminUserRole: string, - adminUserAcceptedTermsVersion: string, + adminUserAcceptedEulaVersion: string, + adminUserAcceptedDpaVersion: string, ) => Promise<{ newAccount: Account; adminUser: User }>; deleteAccount?: (accountId: number, userId: number) => Promise; + deleteAccountAndInsertArchives?: (accountId: number) => Promise; }, ): void => { // テストコードでのみ許される強引な方法でprivateメンバ変数の参照を取得 @@ -248,4 +257,10 @@ export const overrideAccountsRepositoryService = ( writable: true, }); } + if (overrides.deleteAccountAndInsertArchives) { + Object.defineProperty(obj, obj.deleteAccountAndInsertArchives.name, { + value: overrides.deleteAccountAndInsertArchives, + writable: true, + }); + } }; diff --git a/dictation_server/src/common/test/utility.ts b/dictation_server/src/common/test/utility.ts index a5c998b..4722e9f 100644 --- a/dictation_server/src/common/test/utility.ts +++ b/dictation_server/src/common/test/utility.ts @@ -1,6 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; import { DataSource } from 'typeorm'; -import { User } from '../../repositories/users/entity/user.entity'; +import { User, UserArchive } from '../../repositories/users/entity/user.entity'; import { Account } from '../../repositories/accounts/entity/account.entity'; import { ADMIN_ROLES, USER_ROLES } from '../../constants'; @@ -180,7 +180,8 @@ export const makeTestAccount = async ( account_id: accountId, role: d?.role ?? 'admin none', author_id: d?.author_id ?? undefined, - accepted_terms_version: d?.accepted_terms_version ?? '1.0', + accepted_eula_version: d?.accepted_eula_version ?? '1.0', + accepted_dpa_version: d?.accepted_dpa_version ?? '1.0', email_verified: d?.email_verified ?? true, auto_renew: d?.auto_renew ?? true, license_alert: d?.license_alert ?? true, @@ -282,7 +283,8 @@ export const makeTestUser = async ( external_id: d?.external_id ?? uuidv4(), role: d?.role ?? `${ADMIN_ROLES.STANDARD} ${USER_ROLES.NONE}`, author_id: d?.author_id, - accepted_terms_version: d?.accepted_terms_version, + accepted_eula_version: d?.accepted_eula_version ?? '1.0', + accepted_dpa_version: d?.accepted_dpa_version ?? '1.0', email_verified: d?.email_verified ?? true, auto_renew: d?.auto_renew ?? true, license_alert: d?.license_alert ?? true, @@ -368,3 +370,14 @@ export const getUser = async ( export const getUsers = async (dataSource: DataSource): Promise => { return await dataSource.getRepository(User).find(); }; + +/** + * テスト ユーティリティ: ユーザー退避テーブルの内容を取得する + * @param dataSource データソース + * @returns ユーザー退避テーブルの内容 + */ +export const getUserArchive = async ( + dataSource: DataSource, +): Promise => { + return await dataSource.getRepository(UserArchive).find(); +}; diff --git a/dictation_server/src/common/validators/env.validator.ts b/dictation_server/src/common/validators/env.validator.ts index 9ceea2a..836de47 100644 --- a/dictation_server/src/common/validators/env.validator.ts +++ b/dictation_server/src/common/validators/env.validator.ts @@ -20,18 +20,10 @@ export class EnvValidator { @IsNumber() DB_PORT: number; - @IsNotEmpty() - @IsNumber() - DB_EXTERNAL_PORT: number; - @IsNotEmpty() @IsString() DB_NAME: string; - @IsNotEmpty() - @IsString() - DB_ROOT_PASS: string; - @IsNotEmpty() @IsString() DB_USERNAME: string; @@ -40,21 +32,22 @@ export class EnvValidator { @IsString() DB_PASSWORD: string; + // .env.local + @IsOptional() + @IsString() + STAGE: string; + @IsOptional() @IsString() NO_COLOR: string; - @IsNotEmpty() - @IsNumber() - ACCESS_TOKEN_LIFETIME_WEB: number; + @IsOptional() + @IsString() + CORS: string; - @IsNotEmpty() + @IsOptional() @IsNumber() - REFRESH_TOKEN_LIFETIME_WEB: number; - - @IsNotEmpty() - @IsNumber() - REFRESH_TOKEN_LIFETIME_DEFAULT: number; + PORT: number; @IsNotEmpty() @IsString() @@ -64,43 +57,6 @@ export class EnvValidator { @IsString() SIGNIN_FLOW_NAME: string; - @IsNotEmpty() - @IsNumber() - EMAIL_CONFIRM_LIFETIME: number; - - @IsNotEmpty() - @IsString() - APP_DOMAIN: string; - - @IsNotEmpty() - @IsNumber() - STORAGE_TOKEN_EXPIRE_TIME: number; - - // .env.local - @IsOptional() - @IsString() - STAGE: string; - - @IsOptional() - @IsString() - CORS: string; - - @IsNotEmpty() - @IsNumber() - PORT: number; - - @IsNotEmpty() - @IsString() - AZURE_TENANT_ID: string; - - @IsNotEmpty() - @IsString() - AZURE_CLIENT_ID: string; - - @IsNotEmpty() - @IsString() - AZURE_CLIENT_SECRET: string; - @IsNotEmpty() @IsString() ADB2C_TENANT_ID: string; @@ -113,14 +69,10 @@ export class EnvValidator { @IsString() ADB2C_CLIENT_SECRET: string; - @IsNotEmpty() + @IsOptional() @IsString() ADB2C_ORIGIN: string; - @IsNotEmpty() - @IsString() - KEY_VAULT_NAME: string; - @IsNotEmpty() @IsString() JWT_PRIVATE_KEY: string; @@ -145,6 +97,14 @@ export class EnvValidator { @IsString() NOTIFICATION_HUB_CONNECT_STRING: string; + @IsNotEmpty() + @IsString() + APP_DOMAIN: string; + + @IsNotEmpty() + @IsNumber() + STORAGE_TOKEN_EXPIRE_TIME: number; + @IsNotEmpty() @IsString() STORAGE_ACCOUNT_NAME_US: string; @@ -180,6 +140,22 @@ export class EnvValidator { @IsNotEmpty() @IsString() STORAGE_ACCOUNT_ENDPOINT_EU: string; + + @IsNotEmpty() + @IsNumber() + ACCESS_TOKEN_LIFETIME_WEB: number; + + @IsNotEmpty() + @IsNumber() + REFRESH_TOKEN_LIFETIME_WEB: number; + + @IsNotEmpty() + @IsNumber() + REFRESH_TOKEN_LIFETIME_DEFAULT: number; + + @IsNotEmpty() + @IsNumber() + EMAIL_CONFIRM_LIFETIME: number; } export function validate(config: Record) { diff --git a/dictation_server/src/constants/index.ts b/dictation_server/src/constants/index.ts index 66471b9..8e527f4 100644 --- a/dictation_server/src/constants/index.ts +++ b/dictation_server/src/constants/index.ts @@ -246,3 +246,9 @@ export const OPTION_ITEM_VALUE_TYPE = { export const ADB2C_SIGN_IN_TYPE = { EAMILADDRESS: 'emailAddress', } as const; + +/** + * MANUAL_RECOVERY_REQUIRED + * @const {string} + */ +export const MANUAL_RECOVERY_REQUIRED = '[MANUAL_RECOVERY_REQUIRED]'; diff --git a/dictation_server/src/features/accounts/accounts.controller.ts b/dictation_server/src/features/accounts/accounts.controller.ts index 03e4cd9..92206af 100644 --- a/dictation_server/src/features/accounts/accounts.controller.ts +++ b/dictation_server/src/features/accounts/accounts.controller.ts @@ -63,6 +63,8 @@ import { DeleteAccountRequest, DeleteAccountResponse, GetAuthorsResponse, + GetAccountInfoMinimalAccessRequest, + GetAccountInfoMinimalAccessResponse, } from './types/types'; import { USER_ROLES, ADMIN_ROLES, TIERS } from '../../constants'; import { AuthGuard } from '../../common/guards/auth/authguards'; @@ -107,7 +109,8 @@ export class AccountsController { adminMail, adminPassword, adminName, - acceptedTermsVersion, + acceptedEulaVersion, + acceptedDpaVersion, } = body; const role = USER_ROLES.NONE; @@ -122,7 +125,8 @@ export class AccountsController { adminPassword, adminName, role, - acceptedTermsVersion, + acceptedEulaVersion, + acceptedDpaVersion, ); return {}; @@ -231,9 +235,9 @@ export class AccountsController { const { userId } = jwt.decode(accessToken, { json: true }) as AccessToken; const context = makeContext(userId); - console.log(context.trackingId); + const authors = await this.accountService.getAuthors(context, userId); - return { authors: [] }; + return { authors }; } @ApiResponse({ @@ -1071,7 +1075,7 @@ export class AccountsController { description: 'DBアクセスに失敗しログインできる状態で処理が終了した場合', type: ErrorResponse, }) - @ApiOperation({ operationId: 'deleteAccount' }) + @ApiOperation({ operationId: 'deleteAccountAndData' }) @ApiBearerAuth() @UseGuards(AuthGuard) @UseGuards( @@ -1079,7 +1083,7 @@ export class AccountsController { roles: [ADMIN_ROLES.ADMIN], }), ) - async deleteAccount( + async deleteAccountAndData( @Req() req: Request, @Body() body: DeleteAccountRequest, ): Promise { @@ -1088,12 +1092,35 @@ export class AccountsController { const { userId } = jwt.decode(token, { json: true }) as AccessToken; const context = makeContext(userId); - /* TODO 仮実装、別タスクで実装する - await this.accountService.deleteAccount( - context, - accountId - ); - */ + await this.accountService.deleteAccountAndData(context, userId, accountId); + return; + } + + @Post('/minimal-access') + @ApiResponse({ + status: HttpStatus.OK, + type: GetAccountInfoMinimalAccessResponse, + description: '成功時のレスポンス', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: '対象のユーザーIDが存在しない場合', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: '想定外のサーバーエラー', + type: ErrorResponse, + }) + @ApiOperation({ operationId: 'getAccountInfoMinimalAccess' }) + async getAccountInfoMinimalAccess( + @Body() body: GetAccountInfoMinimalAccessRequest, + ): Promise { + const context = makeContext(uuidv4()); + + // TODO 仮実装。API実装タスクで本実装する。 + // const idToken = await this.authService.getVerifiedIdToken(body.idToken); + // await this.accountService.getAccountInfoMinimalAccess(context, idToken); return; } } diff --git a/dictation_server/src/features/accounts/accounts.service.spec.ts b/dictation_server/src/features/accounts/accounts.service.spec.ts index 7fe4171..c7786d5 100644 --- a/dictation_server/src/features/accounts/accounts.service.spec.ts +++ b/dictation_server/src/features/accounts/accounts.service.spec.ts @@ -34,6 +34,8 @@ import { getUsers, makeTestUser, makeHierarchicalAccounts, + getUser, + getUserArchive, } from '../../common/test/utility'; import { AccountsService } from './accounts.service'; import { Context, makeContext } from '../../common/log'; @@ -58,7 +60,10 @@ 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, selectLicense, selectOrderLicense, } from '../licenses/test/utility'; @@ -66,6 +71,7 @@ import { WorktypesRepositoryService } from '../../repositories/worktypes/worktyp 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'; describe('createAccount', () => { let source: DataSource = null; @@ -97,7 +103,8 @@ describe('createAccount', () => { const password = 'dummy_password'; const username = 'dummy_username'; const role = 'none'; - const acceptedTermsVersion = '1.0.0'; + const acceptedEulaVersion = '1.0.0'; + const acceptedDpaVersion = '1.0.0'; overrideAdB2cService(service, { createUser: async () => { @@ -128,7 +135,8 @@ describe('createAccount', () => { password, username, role, - acceptedTermsVersion, + acceptedEulaVersion, + acceptedDpaVersion, ); // 作成したアカウントのIDが返ってくるか確認 expect(accountId).toBe(1); @@ -144,7 +152,8 @@ describe('createAccount', () => { 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_terms_version).toBe(acceptedTermsVersion); + 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); }); @@ -175,7 +184,8 @@ describe('createAccount', () => { const password = 'dummy_password'; const username = 'dummy_username'; const role = 'admin none'; - const acceptedTermsVersion = '1.0.0'; + const acceptedEulaVersion = '1.0.0'; + const acceptedDpaVersion = '1.0.0'; overrideAdB2cService(service, { createUser: async () => { @@ -195,7 +205,8 @@ describe('createAccount', () => { password, username, role, - acceptedTermsVersion, + acceptedEulaVersion, + acceptedDpaVersion, ); } catch (e) { if (e instanceof HttpException) { @@ -240,7 +251,8 @@ describe('createAccount', () => { const password = 'dummy_password'; const username = 'dummy_username'; const role = 'admin none'; - const acceptedTermsVersion = '1.0.0'; + const acceptedEulaVersion = '1.0.0'; + const acceptedDpaVersion = '1.0.0'; overrideAdB2cService(service, { createUser: async () => { @@ -261,7 +273,8 @@ describe('createAccount', () => { password, username, role, - acceptedTermsVersion, + acceptedEulaVersion, + acceptedDpaVersion, ); } catch (e) { if (e instanceof HttpException) { @@ -290,7 +303,8 @@ describe('createAccount', () => { const password = 'dummy_password'; const username = 'dummy_username'; const role = 'none'; - const acceptedTermsVersion = '1.0.0'; + const acceptedEulaVersion = '1.0.0'; + const acceptedDpaVersion = '1.0.0'; overrideAdB2cService(service, { createUser: async () => { @@ -316,7 +330,8 @@ describe('createAccount', () => { password, username, role, - acceptedTermsVersion, + acceptedEulaVersion, + acceptedDpaVersion, ); } catch (e) { if (e instanceof HttpException) { @@ -352,7 +367,8 @@ describe('createAccount', () => { const password = 'dummy_password'; const username = 'dummy_username'; const role = 'none'; - const acceptedTermsVersion = '1.0.0'; + const acceptedEulaVersion = '1.0.0'; + const acceptedDpaVersion = '1.0.0'; overrideAdB2cService(service, { createUser: async () => { @@ -378,7 +394,8 @@ describe('createAccount', () => { password, username, role, - acceptedTermsVersion, + acceptedEulaVersion, + acceptedDpaVersion, ); } catch (e) { if (e instanceof HttpException) { @@ -416,7 +433,8 @@ describe('createAccount', () => { const password = 'dummy_password'; const username = 'dummy_username'; const role = 'none'; - const acceptedTermsVersion = '1.0.0'; + const acceptedEulaVersion = '1.0.0'; + const acceptedDpaVersion = '1.0.0'; overrideAdB2cService(service, { createUser: async () => { @@ -443,7 +461,8 @@ describe('createAccount', () => { password, username, role, - acceptedTermsVersion, + acceptedEulaVersion, + acceptedDpaVersion, ); } catch (e) { if (e instanceof HttpException) { @@ -480,7 +499,8 @@ describe('createAccount', () => { const password = 'dummy_password'; const username = 'dummy_username'; const role = 'none'; - const acceptedTermsVersion = '1.0.0'; + const acceptedEulaVersion = '1.0.0'; + const acceptedDpaVersion = '1.0.0'; overrideAdB2cService(service, { createUser: async () => { @@ -510,7 +530,8 @@ describe('createAccount', () => { password, username, role, - acceptedTermsVersion, + acceptedEulaVersion, + acceptedDpaVersion, ); } catch (e) { if (e instanceof HttpException) { @@ -549,7 +570,8 @@ describe('createAccount', () => { const password = 'dummy_password'; const username = 'dummy_username'; const role = 'none'; - const acceptedTermsVersion = '1.0.0'; + const acceptedEulaVersion = '1.0.0'; + const acceptedDpaVersion = '1.0.0'; overrideAdB2cService(service, { createUser: async ( @@ -596,7 +618,8 @@ describe('createAccount', () => { password, username, role, - acceptedTermsVersion, + acceptedEulaVersion, + acceptedDpaVersion, ); } catch (e) { if (e instanceof HttpException) { @@ -641,7 +664,8 @@ describe('createAccount', () => { const password = 'dummy_password'; const username = 'dummy_username'; const role = 'none'; - const acceptedTermsVersion = '1.0.0'; + const acceptedEulaVersion = '1.0.0'; + const acceptedDpaVersion = '1.0.0'; overrideAdB2cService(service, { createUser: async () => { @@ -685,7 +709,8 @@ describe('createAccount', () => { password, username, role, - acceptedTermsVersion, + acceptedEulaVersion, + acceptedDpaVersion, ); } catch (e) { if (e instanceof HttpException) { @@ -5204,3 +5229,369 @@ describe('getAccountInfo', () => { } }); }); +describe('getAuthors', () => { + let source: DataSource = 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 () => { + await source.destroy(); + source = null; + }); + it('アカウント内のAuthorユーザーの一覧を取得できる', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + + const { id: userId1 } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + author_id: 'AUTHOR_ID_1', + }); + const { id: userId2 } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.AUTHOR, + author_id: 'AUTHOR_ID_2', + }); + const { id: userId3 } = await makeTestUser(source, { + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + // 作成したデータを確認 + { + 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); + } + + const service = module.get(AccountsService); + const context = makeContext(admin.external_id); + const authors = await service.getAuthors(context, admin.external_id); + + //実行結果を確認 + { + expect(authors.length).toBe(2); + expect(authors[0].id).toBe(userId1); + expect(authors[0].authorId).toBe('AUTHOR_ID_1'); + expect(authors[1].id).toBe(userId2); + expect(authors[1].authorId).toBe('AUTHOR_ID_2'); + } + }); + it('アカウント内のAuthorユーザーの一覧を取得できる(0件)', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + const { admin } = await makeTestAccount(source, { tier: 5 }); + + // 作成したデータを確認 + { + const users = await getUsers(source); + expect(users.length).toBe(1); + } + + const service = module.get(AccountsService); + const context = makeContext(admin.external_id); + const authors = await service.getAuthors(context, admin.external_id); + + //実行結果を確認 + { + expect(authors.length).toBe(0); + } + }); + it('DBアクセスに失敗した場合、500エラーとなる', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + const { admin } = await makeTestAccount(source, { tier: 5 }); + + const service = module.get(AccountsService); + const context = makeContext(admin.external_id); + + //DBアクセスに失敗するようにする + const usersService = module.get( + UsersRepositoryService, + ); + usersService.findAuthorUsers = jest.fn().mockRejectedValue('DB failed'); + + //実行結果を確認 + try { + await service.getAuthors(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(); + } + } + }); +}); +describe('deleteAccountAndData', () => { + let source: DataSource = 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 () => { + await source.destroy(); + source = null; + }); + it('アカウント情報が削除されること', async () => { + const module = await makeTestingModule(source); + 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, + tier: 5, + }); + + // ユーザの作成 + const user = await makeTestUser(source, { + account_id: tier5Accounts.account.id, + }); + // ライセンス作成 + await createLicense( + source, + 1, + new Date(), + tier5Accounts.account.id, + LICENSE_TYPE.NORMAL, + LICENSE_ALLOCATED_STATUS.UNALLOCATED, + null, + user.id, + null, + null, + ); + await createLicenseAllocationHistory( + source, + 1, + user.id, + 1, + tier5Accounts.account.id, + 'NONE', + ); + + // ADB2Cユーザーの削除成功 + overrideAdB2cService(service, { + deleteUsers: jest.fn(), + }); + // blobstorageコンテナの削除成功 + overrideBlobstorageService(service, { + deleteContainer: jest.fn(), + }); + // アカウント情報の削除 + await service.deleteAccountAndData( + context, + tier5Accounts.admin.external_id, + tier5Accounts.account.id, + ); + + // DB内が想定通りになっているか確認 + const accountRecord = await getAccount(source, tier5Accounts.account.id); + expect(accountRecord).toBe(null); + + const userRecord = await getUser(source, user.id); + expect(userRecord).toBe(null); + + const UserArchive = await getUserArchive(source); + expect(UserArchive.length).toBe(2); + + const LicenseArchive = await getLicenseArchive(source); + expect(LicenseArchive.length).toBe(1); + + const LicenseAllocationHistoryArchive = + await getLicenseAllocationHistoryArchive(source); + expect(LicenseAllocationHistoryArchive.length).toBe(1); + }); + it('アカウントの削除に失敗した場合はエラーを返す', async () => { + const module = await makeTestingModule(source); + const service = module.get(AccountsService); + const loggerSpy = jest.spyOn(service['logger'], 'log').mockImplementation(); + // 第五階層のアカウント作成 + 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, + tier: 5, + }); + + // ユーザの作成 + const user = await makeTestUser(source, { + account_id: tier5Accounts.account.id, + }); + + // アカウント情報の削除失敗 + overrideAccountsRepositoryService(service, { + deleteAccountAndInsertArchives: jest.fn().mockRejectedValue(new Error()), + }); + + // ADB2Cユーザーの削除成功 + overrideAdB2cService(service, { + deleteUsers: jest.fn(), + }); + + // blobstorageコンテナの削除成功 + overrideBlobstorageService(service, { + deleteContainer: jest.fn(), + }); + + // アカウント情報の削除に失敗することを確認 + await expect( + service.deleteAccountAndData( + context, + tier5Accounts.admin.external_id, + tier5Accounts.account.id, + ), + ).rejects.toEqual( + new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + + // loggerSpyがスパイしているlogger.logメソッドが出力したログを確認(目視確認用) + const logs = loggerSpy.mock.calls.map((call) => call[0]); + console.log(logs); + + // 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(); + }); + it('ADB2Cユーザーの削除失敗時は、MANUAL_RECOVERY_REQUIREDを出して処理続行', async () => { + const module = await makeTestingModule(source); + const service = module.get(AccountsService); + const loggerSpy = jest.spyOn(service['logger'], 'log').mockImplementation(); + // 第五階層のアカウント作成 + 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, + tier: 5, + }); + + // ユーザの作成 + const user = await makeTestUser(source, { + account_id: tier5Accounts.account.id, + }); + + // ADB2Cユーザーの削除失敗 + overrideAdB2cService(service, { + deleteUsers: jest.fn().mockRejectedValue(new Error()), + }); + + // blobstorageコンテナの削除成功 + overrideBlobstorageService(service, { + deleteContainer: jest.fn(), + }); + + // 処理自体は成功することを確認 + expect( + await service.deleteAccountAndData( + context, + tier5Accounts.admin.external_id, + tier5Accounts.account.id, + ), + ).toEqual(undefined); + + // loggerSpyがスパイしているlogger.logメソッドが出力したログを確認(目視確認用) + const logs = loggerSpy.mock.calls.map((call) => call[0]); + console.log(logs); + + // DB内が想定通りになっているか確認 + const accountRecord = await getAccount(source, tier5Accounts.account.id); + expect(accountRecord).toBe(null); + const userRecord = await getUser(source, user.id); + expect(userRecord).toBe(null); + }); + it('blobstorageコンテナを削除で失敗した場合は、MANUAL_RECOVERY_REQUIRED出して正常終了', async () => { + const module = await makeTestingModule(source); + const service = module.get(AccountsService); + const loggerSpy = jest.spyOn(service['logger'], 'log').mockImplementation(); + + // 第五階層のアカウント作成 + 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, + tier: 5, + }); + + // ユーザの作成 + const user = await makeTestUser(source, { + account_id: tier5Accounts.account.id, + }); + + // ADB2Cユーザーの削除成功 + overrideAdB2cService(service, { + deleteUsers: jest.fn(), + }); + + // blobstorageコンテナの削除失敗 + overrideBlobstorageService(service, { + deleteContainer: jest.fn().mockRejectedValue(new Error()), + }); + + // 処理自体は成功することを確認 + expect( + await service.deleteAccountAndData( + context, + tier5Accounts.admin.external_id, + tier5Accounts.account.id, + ), + ).toEqual(undefined); + + // loggerSpyがスパイしているlogger.logメソッドが出力したログを確認(目視確認用) + const logs = loggerSpy.mock.calls.map((call) => call[0]); + console.log(logs); + + // DB内が想定通りになっているか確認 + const accountRecord = await getAccount(source, tier5Accounts.account.id); + expect(accountRecord).toBe(null); + const userRecord = await getUser(source, user.id); + expect(userRecord).toBe(null); + }); +}); diff --git a/dictation_server/src/features/accounts/accounts.service.ts b/dictation_server/src/features/accounts/accounts.service.ts index 2c13616..0587575 100644 --- a/dictation_server/src/features/accounts/accounts.service.ts +++ b/dictation_server/src/features/accounts/accounts.service.ts @@ -15,6 +15,7 @@ import { USER_ROLES, ADB2C_SIGN_IN_TYPE, OPTION_ITEM_VALUE_TYPE, + MANUAL_RECOVERY_REQUIRED, } from '../../constants'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; import { @@ -31,6 +32,7 @@ import { GetOptionItemsResponse, GetPartnersResponse, PostWorktypeOptionItem, + Author, } from './types/types'; import { DateWithZeroTime, @@ -157,14 +159,16 @@ export class AccountsService { password: string, username: string, role: string, - acceptedTermsVersion: string, + acceptedEulaVersion: string, + acceptedDpaVersion: string, ): Promise<{ accountId: number; userId: number; externalUserId: string }> { this.logger.log( `[IN] [${context.trackingId}] ${this.createAccount.name} | params: { ` + `country: ${country}, ` + `dealerAccountId: ${dealerAccountId}, ` + `role: ${role}, ` + - `acceptedTermsVersion: ${acceptedTermsVersion} };`, + `acceptedEulaVersion: ${acceptedEulaVersion} }, ` + + `acceptedDpaVersion: ${acceptedDpaVersion} };`, ); try { let externalUser: { sub: string } | ConflictError; @@ -207,7 +211,8 @@ export class AccountsService { TIERS.TIER5, externalUser.sub, role, - acceptedTermsVersion, + acceptedEulaVersion, + acceptedDpaVersion, ); account = newAccount; user = adminUser; @@ -319,7 +324,7 @@ export class AccountsService { } catch (error) { this.logger.error(`error=${error}`); this.logger.error( - `[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`, + `${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`, ); } } @@ -338,7 +343,7 @@ export class AccountsService { } catch (error) { this.logger.error(`error=${error}`); this.logger.error( - `[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete account: ${accountId}, user: ${userId}`, + `${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete account: ${accountId}, user: ${userId}`, ); } } @@ -361,7 +366,7 @@ export class AccountsService { ); } catch (error) { this.logger.error( - `[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete container: ${accountId}, country: ${country}`, + `${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete container: ${accountId}, country: ${country}`, ); } } @@ -553,6 +558,64 @@ export class AccountsService { } } + /** + * アカウント内のAuthorを取得する + * @param context + * @param externalId + * @returns authors + */ + async getAuthors(context: Context, externalId: string): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.getAuthors.name} | params: { externalId: ${externalId} };`, + ); + + try { + const { account } = await this.usersRepository.findUserByExternalId( + externalId, + ); + + if (!account) { + throw new AccountNotFoundError( + `account not found. externalId: ${externalId}`, + ); + } + + const authorUsers = await this.usersRepository.findAuthorUsers( + account.id, + ); + + const authors = authorUsers.map((x) => { + return { + id: x.id, + authorId: x.author_id, + }; + }); + return authors; + } catch (e) { + this.logger.error(`error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + 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.getAuthors.name}`); + } + } + /** * パートナーを追加する * @param companyName パートナーの会社名 @@ -638,6 +701,7 @@ export class AccountsService { externalUser.sub, USER_ROLES.NONE, null, + null, ); account = newAccount; user = adminUser; @@ -1684,4 +1748,99 @@ export class AccountsService { ); } } + + /** + * アカウントと紐づくデータを削除する + * @param context + * @param externalId + * @param accountId // 削除対象のアカウントID + */ + async deleteAccountAndData( + context: Context, + externalId: string, + accountId: number, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.deleteAccountAndData.name} | params: { ` + + `externalId: ${externalId}, ` + + `accountId: ${accountId}, };`, + ); + let country: string; + let dbUsers: User[]; + try { + // パラメータとトークンから取得したアカウントIDの突き合わせ + const { account_id: myAccountId } = + await this.usersRepository.findUserByExternalId(externalId); + if (myAccountId !== accountId) { + throw new HttpException( + makeErrorResponse('E000108'), + HttpStatus.UNAUTHORIZED, + ); + } + + // アカウント削除前に必要な情報を退避する + const targetAccount = await this.accountRepository.findAccountById( + accountId, + ); + // 削除対象アカウントを削除する + dbUsers = await this.accountRepository.deleteAccountAndInsertArchives( + accountId, + ); + this.logger.log(`[${context.trackingId}] delete account: ${accountId}`); + country = targetAccount.country; + } catch (e) { + // アカウントの削除に失敗した場合はエラーを返す + this.logger.log(`[${context.trackingId}] ${e}`); + this.logger.log( + `[OUT] [${context.trackingId}] ${this.deleteAccountAndData.name}`, + ); + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + try { + // 削除対象アカウント内のADB2Cユーザーをすべて削除する + await this.adB2cService.deleteUsers( + dbUsers.map((x) => x.external_id), + context, + ); + this.logger.log( + `[${ + context.trackingId + }] delete ADB2C users: ${accountId}, users_id: ${dbUsers.map( + (x) => x.external_id, + )}`, + ); + } catch (e) { + // ADB2Cユーザーの削除失敗時は、MANUAL_RECOVERY_REQUIREDを出して処理続行 + this.logger.log(`[${context.trackingId}] ${e}`); + this.logger.log( + `${MANUAL_RECOVERY_REQUIRED} [${ + context.trackingId + }] Failed to delete ADB2C users: ${accountId}, users_id: ${dbUsers.map( + (x) => x.external_id, + )}`, + ); + } + + try { + // blobstorageコンテナを削除する + await this.deleteBlobContainer(accountId, country, context); + this.logger.log( + `[${context.trackingId}] delete blob container: ${accountId}-${country}`, + ); + } catch (e) { + // blobstorageコンテナを削除で失敗した場合は、MANUAL_RECOVERY_REQUIRED出して正常終了 + this.logger.log(`[${context.trackingId}] ${e}`); + this.logger.log( + `${MANUAL_RECOVERY_REQUIRED}[${context.trackingId}] Failed to delete blob container: ${accountId}, country: ${country}`, + ); + } + + this.logger.log( + `[OUT] [${context.trackingId}] ${this.deleteAccountAndData.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 7d9d664..920b877 100644 --- a/dictation_server/src/features/accounts/test/accounts.service.mock.ts +++ b/dictation_server/src/features/accounts/test/accounts.service.mock.ts @@ -345,7 +345,8 @@ export const makeDefaultAccountsRepositoryMockValue = user.account_id = 1234567890123456; user.role = 'none admin'; user.author_id = '6cce347f-0cf1-a15e-19ab-d00988b643f9'; - user.accepted_terms_version = '1.0'; + user.accepted_eula_version = '1.0'; + user.accepted_dpa_version = '1.0'; user.email_verified = true; user.auto_renew = false; user.license_alert = false; @@ -374,7 +375,8 @@ export const makeDefaultUsersRepositoryMockValue = user.account_id = 1234567890123456; user.role = 'none admin'; user.author_id = '6cce347f-0cf1-a15e-19ab-d00988b643f9'; - user.accepted_terms_version = '1.0'; + user.accepted_eula_version = '1.0'; + user.accepted_dpa_version = '1.0'; user.email_verified = true; user.auto_renew = false; user.license_alert = false; @@ -422,7 +424,8 @@ export const makeDefaultUserGroupsRepositoryMockValue = user.account_id = 1234567890123456; user.role = 'none admin'; user.author_id = '6cce347f-0cf1-a15e-19ab-d00988b643f9'; - user.accepted_terms_version = '1.0'; + user.accepted_eula_version = '1.0'; + user.accepted_dpa_version = '1.0'; user.email_verified = true; user.auto_renew = false; user.license_alert = false; diff --git a/dictation_server/src/features/accounts/types/types.ts b/dictation_server/src/features/accounts/types/types.ts index 8a8afb6..47f6536 100644 --- a/dictation_server/src/features/accounts/types/types.ts +++ b/dictation_server/src/features/accounts/types/types.ts @@ -43,8 +43,10 @@ export class CreateAccountRequest { @ApiProperty() @IsAdminPasswordvalid() adminPassword: string; - @ApiProperty({ description: '同意済み利用規約のバージョン' }) - acceptedTermsVersion: string; + @ApiProperty({ description: '同意済み利用規約のバージョン(EULA)' }) + acceptedEulaVersion: string; + @ApiProperty({ description: '同意済み利用規約のバージョン(DPA)' }) + acceptedDpaVersion: string; @ApiProperty({ description: 'reCAPTCHA Token' }) token: string; } @@ -577,3 +579,13 @@ export class DeleteAccountRequest { } export class DeleteAccountResponse {} + +export class GetAccountInfoMinimalAccessRequest { + @ApiProperty({ description: 'idトークン' }) + idToken: string; +} + +export class GetAccountInfoMinimalAccessResponse { + @ApiProperty({ description: '階層' }) + tier: number; +} diff --git a/dictation_server/src/features/auth/auth.controller.ts b/dictation_server/src/features/auth/auth.controller.ts index 83b75de..029e60e 100644 --- a/dictation_server/src/features/auth/auth.controller.ts +++ b/dictation_server/src/features/auth/auth.controller.ts @@ -41,7 +41,7 @@ export class AuthController { }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: '認証エラー', + description: '認証エラー/同意済み利用規約が最新でない場合', type: ErrorResponse, }) @ApiResponse({ 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 362212c..dbc8a6f 100644 --- a/dictation_server/src/features/files/test/files.service.mock.ts +++ b/dictation_server/src/features/files/test/files.service.mock.ts @@ -127,7 +127,8 @@ export const makeDefaultUsersRepositoryMockValue = account_id: 1234567890123456, role: 'none', author_id: '', - accepted_terms_version: '1.0', + accepted_eula_version: '1.0', + accepted_dpa_version: '1.0', email_verified: true, deleted_at: null, created_by: 'test', 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 11058ed..619aa95 100644 --- a/dictation_server/src/features/licenses/test/liscense.service.mock.ts +++ b/dictation_server/src/features/licenses/test/liscense.service.mock.ts @@ -116,7 +116,8 @@ export const makeDefaultUsersRepositoryMockValue = user1.account_id = 1234567890123456; user1.role = 'none'; user1.author_id = '6cce347f-0cf1-a15e-19ab-d00988b643f9'; - user1.accepted_terms_version = '1.0'; + user1.accepted_eula_version = '1.0'; + user1.accepted_dpa_version = '1.0'; user1.email_verified = true; user1.auto_renew = false; user1.license_alert = false; diff --git a/dictation_server/src/features/licenses/test/utility.ts b/dictation_server/src/features/licenses/test/utility.ts index 5a295e5..d0e9a89 100644 --- a/dictation_server/src/features/licenses/test/utility.ts +++ b/dictation_server/src/features/licenses/test/utility.ts @@ -5,6 +5,8 @@ import { CardLicenseIssue, LicenseAllocationHistory, LicenseOrder, + LicenseArchive, + LicenseAllocationHistoryArchive, } from '../../../repositories/licenses/entity/license.entity'; export const createLicense = async ( @@ -189,3 +191,25 @@ export const selectOrderLicense = async ( }); return { orderLicense }; }; + +/** + * テスト ユーティリティ: ライセンス退避テーブルの内容を取得する + * @param dataSource データソース + * @returns ライセンス退避テーブルの内容 + */ +export const getLicenseArchive = async ( + dataSource: DataSource, +): Promise => { + return await dataSource.getRepository(LicenseArchive).find(); +}; + +/** + * テスト ユーティリティ: ライセンス割り当て履歴退避テーブルの内容を取得する + * @param dataSource データソース + * @returns ライセンス割り当て履歴退避テーブルの内容 + */ +export const getLicenseAllocationHistoryArchive = async ( + dataSource: DataSource, +): Promise => { + return await dataSource.getRepository(LicenseAllocationHistoryArchive).find(); +}; 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 64ce597..c2df2d6 100644 --- a/dictation_server/src/features/notification/test/notification.service.mock.ts +++ b/dictation_server/src/features/notification/test/notification.service.mock.ts @@ -78,7 +78,8 @@ export const makeDefaultUsersRepositoryMockValue = user.account_id = 123; user.role = 'none'; user.author_id = undefined; - user.accepted_terms_version = '1.0'; + user.accepted_eula_version = '1.0'; + user.accepted_dpa_version = '1.0'; user.email_verified = true; user.auto_renew = false; user.license_alert = false; 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 8c9ba89..c09742f 100644 --- a/dictation_server/src/features/tasks/test/tasks.service.mock.ts +++ b/dictation_server/src/features/tasks/test/tasks.service.mock.ts @@ -423,7 +423,8 @@ const defaultTasksRepositoryMockValue: { account_id: 1, external_id: 'userId', role: 'typist', - accepted_terms_version: '', + accepted_eula_version: '', + accepted_dpa_version: '', email_verified: true, auto_renew: true, license_alert: true, diff --git a/dictation_server/src/features/terms/terms.controller.spec.ts b/dictation_server/src/features/terms/terms.controller.spec.ts new file mode 100644 index 0000000..7473f05 --- /dev/null +++ b/dictation_server/src/features/terms/terms.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TermsController } from './terms.controller'; +import { TermsService } from './terms.service'; + +describe('TermsController', () => { + let controller: TermsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TermsController], + providers: [TermsService], + }).compile(); + + controller = module.get(TermsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/dictation_server/src/features/terms/terms.controller.ts b/dictation_server/src/features/terms/terms.controller.ts new file mode 100644 index 0000000..f28d5f8 --- /dev/null +++ b/dictation_server/src/features/terms/terms.controller.ts @@ -0,0 +1,40 @@ +import { Controller, HttpStatus, Post } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { TermsService } from '../terms/terms.service'; +import { ErrorResponse } from '../../common/error/types/types'; +import { makeContext } from '../../common/log'; +import { v4 as uuidv4 } from 'uuid'; +import { GetTermsInfoResponse, TermInfo } from './types/types'; + +@ApiTags('terms') +@Controller('terms') +export class TermsController { + constructor( + private readonly termsService: TermsService, //private readonly cryptoService: CryptoService, + ) {} + + @Post() + @ApiResponse({ + status: HttpStatus.OK, + type: GetTermsInfoResponse, + description: '成功時のレスポンス', + }) + @ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: '想定外のサーバーエラー', + type: ErrorResponse, + }) + @ApiOperation({ operationId: 'getTermsInfo' }) + 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[]; + + return { termsInfo }; + } +} diff --git a/dictation_server/src/features/terms/terms.module.ts b/dictation_server/src/features/terms/terms.module.ts new file mode 100644 index 0000000..e314518 --- /dev/null +++ b/dictation_server/src/features/terms/terms.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TermsController } from './terms.controller'; +import { TermsService } from './terms.service'; + +@Module({ + controllers: [TermsController], + 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 new file mode 100644 index 0000000..6e8839b --- /dev/null +++ b/dictation_server/src/features/terms/terms.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TermsService } from './terms.service'; + +describe('TermsService', () => { + let service: TermsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TermsService], + }).compile(); + + service = module.get(TermsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/dictation_server/src/features/terms/terms.service.ts b/dictation_server/src/features/terms/terms.service.ts new file mode 100644 index 0000000..51ba395 --- /dev/null +++ b/dictation_server/src/features/terms/terms.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class TermsService {} diff --git a/dictation_server/src/features/terms/types/types.ts b/dictation_server/src/features/terms/types/types.ts new file mode 100644 index 0000000..e832cc6 --- /dev/null +++ b/dictation_server/src/features/terms/types/types.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class GetTermsInfoResponse { + termsInfo: TermInfo[]; +} + +export class TermInfo { + @ApiProperty({ description: '利用規約種別' }) + documentType: string; + @ApiProperty({ description: 'バージョン' }) + version: 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 d874872..036f759 100644 --- a/dictation_server/src/features/users/test/users.service.mock.ts +++ b/dictation_server/src/features/users/test/users.service.mock.ts @@ -356,7 +356,8 @@ export const makeDefaultUsersRepositoryMockValue = user1.account_id = 1234567890123456; user1.role = 'none'; user1.author_id = '6cce347f-0cf1-a15e-19ab-d00988b643f9'; - user1.accepted_terms_version = '1.0'; + user1.accepted_eula_version = '1.0'; + user1.accepted_dpa_version = '1.0'; user1.email_verified = true; user1.auto_renew = false; user1.license_alert = false; @@ -375,7 +376,8 @@ export const makeDefaultUsersRepositoryMockValue = user2.account_id = 1234567890123456; user2.role = 'none'; user2.author_id = '551c4077-5b55-a38c-2c55-cd1edd537aa8'; - user2.accepted_terms_version = '1.0'; + user2.accepted_eula_version = '1.0'; + user2.accepted_dpa_version = '1.0'; user2.email_verified = true; user2.auto_renew = false; user2.license_alert = false; diff --git a/dictation_server/src/features/users/types/types.ts b/dictation_server/src/features/users/types/types.ts index c3df59c..991ec09 100644 --- a/dictation_server/src/features/users/types/types.ts +++ b/dictation_server/src/features/users/types/types.ts @@ -255,3 +255,14 @@ export class DeallocateLicenseRequest { } export class DeallocateLicenseResponse {} + +export class UpdateAcceptedVersionRequest { + @ApiProperty({ description: 'IDトークン' }) + idToken: string; + @ApiProperty({ description: '更新バージョン(EULA)' }) + acceptedEULAVersion: string; + @ApiProperty({ description: '更新バージョン(DPA)', required: false }) + acceptedDPAVersion?: string | undefined; +} + +export class UpdateAcceptedVersionResponse {} diff --git a/dictation_server/src/features/users/users.controller.ts b/dictation_server/src/features/users/users.controller.ts index 11ff39c..81306ec 100644 --- a/dictation_server/src/features/users/users.controller.ts +++ b/dictation_server/src/features/users/users.controller.ts @@ -37,6 +37,8 @@ import { AllocateLicenseRequest, DeallocateLicenseResponse, DeallocateLicenseRequest, + UpdateAcceptedVersionRequest, + UpdateAcceptedVersionResponse, } from './types/types'; import { UsersService } from './users.service'; import jwt from 'jsonwebtoken'; @@ -469,4 +471,35 @@ export class UsersController { await this.usersService.deallocateLicense(context, body.userId); return {}; } + + @ApiResponse({ + status: HttpStatus.OK, + type: UpdateAcceptedVersionResponse, + description: '成功時のレスポンス', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'パラメータ不正/対象のユーザidが存在しない場合', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: '想定外のサーバーエラー', + type: ErrorResponse, + }) + @ApiOperation({ + operationId: 'updateAcceptedVersion', + description: '利用規約同意バージョンを更新', + }) + @Post('/accepted-version') + async updateAcceptedVersion( + @Body() body: UpdateAcceptedVersionRequest, + ): Promise { + const context = makeContext(uuidv4()); + + // TODO 仮実装。API実装タスクで本実装する。 + // const idToken = await this.authService.getVerifiedIdToken(body.idToken); + // await this.usersService.updateAcceptedVersion(context, idToken); + return {}; + } } diff --git a/dictation_server/src/features/users/users.service.spec.ts b/dictation_server/src/features/users/users.service.spec.ts index 04e5068..7bc2eae 100644 --- a/dictation_server/src/features/users/users.service.spec.ts +++ b/dictation_server/src/features/users/users.service.spec.ts @@ -188,7 +188,8 @@ describe('UsersService.confirmUserAndInitPassword', () => { external_id: 'TEST9999', account_id: 1, role: 'None', - accepted_terms_version: 'string', + accepted_eula_version: 'string', + accepted_dpa_version: 'string', email_verified: false, created_by: 'string;', created_at: new Date(), @@ -232,7 +233,8 @@ describe('UsersService.confirmUserAndInitPassword', () => { external_id: 'TEST9999', account_id: 1, role: 'None', - accepted_terms_version: 'string', + accepted_eula_version: 'string', + accepted_dpa_version: 'string', email_verified: false, created_by: 'string;', created_at: new Date(), @@ -272,7 +274,8 @@ describe('UsersService.confirmUserAndInitPassword', () => { external_id: 'TEST9999', account_id: 1, role: 'None', - accepted_terms_version: 'string', + accepted_eula_version: 'string', + accepted_dpa_version: 'string', email_verified: true, created_by: 'string;', created_at: new Date(), @@ -316,7 +319,8 @@ describe('UsersService.confirmUserAndInitPassword', () => { external_id: 'TEST9999', account_id: 1, role: 'None', - accepted_terms_version: 'string', + accepted_eula_version: 'string', + accepted_dpa_version: 'string', email_verified: false, created_by: 'string;', created_at: new Date(), diff --git a/dictation_server/src/features/users/users.service.ts b/dictation_server/src/features/users/users.service.ts index 94377ca..a3e9521 100644 --- a/dictation_server/src/features/users/users.service.ts +++ b/dictation_server/src/features/users/users.service.ts @@ -35,6 +35,7 @@ import { import { ADB2C_SIGN_IN_TYPE, LICENSE_EXPIRATION_THRESHOLD_DAYS, + MANUAL_RECOVERY_REQUIRED, USER_LICENSE_STATUS, USER_ROLES, } from '../../constants'; @@ -300,7 +301,7 @@ export class UsersService { } catch (error) { this.logger.error(`error=${error}`); this.logger.error( - `[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`, + `${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`, ); } } @@ -313,7 +314,7 @@ export class UsersService { } catch (error) { this.logger.error(`error=${error}`); this.logger.error( - `[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete user: ${userId}`, + `${MANUAL_RECOVERY_REQUIRED} [${context.trackingId}] Failed to delete user: ${userId}`, ); } } diff --git a/dictation_server/src/features/workflows/test/utility.ts b/dictation_server/src/features/workflows/test/utility.ts new file mode 100644 index 0000000..473f9df --- /dev/null +++ b/dictation_server/src/features/workflows/test/utility.ts @@ -0,0 +1,94 @@ +import { DataSource } from 'typeorm'; +import { Workflow } from '../../../repositories/workflows/entity/workflow.entity'; +import { WorkflowTypist } from '../../../repositories/workflows/entity/workflow_typists.entity'; + +// Workflowを作成する +export const createWorkflow = async ( + datasource: DataSource, + accountId: number, + authorId: number, + worktypeId?: number | undefined, + templateId?: number | undefined, +): Promise => { + const { identifiers } = await datasource.getRepository(Workflow).insert({ + account_id: accountId, + author_id: authorId, + worktype_id: worktypeId ?? undefined, + template_id: templateId ?? undefined, + created_by: 'test_runner', + created_at: new Date(), + updated_by: 'updater', + updated_at: new Date(), + }); + const workflow = identifiers.pop() as Workflow; + + return workflow; +}; + +// Workflow一覧を取得する +export const getWorkflows = async ( + datasource: DataSource, + accountId: number, +): Promise => { + return await datasource.getRepository(Workflow).find({ + where: { + account_id: accountId, + }, + }); +}; + +// Workflowを取得する +export const getWorkflow = async ( + datasource: DataSource, + accountId: number, + id: number, +): Promise => { + return await datasource.getRepository(Workflow).findOne({ + where: { + account_id: accountId, + id: id, + }, + }); +}; + +// Workflowを作成する +export const createWorkflowTypist = async ( + datasource: DataSource, + workflowId: number, + typistUserId?: number | undefined, + typistGroupId?: number | undefined, +): Promise => { + const { identifiers } = await datasource + .getRepository(WorkflowTypist) + .insert({ + workflow_id: workflowId, + typist_id: typistUserId ?? undefined, + typist_group_id: typistGroupId ?? undefined, + created_by: 'test_runner', + created_at: new Date(), + updated_by: 'updater', + updated_at: new Date(), + }); + const workflow = identifiers.pop() as Workflow; + + return workflow; +}; + +// WorkflowTypist一覧を取得する +export const getWorkflowTypists = async ( + datasource: DataSource, + workflowId: number, +): Promise => { + return await datasource.getRepository(WorkflowTypist).find({ + where: { + workflow_id: workflowId, + }, + }); +}; + +// WorkflowTypist一覧全件を取得する +export const getAllWorkflowTypists = async ( + datasource: DataSource, +): Promise => { + return await datasource.getRepository(WorkflowTypist).find(); +}; diff --git a/dictation_server/src/features/workflows/types/types.ts b/dictation_server/src/features/workflows/types/types.ts index 3bd82f9..ced5b59 100644 --- a/dictation_server/src/features/workflows/types/types.ts +++ b/dictation_server/src/features/workflows/types/types.ts @@ -50,7 +50,7 @@ export class WorkflowTypist { } export class CreateWorkflowsRequest { - @ApiProperty({ description: 'Authornの内部ID' }) + @ApiProperty({ description: 'Authorの内部ID' }) @Type(() => Number) @IsInt() @Min(0) @@ -79,3 +79,52 @@ export class CreateWorkflowsRequest { } export class CreateWorkflowsResponse {} + +export class UpdateWorkflowRequestParam { + @ApiProperty({ description: 'ワークフローの内部ID' }) + @Type(() => Number) + @IsInt() + @Min(0) + workflowId: number; +} + +export class UpdateWorkflowRequest { + @ApiProperty({ description: 'Authorの内部ID' }) + @Type(() => Number) + @IsInt() + @Min(0) + authorId: number; + @ApiProperty({ description: 'Worktypeの内部ID', required: false }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + worktypeId?: number | undefined; + @ApiProperty({ description: 'テンプレートの内部ID', required: false }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + templateId?: number | undefined; + @ApiProperty({ + description: 'ルーティング候補のタイピストユーザー/タイピストグループ', + type: [WorkflowTypist], + minItems: 1, + }) + @Type(() => WorkflowTypist) + @IsArray() + @ArrayMinSize(1) + typists: WorkflowTypist[]; +} + +export class UpdateWorkflowResponse {} + +export class DeleteWorkflowRequestParam { + @ApiProperty({ description: 'ワークフローの内部ID' }) + @Type(() => Number) + @IsInt() + @Min(0) + workflowId: number; +} + +export class DeleteWorkflowResponse {} diff --git a/dictation_server/src/features/workflows/workflows.controller.ts b/dictation_server/src/features/workflows/workflows.controller.ts index 9b256a7..c571842 100644 --- a/dictation_server/src/features/workflows/workflows.controller.ts +++ b/dictation_server/src/features/workflows/workflows.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, HttpStatus, + Param, Post, Req, UseGuards, @@ -20,6 +21,11 @@ import { GetWorkflowsResponse, CreateWorkflowsRequest, CreateWorkflowsResponse, + UpdateWorkflowResponse, + UpdateWorkflowRequest, + UpdateWorkflowRequestParam, + DeleteWorkflowRequestParam, + DeleteWorkflowResponse, } from './types/types'; import { AuthGuard } from '../../common/guards/auth/authguards'; import { RoleGuard } from '../../common/guards/role/roleguards'; @@ -62,9 +68,10 @@ export class WorkflowsController { const { userId } = jwt.decode(token, { json: true }) as AccessToken; const context = makeContext(userId); - console.log(context.trackingId); - return { workflows: [] }; + const workflows = await this.workflowsService.getWorkflows(context, userId); + + return { workflows }; } @ApiResponse({ @@ -99,14 +106,109 @@ export class WorkflowsController { @Req() req: Request, @Body() body: CreateWorkflowsRequest, ): Promise { - const { authorId } = body; + const { authorId, worktypeId, templateId, typists } = body; const token = retrieveAuthorizationToken(req); const { userId } = jwt.decode(token, { json: true }) as AccessToken; const context = makeContext(userId); - console.log(context.trackingId); - console.log(authorId); + await this.workflowsService.createWorkflow( + context, + userId, + authorId, + worktypeId, + templateId, + typists, + ); return {}; } + + @ApiResponse({ + status: HttpStatus.OK, + type: UpdateWorkflowResponse, + description: '成功時のレスポンス', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'パラメータ不正エラー', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: '認証エラー', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: '想定外のサーバーエラー', + type: ErrorResponse, + }) + @ApiOperation({ + operationId: 'updateWorkflow', + description: 'アカウント内のワークフローを編集します', + }) + @ApiBearerAuth() + @UseGuards(AuthGuard) + @UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] })) + @Post('/:workflowId') + async updateWorkflow( + @Req() req: Request, + @Param() param: UpdateWorkflowRequestParam, + @Body() body: UpdateWorkflowRequest, + ): Promise { + const { authorId, worktypeId, templateId, typists } = body; + const { workflowId } = param; + const token = retrieveAuthorizationToken(req); + const { userId } = jwt.decode(token, { json: true }) as AccessToken; + + const context = makeContext(userId); + await this.workflowsService.updateWorkflow( + context, + userId, + workflowId, + authorId, + worktypeId, + templateId, + typists, + ); + + return {}; + } + + @ApiResponse({ + status: HttpStatus.OK, + type: DeleteWorkflowResponse, + description: '成功時のレスポンス', + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: '認証エラー', + type: ErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: '想定外のサーバーエラー', + type: ErrorResponse, + }) + @ApiOperation({ + operationId: 'deleteWorkflow', + description: 'アカウント内のワークフローを削除します', + }) + @ApiBearerAuth() + @UseGuards(AuthGuard) + @UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] })) + @Post('/:workflowId/delete') + async deleteWorkflow( + @Req() req: Request, + @Param() param: DeleteWorkflowRequestParam, + ): Promise { + const { workflowId } = param; + const token = retrieveAuthorizationToken(req); + const { userId } = jwt.decode(token, { json: true }) as AccessToken; + + const context = makeContext(userId); + console.log(workflowId); + console.log(context.trackingId); + return {}; + } } diff --git a/dictation_server/src/features/workflows/workflows.module.ts b/dictation_server/src/features/workflows/workflows.module.ts index f0547ff..9a25872 100644 --- a/dictation_server/src/features/workflows/workflows.module.ts +++ b/dictation_server/src/features/workflows/workflows.module.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { UsersRepositoryModule } from '../../repositories/users/users.repository.module'; import { WorkflowsController } from './workflows.controller'; import { WorkflowsService } from './workflows.service'; +import { WorkflowsRepositoryModule } from '../../repositories/workflows/workflows.repository.module'; +import { AdB2cModule } from '../../gateways/adb2c/adb2c.module'; @Module({ - imports: [UsersRepositoryModule], + imports: [UsersRepositoryModule, WorkflowsRepositoryModule, AdB2cModule], providers: [WorkflowsService], controllers: [WorkflowsController], }) diff --git a/dictation_server/src/features/workflows/workflows.service.spec.ts b/dictation_server/src/features/workflows/workflows.service.spec.ts new file mode 100644 index 0000000..10e5714 --- /dev/null +++ b/dictation_server/src/features/workflows/workflows.service.spec.ts @@ -0,0 +1,2002 @@ +import { DataSource } from 'typeorm'; +import { makeTestingModule } from '../../common/test/modules'; +import { makeTestAccount, makeTestUser } from '../../common/test/utility'; +import { makeContext } from '../../common/log'; +import { WorkflowsService } from './workflows.service'; +import { USER_ROLES } from '../../constants'; +import { createTemplateFile } from '../templates/test/utility'; +import { createWorktype } from '../accounts/test/utility'; +import { + createWorkflow, + createWorkflowTypist, + getAllWorkflowTypists, + getWorkflowTypists, + getWorkflows, +} from './test/utility'; +import { createUserGroup } from '../users/test/utility'; +import { overrideAdB2cService } from '../../common/test/overrides'; +import { WorkflowsRepositoryService } from '../../repositories/workflows/workflows.repository.service'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; + +describe('getWorkflows', () => { + let source: DataSource = 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 () => { + await source.destroy(); + source = null; + }); + + it('アカウント内のWorkflow一覧を取得できる', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: authorId3 } = await makeTestUser(source, { + external_id: 'author3', + author_id: 'AUTHOR3', + account_id: account.id, + role: USER_ROLES.AUTHOR, + }); + const { id: typistId, external_id: typistExternalId } = await makeTestUser( + source, + { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }, + ); + const { userGroupId } = await createUserGroup( + source, + account.id, + 'group1', + [typistId], + ); + + const { id: worktypeId1 } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + const { id: templateId1 } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + const workflow1 = await createWorkflow( + source, + account.id, + authorId1, + worktypeId1, + templateId1, + ); + const workflow2 = await createWorkflow( + source, + account.id, + authorId2, + undefined, + templateId1, + ); + const workflow3 = await createWorkflow( + source, + account.id, + authorId3, + worktypeId1, + undefined, + ); + + await createWorkflowTypist(source, workflow1.id, typistId, undefined); + await createWorkflowTypist(source, workflow2.id, undefined, userGroupId); + await createWorkflowTypist(source, workflow3.id, undefined, userGroupId); + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(3); + expect(workflows[0].id).toBe(workflow1.id); + expect(workflows[0].author_id).toBe(authorId1); + expect(workflows[0].worktype_id).toBe(worktypeId1); + expect(workflows[0].template_id).toBe(templateId1); + + expect(workflows[1].id).toBe(workflow2.id); + expect(workflows[1].author_id).toBe(authorId2); + expect(workflows[1].worktype_id).toBe(null); + expect(workflows[1].template_id).toBe(templateId1); + + expect(workflows[2].id).toBe(workflow3.id); + expect(workflows[2].author_id).toBe(authorId3); + expect(workflows[2].worktype_id).toBe(worktypeId1); + expect(workflows[2].template_id).toBe(null); + } + + overrideAdB2cService(service, { + getUsers: async () => [{ id: typistExternalId, displayName: 'typist1' }], + }); + + const resWorkflows = await service.getWorkflows(context, admin.external_id); + + //実行結果を確認 + { + expect(resWorkflows.length).toBe(3); + 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].typists.length).toBe(1); + expect(resWorkflows[0].typists[0].typistUserId).toBe(typistId); + expect(resWorkflows[0].typists[0].typistName).toBe('typist1'); + + expect(resWorkflows[1].id).toBe(workflow2.id); + 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].typists.length).toBe(1); + expect(resWorkflows[1].typists[0].typistGroupId).toBe(userGroupId); + expect(resWorkflows[1].typists[0].typistName).toBe('group1'); + + 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].template).toBe(undefined); + expect(resWorkflows[2].typists.length).toBe(1); + expect(resWorkflows[2].typists[0].typistGroupId).toBe(userGroupId); + expect(resWorkflows[2].typists[0].typistName).toBe('group1'); + } + }); + + it('アカウント内のWorkflow一覧を取得できる(0件)', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + const { admin } = await makeTestAccount(source, { tier: 5 }); + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + overrideAdB2cService(service, { + getUsers: async () => [], + }); + + const resWorkflows = await service.getWorkflows(context, admin.external_id); + + //実行結果を確認 + { + expect(resWorkflows.length).toBe(0); + } + }); + + it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //DBアクセスに失敗するようにする + const templatesService = module.get( + WorkflowsRepositoryService, + ); + templatesService.getWorkflows = jest.fn().mockRejectedValue('DB failed'); + + //実行結果を確認 + try { + await service.getWorkflows(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(); + } + } + }); +}); + +describe('createWorkflows', () => { + let source: DataSource = 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 () => { + await source.destroy(); + source = null; + }); + + it('アカウント内にWorkflowを作成できる(WorktypeIDあり、テンプレートファイルあり)', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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 { id: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + const { id: templateId } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(0); + expect(workflowTypists.length).toBe(0); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + await service.createWorkflow( + context, + admin.external_id, + authorId, + worktypeId, + templateId, + [ + { + typistId: typistId, + }, + ], + ); + + //実行結果を確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId); + expect(workflows[0].worktype_id).toBe(worktypeId); + expect(workflows[0].template_id).toBe(templateId); + + const workflowTypists = await getWorkflowTypists(source, workflows[0].id); + expect(workflowTypists.length).toBe(1); + expect(workflowTypists[0].typist_id).toBe(typistId); + expect(workflowTypists[0].typist_group_id).toBe(null); + } + }); + + it('アカウント内にWorkflowを作成できる(WorktypeIDなし、テンプレートファイルあり)', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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 { id: templateId } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(0); + expect(workflowTypists.length).toBe(0); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + await service.createWorkflow( + context, + admin.external_id, + authorId, + undefined, + templateId, + [ + { + typistId: typistId, + }, + ], + ); + + //実行結果を確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId); + expect(workflows[0].worktype_id).toBe(null); + expect(workflows[0].template_id).toBe(templateId); + + const workflowTypists = await getWorkflowTypists(source, workflows[0].id); + expect(workflowTypists.length).toBe(1); + expect(workflowTypists[0].typist_id).toBe(typistId); + expect(workflowTypists[0].typist_group_id).toBe(null); + } + }); + + it('アカウント内にWorkflowを作成できる(WorktypeIDあり、テンプレートファイルなし)', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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 { id: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(0); + expect(workflowTypists.length).toBe(0); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + await service.createWorkflow( + context, + admin.external_id, + authorId, + worktypeId, + undefined, + [ + { + typistId: typistId, + }, + ], + ); + + //実行結果を確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId); + expect(workflows[0].worktype_id).toBe(worktypeId); + expect(workflows[0].template_id).toBe(null); + + const workflowTypists = await getWorkflowTypists(source, workflows[0].id); + expect(workflowTypists.length).toBe(1); + expect(workflowTypists[0].typist_id).toBe(typistId); + expect(workflowTypists[0].typist_group_id).toBe(null); + } + }); + + it('アカウント内にWorkflowを作成できる(WorktypeIDなし、テンプレートファイルなし)', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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 workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(0); + expect(workflowTypists.length).toBe(0); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + await service.createWorkflow( + context, + admin.external_id, + authorId, + undefined, + undefined, + [ + { + typistId: typistId, + }, + ], + ); + + //実行結果を確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId); + expect(workflows[0].worktype_id).toBe(null); + expect(workflows[0].template_id).toBe(null); + + const workflowTypists = await getWorkflowTypists(source, workflows[0].id); + expect(workflowTypists.length).toBe(1); + expect(workflowTypists[0].typist_id).toBe(typistId); + expect(workflowTypists[0].typist_group_id).toBe(null); + } + }); + + it('アカウント内にWorkflowを作成できる(WorktypeIDなし、テンプレートファイルなし、同一AuthorIDのワークフローあり)', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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 { id: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(0); + expect(workflowTypists.length).toBe(0); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + // 同一AuthorIDのワークフローを作成 + await service.createWorkflow( + context, + admin.external_id, + authorId, + worktypeId, + undefined, + [ + { + typistId: typistId, + }, + ], + ); + + await service.createWorkflow( + context, + admin.external_id, + authorId, + undefined, + undefined, + [ + { + typistId: typistId, + }, + ], + ); + + //実行結果を確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(2); + expect(workflows[1].account_id).toBe(account.id); + expect(workflows[1].author_id).toBe(authorId); + expect(workflows[1].worktype_id).toBe(null); + expect(workflows[1].template_id).toBe(null); + + const workflowTypists = await getWorkflowTypists(source, workflows[1].id); + expect(workflowTypists.length).toBe(1); + expect(workflowTypists[0].typist_id).toBe(typistId); + expect(workflowTypists[0].typist_group_id).toBe(null); + } + }); + + it('DBにAuthorが存在しない場合、400エラーとなること', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + const { account, admin } = await makeTestAccount(source, { tier: 5 }); + + const { id: typistId } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const { id: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + const { id: templateId } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(0); + expect(workflowTypists.length).toBe(0); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.createWorkflow( + context, + admin.external_id, + 0, + worktypeId, + templateId, + [ + { + typistId: typistId, + }, + ], + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010204')); + } else { + fail(); + } + } + }); + + it('DBにWorktypeIDが存在しない場合、400エラーとなること', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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 { id: templateId } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(0); + expect(workflowTypists.length).toBe(0); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.createWorkflow( + context, + admin.external_id, + authorId, + 9999, + templateId, + [ + { + typistId: typistId, + }, + ], + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E011003')); + } else { + fail(); + } + } + }); + + it('DBにテンプレートファイルが存在しない場合、400エラーとなること', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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 { id: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(0); + expect(workflowTypists.length).toBe(0); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.createWorkflow( + context, + admin.external_id, + authorId, + worktypeId, + 9999, + [ + { + typistId: typistId, + }, + ], + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E012001')); + } else { + fail(); + } + } + }); + + it('DBにルーティング候補ユーザーが存在しない場合、400エラーとなること', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + const { id: templateId } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(0); + expect(workflowTypists.length).toBe(0); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.createWorkflow( + context, + admin.external_id, + authorId, + worktypeId, + templateId, + [ + { + typistId: 9999, + }, + ], + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010204')); + } else { + fail(); + } + } + }); + + it('DBにルーティング候補グループが存在しない場合、400エラーとなること', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + const { id: templateId } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(0); + expect(workflowTypists.length).toBe(0); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.createWorkflow( + context, + admin.external_id, + authorId, + worktypeId, + templateId, + [ + { + typistGroupId: 9999, + }, + ], + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010908')); + } else { + fail(); + } + } + }); + + it('DBにAuthorIDとWorktypeIDのペアがすでに存在する場合、400エラーとなること', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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 { id: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + const { id: templateId } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + await createWorkflow(source, account.id, authorId, worktypeId, templateId); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId); + expect(workflows[0].worktype_id).toBe(worktypeId); + expect(workflows[0].template_id).toBe(templateId); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.createWorkflow( + context, + admin.external_id, + authorId, + worktypeId, + templateId, + [ + { + typistId: typistId, + }, + ], + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E013001')); + } else { + fail(); + } + } + }); + + it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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 { id: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + const { id: templateId } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(0); + expect(workflowTypists.length).toBe(0); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //DBアクセスに失敗するようにする + const templatesService = module.get( + WorkflowsRepositoryService, + ); + templatesService.createtWorkflows = jest + .fn() + .mockRejectedValue('DB failed'); + + //実行結果を確認 + try { + await service.createWorkflow( + context, + admin.external_id, + authorId, + worktypeId, + templateId, + [ + { + typistId: typistId, + }, + ], + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + }); +}); + +describe('updateWorkflow', () => { + let source: DataSource = 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 () => { + await source.destroy(); + source = null; + }); + + it('アカウント内のWorkflowを更新できる(WorktypeIDあり、テンプレートファイルあり)', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: typistId1 } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + const { id: typistId2 } = await makeTestUser(source, { + external_id: 'typist12', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const { id: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + const { id: templateId } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + const preWorkflow = await createWorkflow( + source, + account.id, + authorId1, + undefined, + undefined, + ); + await createWorkflowTypist(source, preWorkflow.id, typistId1); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(1); + expect(workflows[0].id).toBe(preWorkflow.id); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId1); + expect(workflows[0].worktype_id).toBe(null); + expect(workflows[0].template_id).toBe(null); + expect(workflowTypists.length).toBe(1); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + await service.updateWorkflow( + context, + admin.external_id, + preWorkflow.id, + authorId2, + worktypeId, + templateId, + [ + { + typistId: typistId2, + }, + ], + ); + + //実行結果を確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId2); + expect(workflows[0].worktype_id).toBe(worktypeId); + expect(workflows[0].template_id).toBe(templateId); + + const workflowTypists = await getWorkflowTypists(source, workflows[0].id); + expect(workflowTypists.length).toBe(1); + expect(workflowTypists[0].typist_id).toBe(typistId2); + } + }); + + it('アカウント内にWorkflowを作成できる(WorktypeIDなし、テンプレートファイルあり)', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: typistId1 } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + const { id: typistId2 } = await makeTestUser(source, { + external_id: 'typist12', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const { id: templateId } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + const preWorkflow = await createWorkflow( + source, + account.id, + authorId1, + undefined, + undefined, + ); + await createWorkflowTypist(source, preWorkflow.id, typistId1); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(1); + expect(workflows[0].id).toBe(preWorkflow.id); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId1); + expect(workflows[0].worktype_id).toBe(null); + expect(workflows[0].template_id).toBe(null); + expect(workflowTypists.length).toBe(1); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + await service.updateWorkflow( + context, + admin.external_id, + preWorkflow.id, + authorId2, + undefined, + templateId, + [ + { + typistId: typistId2, + }, + ], + ); + + //実行結果を確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId2); + expect(workflows[0].worktype_id).toBe(null); + expect(workflows[0].template_id).toBe(templateId); + + const workflowTypists = await getWorkflowTypists(source, workflows[0].id); + expect(workflowTypists.length).toBe(1); + expect(workflowTypists[0].typist_id).toBe(typistId2); + } + }); + + it('アカウント内にWorkflowを作成できる(WorktypeIDあり、テンプレートファイルなし)', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: typistId1 } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + const { id: typistId2 } = await makeTestUser(source, { + external_id: 'typist12', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const { id: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + const preWorkflow = await createWorkflow( + source, + account.id, + authorId1, + undefined, + undefined, + ); + await createWorkflowTypist(source, preWorkflow.id, typistId1); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(1); + expect(workflows[0].id).toBe(preWorkflow.id); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId1); + expect(workflows[0].worktype_id).toBe(null); + expect(workflows[0].template_id).toBe(null); + expect(workflowTypists.length).toBe(1); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + await service.updateWorkflow( + context, + admin.external_id, + preWorkflow.id, + authorId2, + worktypeId, + undefined, + [ + { + typistId: typistId2, + }, + ], + ); + + //実行結果を確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId2); + expect(workflows[0].worktype_id).toBe(worktypeId); + expect(workflows[0].template_id).toBe(null); + + const workflowTypists = await getWorkflowTypists(source, workflows[0].id); + expect(workflowTypists.length).toBe(1); + expect(workflowTypists[0].typist_id).toBe(typistId2); + } + }); + + it('アカウント内にWorkflowを作成できる(WorktypeIDなし、テンプレートファイルなし)', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: typistId1 } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + const { id: typistId2 } = await makeTestUser(source, { + external_id: 'typist12', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const preWorkflow = await createWorkflow( + source, + account.id, + authorId1, + undefined, + undefined, + ); + await createWorkflowTypist(source, preWorkflow.id, typistId1); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(1); + expect(workflows[0].id).toBe(preWorkflow.id); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId1); + expect(workflows[0].worktype_id).toBe(null); + expect(workflows[0].template_id).toBe(null); + expect(workflowTypists.length).toBe(1); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + await service.updateWorkflow( + context, + admin.external_id, + preWorkflow.id, + authorId2, + undefined, + undefined, + [ + { + typistId: typistId2, + }, + ], + ); + + //実行結果を確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId2); + expect(workflows[0].worktype_id).toBe(null); + expect(workflows[0].template_id).toBe(null); + + const workflowTypists = await getWorkflowTypists(source, workflows[0].id); + expect(workflowTypists.length).toBe(1); + expect(workflowTypists[0].typist_id).toBe(typistId2); + } + }); + + it('アカウント内にWorkflowを作成できる(WorktypeIDなし、テンプレートファイルなし、同一AuthorIDのワークフローあり)', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: typistId1 } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + const { id: typistId2 } = await makeTestUser(source, { + external_id: 'typist12', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + const { id: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + // 更新対象のワークフローを作成 + const preWorkflow1 = await createWorkflow( + source, + account.id, + authorId1, + undefined, + undefined, + ); + // 同一AuthorIDのワークフローを作成 + const preWorkflow2 = await createWorkflow( + source, + account.id, + authorId2, // 更新するAuthorIDと同じ + worktypeId, + undefined, + ); + + await createWorkflowTypist(source, preWorkflow1.id, typistId1); + await createWorkflowTypist(source, preWorkflow2.id, typistId1); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(2); + expect(workflows[0].id).toBe(preWorkflow1.id); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId1); + expect(workflows[0].worktype_id).toBe(null); + expect(workflows[0].template_id).toBe(null); + expect(workflows[1].id).toBe(preWorkflow2.id); + expect(workflows[1].account_id).toBe(account.id); + expect(workflows[1].author_id).toBe(authorId2); + expect(workflows[1].worktype_id).toBe(worktypeId); + expect(workflows[1].template_id).toBe(null); + expect(workflowTypists.length).toBe(2); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + await service.updateWorkflow( + context, + admin.external_id, + preWorkflow1.id, + authorId2, + undefined, + undefined, + [ + { + typistId: typistId2, + }, + ], + ); + + //実行結果を確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(2); + expect(workflows[1].account_id).toBe(account.id); + expect(workflows[1].author_id).toBe(authorId2); + expect(workflows[1].worktype_id).toBe(null); + expect(workflows[1].template_id).toBe(null); + + const workflowTypists = await getWorkflowTypists(source, workflows[1].id); + expect(workflowTypists.length).toBe(1); + expect(workflowTypists[0].typist_id).toBe(typistId2); + } + }); + + it('DBにWorkflowが存在しない場合、400エラーとなること', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: typistId1 } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.updateWorkflow( + context, + admin.external_id, + 9999, + authorId1, + undefined, + undefined, + [ + { + typistId: typistId1, + }, + ], + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E013002')); + } else { + fail(); + } + } + }); + it('DBにAuthorが存在しない場合、400エラーとなること', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: typistId1 } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const { id: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + const { id: templateId } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + const preWorkflow = await createWorkflow( + source, + account.id, + authorId1, + undefined, + undefined, + ); + await createWorkflowTypist(source, preWorkflow.id, typistId1); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.updateWorkflow( + context, + admin.external_id, + preWorkflow.id, + 9999, + worktypeId, + templateId, + [ + { + typistId: typistId1, + }, + ], + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010204')); + } else { + fail(); + } + } + }); + + it('DBにWorktypeIDが存在しない場合、400エラーとなること', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: typistId1 } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const { id: templateId } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + const preWorkflow = await createWorkflow( + source, + account.id, + authorId1, + undefined, + undefined, + ); + await createWorkflowTypist(source, preWorkflow.id, typistId1); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.updateWorkflow( + context, + admin.external_id, + preWorkflow.id, + authorId1, + 9999, + templateId, + [ + { + typistId: typistId1, + }, + ], + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E011003')); + } else { + fail(); + } + } + }); + + it('DBにテンプレートファイルが存在しない場合、400エラーとなること', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: typistId1 } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const { id: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + const preWorkflow = await createWorkflow( + source, + account.id, + authorId1, + undefined, + undefined, + ); + await createWorkflowTypist(source, preWorkflow.id, typistId1); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.updateWorkflow( + context, + admin.external_id, + preWorkflow.id, + authorId1, + worktypeId, + 9999, + [ + { + typistId: typistId1, + }, + ], + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E012001')); + } else { + fail(); + } + } + }); + + it('DBにルーティング候補ユーザーが存在しない場合、400エラーとなること', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: typistId1 } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const { id: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + const { id: templateId } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + const preWorkflow = await createWorkflow( + source, + account.id, + authorId1, + undefined, + undefined, + ); + await createWorkflowTypist(source, preWorkflow.id, typistId1); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.updateWorkflow( + context, + admin.external_id, + preWorkflow.id, + authorId1, + worktypeId, + templateId, + [ + { + typistId: 9999, + }, + ], + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010204')); + } else { + fail(); + } + } + }); + + it('DBにルーティング候補グループが存在しない場合、400エラーとなること', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: typistId1 } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const { id: worktypeId } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + const { id: templateId } = await createTemplateFile( + source, + account.id, + 'fileName1', + 'url1', + ); + + const preWorkflow = await createWorkflow( + source, + account.id, + authorId1, + undefined, + undefined, + ); + await createWorkflowTypist(source, preWorkflow.id, typistId1); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(1); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.updateWorkflow( + context, + admin.external_id, + preWorkflow.id, + authorId1, + worktypeId, + templateId, + [ + { + typistGroupId: 9999, + }, + ], + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010908')); + } else { + fail(); + } + } + }); + + it('DBにAuthorIDとWorktypeIDのペアがすでに存在する場合、400エラーとなること', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: typistId1 } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + const { id: worktypeId1 } = await createWorktype( + source, + account.id, + 'worktype1', + ); + + await createWorkflow(source, account.id, authorId1, worktypeId1, undefined); + + const preWorkflow = await createWorkflow( + source, + account.id, + authorId1, + undefined, + undefined, + ); + await createWorkflowTypist(source, preWorkflow.id, typistId1); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + expect(workflows.length).toBe(2); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //実行結果を確認 + try { + await service.updateWorkflow( + context, + admin.external_id, + preWorkflow.id, + authorId1, + worktypeId1, + undefined, + [ + { + typistId: typistId1, + }, + ], + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E013001')); + } else { + fail(); + } + } + }); + + it('DBアクセスに失敗した場合、500エラーを返却する', async () => { + const module = await makeTestingModule(source); + // 第五階層のアカウント作成 + 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: typistId1 } = await makeTestUser(source, { + external_id: 'typist1', + account_id: account.id, + role: USER_ROLES.TYPIST, + }); + + const preWorkflow = await createWorkflow( + source, + account.id, + authorId1, + undefined, + undefined, + ); + await createWorkflowTypist(source, preWorkflow.id, typistId1); + + //作成したデータを確認 + { + const workflows = await getWorkflows(source, account.id); + const workflowTypists = await getAllWorkflowTypists(source); + expect(workflows.length).toBe(1); + expect(workflows[0].id).toBe(preWorkflow.id); + expect(workflows[0].account_id).toBe(account.id); + expect(workflows[0].author_id).toBe(authorId1); + expect(workflows[0].worktype_id).toBe(null); + expect(workflows[0].template_id).toBe(null); + expect(workflowTypists.length).toBe(1); + } + + const service = module.get(WorkflowsService); + const context = makeContext(admin.external_id); + + //DBアクセスに失敗するようにする + const workflowsRepositoryService = module.get( + WorkflowsRepositoryService, + ); + workflowsRepositoryService.updatetWorkflow = jest + .fn() + .mockRejectedValue('DB failed'); + + //実行結果を確認 + try { + await service.updateWorkflow( + context, + admin.external_id, + preWorkflow.id, + authorId1, + undefined, + undefined, + [ + { + typistId: typistId1, + }, + ], + ); + } 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 9b32526..288629a 100644 --- a/dictation_server/src/features/workflows/workflows.service.ts +++ b/dictation_server/src/features/workflows/workflows.service.ts @@ -1,7 +1,293 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { WorkflowsRepositoryService } from '../../repositories/workflows/workflows.repository.service'; +import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; +import { Context } from '../../common/log'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; +import { Workflow, WorkflowTypist } from './types/types'; +import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; +import { UserNotFoundError } from '../../repositories/users/errors/types'; +import { TypistGroupNotExistError } from '../../repositories/user_groups/errors/types'; +import { WorktypeIdNotFoundError } from '../../repositories/worktypes/errors/types'; +import { TemplateFileNotExistError } from '../../repositories/template_files/errors/types'; +import { + AuthorIdAndWorktypeIdPairAlreadyExistsError, + WorkflowIdNotFoundError, +} from '../../repositories/workflows/errors/types'; @Injectable() export class WorkflowsService { private readonly logger = new Logger(WorkflowsService.name); - constructor() {} + constructor( + private readonly usersRepository: UsersRepositoryService, + private readonly workflowsRepository: WorkflowsRepositoryService, + private readonly adB2cService: AdB2cService, + ) {} + /** + * ワークフロー一覧を取得する + * @param context + * @param externalId + * @returns workflows + */ + async getWorkflows( + context: Context, + externalId: string, + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.getWorkflows.name} | params: { externalId: ${externalId} };`, + ); + try { + const { account_id: accountId } = + await this.usersRepository.findUserByExternalId(externalId); + + // DBからワークフロー一覧を取得 + const workflowRecords = await this.workflowsRepository.getWorkflows( + accountId, + ); + + // ワークフロー一覧からtypistのexternalIdを取得 + const externalIds = workflowRecords.flatMap((workflow) => { + const workflowTypists = workflow.workflowTypists.flatMap( + (workflowTypist) => { + const { typist } = workflowTypist; + return typist ? [typist?.external_id] : []; + }, + ); + return workflowTypists; + }); + const distinctedExternalIds = [...new Set(externalIds)]; + + // ADB2Cからユーザー一覧を取得 + const adb2cUsers = await this.adB2cService.getUsers( + context, + distinctedExternalIds, + ); + + // DBから取得したワークフロー一覧を整形 + const workflows = workflowRecords.map((workflow) => { + const { id, author, worktype, template, workflowTypists } = workflow; + + const authorId = { id: author.id, authorId: author.author_id }; + const worktypeId = worktype + ? { id: worktype.id, worktypeId: worktype.custom_worktype_id } + : undefined; + const templateId = template + ? { id: template.id, fileName: template.file_name } + : undefined; + + // ルーティング候補を整形 + const typists = workflowTypists.map((workflowTypist) => { + const { typist, typistGroup } = workflowTypist; + + // typistがユーザーの場合はADB2Cからユーザー名を取得 + const typistName = typist + ? adb2cUsers.find( + (adb2cUser) => adb2cUser.id === typist.external_id, + ).displayName + : typistGroup.name; + + return { + typistUserId: typist?.id, + typistGroupId: typistGroup?.id, + typistName, + }; + }); + + return { + id, + author: authorId, + worktype: worktypeId, + template: templateId, + typists, + }; + }); + + return workflows; + } 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.getWorkflows.name}`, + ); + } + } + + /** + * ワークフローを作成する + * @param context + * @param externalId + * @param authorId + * @param worktypeId + * @param templateId + * @param typists + * @returns workflow + */ + async createWorkflow( + context: Context, + externalId: string, + authorId: number, + worktypeId?: number | undefined, + templateId?: number | undefined, + typists?: WorkflowTypist[], + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.createWorkflow.name} | | params: { ` + + `externalId: ${externalId}, ` + + `authorId: ${authorId}, ` + + `worktypeId: ${worktypeId}, ` + + `templateId: ${templateId}, ` + + `typists: ${JSON.stringify(typists)} };`, + ); + try { + const { account_id: accountId } = + await this.usersRepository.findUserByExternalId(externalId); + + await this.workflowsRepository.createtWorkflows( + accountId, + authorId, + worktypeId, + templateId, + typists, + ); + } 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 TypistGroupNotExistError: + throw new HttpException( + makeErrorResponse('E010908'), + HttpStatus.BAD_REQUEST, + ); + case WorktypeIdNotFoundError: + throw new HttpException( + makeErrorResponse('E011003'), + HttpStatus.BAD_REQUEST, + ); + case TemplateFileNotExistError: + throw new HttpException( + makeErrorResponse('E012001'), + HttpStatus.BAD_REQUEST, + ); + case AuthorIdAndWorktypeIdPairAlreadyExistsError: + throw new HttpException( + makeErrorResponse('E013001'), + 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.createWorkflow.name}`, + ); + } + } + /** + * アカウント内のワークフローを更新する + * @param context + * @param externalId + * @param workflowId + * @param authorId + * @param [worktypeId] + * @param [templateId] + * @param [typists] + * @returns workflow + */ + async updateWorkflow( + context: Context, + externalId: string, + workflowId: number, + authorId: number, + worktypeId?: number | undefined, + templateId?: number | undefined, + typists?: WorkflowTypist[], + ): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.updateWorkflow.name} | params: { ` + + `externalId: ${externalId}, ` + + `workflowId: ${workflowId}, ` + + `authorId: ${authorId}, ` + + `worktypeId: ${worktypeId}, ` + + `templateId: ${templateId}, ` + + `typists: ${JSON.stringify(typists)} };`, + ); + try { + const { account_id: accountId } = + await this.usersRepository.findUserByExternalId(externalId); + + await this.workflowsRepository.updatetWorkflow( + accountId, + workflowId, + authorId, + worktypeId, + templateId, + typists, + ); + } catch (e) { + this.logger.error(`[${context.trackingId}] error=${e}`); + if (e instanceof Error) { + switch (e.constructor) { + case WorkflowIdNotFoundError: + throw new HttpException( + makeErrorResponse('E013002'), + HttpStatus.BAD_REQUEST, + ); + case UserNotFoundError: + throw new HttpException( + makeErrorResponse('E010204'), + HttpStatus.BAD_REQUEST, + ); + case TypistGroupNotExistError: + throw new HttpException( + makeErrorResponse('E010908'), + HttpStatus.BAD_REQUEST, + ); + case WorktypeIdNotFoundError: + throw new HttpException( + makeErrorResponse('E011003'), + HttpStatus.BAD_REQUEST, + ); + case TemplateFileNotExistError: + throw new HttpException( + makeErrorResponse('E012001'), + HttpStatus.BAD_REQUEST, + ); + case AuthorIdAndWorktypeIdPairAlreadyExistsError: + throw new HttpException( + makeErrorResponse('E013001'), + 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.updateWorkflow.name}`, + ); + } + } } diff --git a/dictation_server/src/gateways/adb2c/adb2c.service.ts b/dictation_server/src/gateways/adb2c/adb2c.service.ts index d1000c5..254acea 100644 --- a/dictation_server/src/gateways/adb2c/adb2c.service.ts +++ b/dictation_server/src/gateways/adb2c/adb2c.service.ts @@ -254,6 +254,31 @@ export class AdB2cService { this.logger.log(`[OUT] [${context.trackingId}] ${this.deleteUser.name}`); } } + + /** + * Azure AD B2Cからユーザ情報を削除する(複数) + * @param externalIds 外部ユーザーID + * @param context コンテキスト + */ + async deleteUsers(externalIds: string[], context: Context): Promise { + this.logger.log( + `[IN] [${context.trackingId}] ${this.deleteUsers.name} | params: { externalIds: ${externalIds} };`, + ); + + try { + // 複数ユーザーを一括削除する方法が不明なため、rate limitの懸念があるのを承知のうえ単一削除の繰り返しで実装 + // TODO 一括削除する方法が判明したら修正する + // https://learn.microsoft.com/en-us/graph/api/user-delete?view=graph-rest-1.0&tabs=javascript#example + externalIds.map( + async (x) => await this.graphClient.api(`users/${x}`).delete(), + ); + } catch (e) { + this.logger.error(`error=${e}`); + throw e; + } finally { + this.logger.log(`[OUT] [${context.trackingId}] ${this.deleteUsers.name}`); + } + } } // TODO [Task2002] 文字列の配列を15要素ずつ区切る(この処理も別タスクで削除予定) diff --git a/dictation_server/src/gateways/blobstorage/blobstorage.service.ts b/dictation_server/src/gateways/blobstorage/blobstorage.service.ts index 4f81b5f..57dd908 100644 --- a/dictation_server/src/gateways/blobstorage/blobstorage.service.ts +++ b/dictation_server/src/gateways/blobstorage/blobstorage.service.ts @@ -51,14 +51,9 @@ export class BlobstorageService { this.configService.get('STORAGE_ACCOUNT_ENDPOINT_EU'), this.sharedKeyCredentialEU, ); - - const expireTime = Number( + this.sasTokenExpireHour = Number( this.configService.get('STORAGE_TOKEN_EXPIRE_TIME'), ); - if (Number.isNaN(expireTime)) { - throw new Error(`STORAGE_TOKEN_EXPIRE_TIME is invalid value NaN`); - } - this.sasTokenExpireHour = expireTime; } /** diff --git a/dictation_server/src/repositories/accounts/accounts.repository.service.ts b/dictation_server/src/repositories/accounts/accounts.repository.service.ts index 5da84e7..39fe067 100644 --- a/dictation_server/src/repositories/accounts/accounts.repository.service.ts +++ b/dictation_server/src/repositories/accounts/accounts.repository.service.ts @@ -10,9 +10,15 @@ import { UpdateResult, EntityManager, } from 'typeorm'; -import { User } from '../users/entity/user.entity'; +import { User, UserArchive } from '../users/entity/user.entity'; import { Account } from './entity/account.entity'; -import { License, LicenseOrder } from '../licenses/entity/license.entity'; +import { + License, + LicenseAllocationHistory, + LicenseAllocationHistoryArchive, + LicenseArchive, + LicenseOrder, +} from '../licenses/entity/license.entity'; import { SortCriteria } from '../sort_criteria/entity/sort_criteria.entity'; import { getDirection, @@ -100,7 +106,8 @@ export class AccountsRepositoryService { * @param tier * @param adminExternalUserId * @param adminUserRole - * @param adminUserAcceptedTermsVersion + * @param adminUserAcceptedEulaVersion + * @param adminUserAcceptedDpaVersion * @returns account/admin user */ async createAccount( @@ -110,7 +117,8 @@ export class AccountsRepositoryService { tier: number, adminExternalUserId: string, adminUserRole: string, - adminUserAcceptedTermsVersion: string, + adminUserAcceptedEulaVersion: string, + adminUserAcceptedDpaVersion: string, ): Promise<{ newAccount: Account; adminUser: User }> { return await this.dataSource.transaction(async (entityManager) => { const account = new Account(); @@ -130,7 +138,8 @@ export class AccountsRepositoryService { user.account_id = persistedAccount.id; user.external_id = adminExternalUserId; user.role = adminUserRole; - user.accepted_terms_version = adminUserAcceptedTermsVersion; + user.accepted_eula_version = adminUserAcceptedEulaVersion; + user.accepted_dpa_version = adminUserAcceptedDpaVersion; } const usersRepo = entityManager.getRepository(User); const newUser = usersRepo.create(user); @@ -902,4 +911,65 @@ export class AccountsRepositoryService { ); }); } + + /** + * 指定されたアカウントを削除する + * @param accountId + * @returns users 削除対象のユーザー + */ + async deleteAccountAndInsertArchives(accountId: number): Promise { + return await this.dataSource.transaction(async (entityManager) => { + // 削除対象のユーザーを退避テーブルに退避 + const users = await this.dataSource.getRepository(User).find({ + where: { + account_id: accountId, + }, + }); + const userArchiveRepo = entityManager.getRepository(UserArchive); + await userArchiveRepo + .createQueryBuilder() + .insert() + .into(UserArchive) + .values(users) + .execute(); + + // 削除対象のライセンスを退避テーブルに退避 + const licenses = await this.dataSource.getRepository(License).find({ + where: { + account_id: accountId, + }, + }); + const licenseArchiveRepo = entityManager.getRepository(LicenseArchive); + await licenseArchiveRepo + .createQueryBuilder() + .insert() + .into(LicenseArchive) + .values(licenses) + .execute(); + + // 削除対象のライセンス割り当て履歴を退避テーブルに退避 + const licenseHistories = await this.dataSource + .getRepository(LicenseAllocationHistory) + .find({ + where: { + account_id: accountId, + }, + }); + const licenseHistoryArchiveRepo = entityManager.getRepository( + LicenseAllocationHistoryArchive, + ); + await licenseHistoryArchiveRepo + .createQueryBuilder() + .insert() + .into(LicenseAllocationHistoryArchive) + .values(licenseHistories) + .execute(); + + // アカウントを削除 + // アカウントを削除することで、外部キー制約がで紐づいている関連テーブルのデータも削除される + const accountRepo = entityManager.getRepository(Account); + await accountRepo.delete({ id: accountId }); + return users; + }); + } } diff --git a/dictation_server/src/repositories/licenses/entity/license.entity.ts b/dictation_server/src/repositories/licenses/entity/license.entity.ts index 58716e5..dc39e6e 100644 --- a/dictation_server/src/repositories/licenses/entity/license.entity.ts +++ b/dictation_server/src/repositories/licenses/entity/license.entity.ts @@ -7,6 +7,7 @@ import { OneToOne, JoinColumn, ManyToOne, + PrimaryColumn, } from 'typeorm'; import { User } from '../../users/entity/user.entity'; @@ -188,3 +189,90 @@ export class LicenseAllocationHistory { @JoinColumn({ name: 'license_id' }) license?: License; } + +@Entity({ name: 'licenses_archive' }) +export class LicenseArchive { + @PrimaryColumn() + id: number; + + @Column({ nullable: true }) + expiry_date: Date; + + @Column() + account_id: number; + + @Column() + type: string; + + @Column() + status: string; + + @Column({ nullable: true }) + allocated_user_id: number; + + @Column({ nullable: true }) + order_id: number; + + @Column({ nullable: true }) + deleted_at: Date; + + @Column({ nullable: true }) + delete_order_id: number; + + @Column({ nullable: true }) + created_by: string; + + @Column() + created_at: Date; + + @Column({ nullable: true }) + updated_by: string; + + @Column() + updated_at: Date; + + @CreateDateColumn() + archived_at: Date; +} + +@Entity({ name: 'license_allocation_history_archive' }) +export class LicenseAllocationHistoryArchive { + @PrimaryColumn() + id: number; + + @Column() + user_id: number; + + @Column() + license_id: number; + + @Column() + is_allocated: boolean; + + @Column() + account_id: number; + + @Column() + executed_at: Date; + + @Column() + switch_from_type: string; + + @Column({ nullable: true }) + deleted_at: Date; + + @Column({ nullable: true }) + created_by: string; + + @Column() + created_at: Date; + + @Column({ nullable: true }) + updated_by: string; + + @Column() + updated_at: Date; + + @CreateDateColumn() + archived_at: Date; +} diff --git a/dictation_server/src/repositories/licenses/licenses.repository.module.ts b/dictation_server/src/repositories/licenses/licenses.repository.module.ts index 252f01b..29ed573 100644 --- a/dictation_server/src/repositories/licenses/licenses.repository.module.ts +++ b/dictation_server/src/repositories/licenses/licenses.repository.module.ts @@ -6,6 +6,8 @@ import { License, LicenseOrder, LicenseAllocationHistory, + LicenseArchive, + LicenseAllocationHistoryArchive, } from './entity/license.entity'; import { LicensesRepositoryService } from './licenses.repository.service'; @@ -17,6 +19,8 @@ import { LicensesRepositoryService } from './licenses.repository.service'; CardLicense, CardLicenseIssue, LicenseAllocationHistory, + LicenseArchive, + LicenseAllocationHistoryArchive, ]), ], providers: [LicensesRepositoryService], diff --git a/dictation_server/src/repositories/template_files/errors/types.ts b/dictation_server/src/repositories/template_files/errors/types.ts new file mode 100644 index 0000000..52896de --- /dev/null +++ b/dictation_server/src/repositories/template_files/errors/types.ts @@ -0,0 +1,2 @@ +// テンプレートファイルが存在しないエラー +export class TemplateFileNotExistError extends Error {} diff --git a/dictation_server/src/repositories/users/entity/user.entity.ts b/dictation_server/src/repositories/users/entity/user.entity.ts index 51cc19c..e7399aa 100644 --- a/dictation_server/src/repositories/users/entity/user.entity.ts +++ b/dictation_server/src/repositories/users/entity/user.entity.ts @@ -9,6 +9,7 @@ import { JoinColumn, OneToOne, OneToMany, + PrimaryColumn, } from 'typeorm'; import { License } from '../../licenses/entity/license.entity'; import { UserGroupMember } from '../../user_groups/entity/user_group_member.entity'; @@ -31,7 +32,10 @@ export class User { author_id?: string; @Column({ nullable: true }) - accepted_terms_version?: string; + accepted_eula_version?: string; + + @Column({ nullable: true }) + accepted_dpa_version?: string; @Column({ default: false }) email_verified: boolean; @@ -69,7 +73,7 @@ export class User { @UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 updated_at: Date; - @ManyToOne(() => Account, (account) => account.user) + @ManyToOne(() => Account, (account) => account.user, { onDelete: 'CASCADE' }) // onDeleteはSQLite用設定値.本番用は別途migrationで設定 @JoinColumn({ name: 'account_id' }) account?: Account; @@ -80,6 +84,66 @@ export class User { userGroupMembers?: UserGroupMember[]; } +@Entity({ name: 'users_archive' }) +export class UserArchive { + @PrimaryColumn() + id: number; + + @Column() + external_id: string; + + @Column() + account_id: number; + + @Column() + role: string; + + @Column({ nullable: true }) + author_id?: string; + + @Column({ nullable: true }) + accepted_eula_version?: string; + + @Column({ nullable: true }) + accepted_dpa_version?: string; + + @Column() + email_verified: boolean; + + @Column() + auto_renew: boolean; + + @Column() + license_alert: boolean; + + @Column() + notification: boolean; + + @Column() + encryption: boolean; + + @Column() + prompt: boolean; + + @Column({ nullable: true }) + deleted_at?: Date; + + @Column({ nullable: true }) + created_by: string; + + @Column() + created_at: Date; + + @Column({ nullable: true }) + updated_by?: string; + + @Column() + updated_at: Date; + + @CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定 + archived_at: Date; +} + export type newUser = Omit< User, | 'id' diff --git a/dictation_server/src/repositories/users/users.repository.module.ts b/dictation_server/src/repositories/users/users.repository.module.ts index 79be43a..94ccdc4 100644 --- a/dictation_server/src/repositories/users/users.repository.module.ts +++ b/dictation_server/src/repositories/users/users.repository.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { User } from './entity/user.entity'; +import { User, UserArchive } from './entity/user.entity'; import { UsersRepositoryService } from './users.repository.service'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [TypeOrmModule.forFeature([User, UserArchive])], providers: [UsersRepositoryService], exports: [UsersRepositoryService], }) diff --git a/dictation_server/src/repositories/users/users.repository.service.ts b/dictation_server/src/repositories/users/users.repository.service.ts index b4c388d..cab67bd 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -40,7 +40,8 @@ export class UsersRepositoryService { license_alert, notification, author_id, - accepted_terms_version, + accepted_eula_version, + accepted_dpa_version, encryption, encryption_password: encryptionPassword, prompt, @@ -54,7 +55,8 @@ export class UsersRepositoryService { userEntity.license_alert = license_alert; userEntity.notification = notification; userEntity.author_id = author_id; - userEntity.accepted_terms_version = accepted_terms_version; + userEntity.accepted_eula_version = accepted_eula_version; + userEntity.accepted_dpa_version = accepted_dpa_version; userEntity.encryption = encryption; userEntity.encryption_password = encryptionPassword; userEntity.prompt = prompt; @@ -375,6 +377,25 @@ export class UsersRepositoryService { }); } + /** + * アカウント内のAuthorユーザーを取得する + * @param accountId + * @returns author users + */ + async findAuthorUsers(accountId: number): Promise { + return await this.dataSource.transaction(async (entityManager) => { + const repo = entityManager.getRepository(User); + const authors = await repo.find({ + where: { + account_id: accountId, + role: USER_ROLES.AUTHOR, + deleted_at: IsNull(), + }, + }); + return authors; + }); + } + /** * UserID指定のユーザーとソート条件を同時に削除する * @param userId diff --git a/dictation_server/src/repositories/workflows/entity/workflow.entity.ts b/dictation_server/src/repositories/workflows/entity/workflow.entity.ts new file mode 100644 index 0000000..e3bac8e --- /dev/null +++ b/dictation_server/src/repositories/workflows/entity/workflow.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + JoinColumn, + ManyToOne, +} from 'typeorm'; +import { WorkflowTypist } from './workflow_typists.entity'; +import { Worktype } from '../../worktypes/entity/worktype.entity'; +import { TemplateFile } from '../../template_files/entity/template_file.entity'; +import { User } from '../../users/entity/user.entity'; + +@Entity({ name: 'workflows' }) +export class Workflow { + @PrimaryGeneratedColumn() + id: number; + + @Column() + account_id: number; + + @Column() + author_id: number; + + @Column({ nullable: true }) + worktype_id?: number; + + @Column({ nullable: true }) + template_id?: number; + + @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; + + @ManyToOne(() => User, (user) => user.id) + @JoinColumn({ name: 'author_id' }) + author?: User; + + @ManyToOne(() => Worktype, (worktype) => worktype.id) + @JoinColumn({ name: 'worktype_id' }) + worktype?: Worktype; + + @ManyToOne(() => TemplateFile, (templateFile) => templateFile.id) + @JoinColumn({ name: 'template_id' }) + template?: TemplateFile; + + @OneToMany(() => WorkflowTypist, (workflowTypist) => workflowTypist.workflow) + workflowTypists?: WorkflowTypist[]; +} diff --git a/dictation_server/src/repositories/workflows/entity/workflow_typists.entity.ts b/dictation_server/src/repositories/workflows/entity/workflow_typists.entity.ts new file mode 100644 index 0000000..b3d7139 --- /dev/null +++ b/dictation_server/src/repositories/workflows/entity/workflow_typists.entity.ts @@ -0,0 +1,51 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Workflow } from './workflow.entity'; +import { User } from '../../users/entity/user.entity'; +import { UserGroup } from '../../user_groups/entity/user_group.entity'; + +@Entity({ name: 'workflow_typists' }) +export class WorkflowTypist { + @PrimaryGeneratedColumn() + id: number; + + @Column() + workflow_id: number; + + @Column({ nullable: true }) + typist_id?: number; + + @Column({ nullable: true }) + typist_group_id?: number; + + @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; + + @ManyToOne(() => Workflow, (workflow) => workflow.id) + @JoinColumn({ name: 'workflow_id' }) + workflow?: Workflow; + + @ManyToOne(() => User, (user) => user.id) + @JoinColumn({ name: 'typist_id' }) + typist?: User; + + @ManyToOne(() => UserGroup, (userGroup) => userGroup.id) + @JoinColumn({ name: 'typist_group_id' }) + typistGroup?: UserGroup; +} diff --git a/dictation_server/src/repositories/workflows/errors/types.ts b/dictation_server/src/repositories/workflows/errors/types.ts new file mode 100644 index 0000000..8680e30 --- /dev/null +++ b/dictation_server/src/repositories/workflows/errors/types.ts @@ -0,0 +1,4 @@ +// AuthorIDとWorktypeIDのペア重複エラー +export class AuthorIdAndWorktypeIdPairAlreadyExistsError extends Error {} +// WorkflowID存在エラー +export class WorkflowIdNotFoundError extends Error {} diff --git a/dictation_server/src/repositories/workflows/workflows.repository.module.ts b/dictation_server/src/repositories/workflows/workflows.repository.module.ts new file mode 100644 index 0000000..6877efd --- /dev/null +++ b/dictation_server/src/repositories/workflows/workflows.repository.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { WorkflowTypist } from './entity/workflow_typists.entity'; +import { Workflow } from './entity/workflow.entity'; +import { WorkflowsRepositoryService } from './workflows.repository.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Workflow, WorkflowTypist])], + providers: [WorkflowsRepositoryService], + exports: [WorkflowsRepositoryService], +}) +export class WorkflowsRepositoryModule {} diff --git a/dictation_server/src/repositories/workflows/workflows.repository.service.ts b/dictation_server/src/repositories/workflows/workflows.repository.service.ts new file mode 100644 index 0000000..0437d79 --- /dev/null +++ b/dictation_server/src/repositories/workflows/workflows.repository.service.ts @@ -0,0 +1,345 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, In, IsNull } from 'typeorm'; +import { Workflow } from './entity/workflow.entity'; +import { WorkflowTypist as DbWorkflowTypist } from './entity/workflow_typists.entity'; +import { User } from '../users/entity/user.entity'; +import { WorkflowTypist } from '../../features/workflows/types/types'; +import { Worktype } from '../worktypes/entity/worktype.entity'; +import { TemplateFile } from '../template_files/entity/template_file.entity'; +import { UserGroup } from '../user_groups/entity/user_group.entity'; +import { TypistGroupNotExistError } from '../user_groups/errors/types'; +import { UserNotFoundError } from '../users/errors/types'; +import { WorktypeIdNotFoundError } from '../worktypes/errors/types'; +import { TemplateFileNotExistError } from '../template_files/errors/types'; +import { + AuthorIdAndWorktypeIdPairAlreadyExistsError, + WorkflowIdNotFoundError, +} from './errors/types'; + +@Injectable() +export class WorkflowsRepositoryService { + constructor(private dataSource: DataSource) {} + + /** + * ワークフロー一を取得する + * @param externalId + * @returns worktypes and active worktype id + */ + async getWorkflows(accountId: number): Promise { + return await this.dataSource.transaction(async (entityManager) => { + const workflowRepo = entityManager.getRepository(Workflow); + + const workflows = await workflowRepo.find({ + where: { account_id: accountId }, + relations: { + author: true, + worktype: true, + template: true, + workflowTypists: { + typist: true, + typistGroup: true, + }, + }, + order: { + id: 'ASC', + }, + }); + + return workflows; + }); + } + + /** + * ワークフローを作成する + * @param accountId + * @param authorId + * @param worktypeId + * @param templateId + * @param typists + * @returns workflows + */ + async createtWorkflows( + accountId: number, + authorId: number, + worktypeId?: number | undefined, + templateId?: number | undefined, + typists?: WorkflowTypist[], + ): Promise { + return await this.dataSource.transaction(async (entityManager) => { + // authorの存在確認 + const userRepo = entityManager.getRepository(User); + const author = await userRepo.findOne({ + where: { account_id: accountId, id: authorId }, + }); + if (!author) { + throw new UserNotFoundError(`author not found. id: ${authorId}`); + } + + // worktypeの存在確認 + if (worktypeId !== undefined) { + const worktypeRepo = entityManager.getRepository(Worktype); + const worktypes = await worktypeRepo.find({ + where: { account_id: accountId, id: worktypeId }, + }); + if (worktypes.length === 0) { + throw new WorktypeIdNotFoundError( + `worktype not found. id: ${worktypeId}`, + ); + } + } + + // templateの存在確認 + if (templateId !== undefined) { + const templateRepo = entityManager.getRepository(TemplateFile); + const template = await templateRepo.findOne({ + where: { account_id: accountId, id: templateId }, + }); + if (!template) { + throw new TemplateFileNotExistError('template not found'); + } + } + + // ルーティング候補ユーザーの存在確認 + const typistIds = typists.flatMap((typist) => + typist.typistId ? [typist.typistId] : [], + ); + const typistUsers = await userRepo.find({ + where: { account_id: accountId, id: In(typistIds) }, + }); + if (typistUsers.length !== typistIds.length) { + throw new UserNotFoundError(`typist not found. ids: ${typistIds}`); + } + + // ルーティング候補ユーザーグループの存在確認 + const groupIds = typists.flatMap((typist) => { + return typist.typistGroupId ? [typist.typistGroupId] : []; + }); + const userGroupRepo = entityManager.getRepository(UserGroup); + const typistGroups = await userGroupRepo.find({ + where: { account_id: accountId, id: In(groupIds) }, + }); + if (typistGroups.length !== groupIds.length) { + throw new TypistGroupNotExistError( + `typist group not found. ids: ${groupIds}`, + ); + } + + const workflowRepo = entityManager.getRepository(Workflow); + + // ワークフローの重複確認 + const workflow = await workflowRepo.find({ + where: { + account_id: accountId, + author_id: authorId, + worktype_id: worktypeId !== undefined ? worktypeId : IsNull(), + }, + }); + if (workflow.length !== 0) { + throw new AuthorIdAndWorktypeIdPairAlreadyExistsError( + 'workflow already exists', + ); + } + + // ワークフローのデータ作成 + const newWorkflow = this.makeWorkflow( + accountId, + authorId, + worktypeId, + templateId, + ); + + await workflowRepo.save(newWorkflow); + + // ルーティング候補のデータ作成 + const workflowTypists = typists.map((typist) => + this.makeWorkflowTypist( + newWorkflow.id, + typist.typistId, + typist.typistGroupId, + ), + ); + + const workflowTypistsRepo = entityManager.getRepository(DbWorkflowTypist); + await workflowTypistsRepo.save(workflowTypists); + }); + } + + /** + * ワークフローを更新する + * @param accountId + * @param workflowId + * @param authorId + * @param [worktypeId] + * @param [templateId] + * @param [typists] + * @returns workflow + */ + async updatetWorkflow( + accountId: number, + workflowId: number, + authorId: number, + worktypeId?: number | undefined, + templateId?: number | undefined, + typists?: WorkflowTypist[], + ): Promise { + return await this.dataSource.transaction(async (entityManager) => { + const workflowRepo = entityManager.getRepository(Workflow); + + // ワークフローの存在確認 + const targetWorkflow = await workflowRepo.findOne({ + where: { account_id: accountId, id: workflowId }, + }); + if (!targetWorkflow) { + throw new WorkflowIdNotFoundError( + `workflow not found. id: ${workflowId}`, + ); + } + + // authorの存在確認 + const userRepo = entityManager.getRepository(User); + const author = await userRepo.findOne({ + where: { account_id: accountId, id: authorId }, + }); + if (!author) { + throw new UserNotFoundError(`author not found. id: ${authorId}`); + } + + // worktypeの存在確認 + if (worktypeId !== undefined) { + const worktypeRepo = entityManager.getRepository(Worktype); + const worktypes = await worktypeRepo.find({ + where: { account_id: accountId, id: worktypeId }, + }); + if (worktypes.length === 0) { + throw new WorktypeIdNotFoundError( + `worktype not found. id: ${worktypeId}`, + ); + } + } + + // templateの存在確認 + if (templateId !== undefined) { + const templateRepo = entityManager.getRepository(TemplateFile); + const template = await templateRepo.findOne({ + where: { account_id: accountId, id: templateId }, + }); + if (!template) { + throw new TemplateFileNotExistError( + `template not found. id: ${templateId}`, + ); + } + } + + // ルーティング候補ユーザーの存在確認 + const typistIds = typists.flatMap((typist) => + typist.typistId ? [typist.typistId] : [], + ); + const typistUsers = await userRepo.find({ + where: { account_id: accountId, id: In(typistIds) }, + }); + if (typistUsers.length !== typistIds.length) { + throw new UserNotFoundError(`typist not found. ids: ${typistIds}`); + } + + // ルーティング候補ユーザーグループの存在確認 + const groupIds = typists.flatMap((typist) => { + return typist.typistGroupId ? [typist.typistGroupId] : []; + }); + const userGroupRepo = entityManager.getRepository(UserGroup); + const typistGroups = await userGroupRepo.find({ + where: { account_id: accountId, id: In(groupIds) }, + }); + if (typistGroups.length !== groupIds.length) { + throw new TypistGroupNotExistError( + `typist group not found. ids: ${groupIds}`, + ); + } + + const workflowTypistsRepo = entityManager.getRepository(DbWorkflowTypist); + + // 既存データの削除 + await workflowTypistsRepo.delete({ workflow_id: workflowId }); + await workflowRepo.delete(workflowId); + + { + // ワークフローの重複確認 + const duplicateWorkflow = await workflowRepo.find({ + where: { + account_id: accountId, + author_id: authorId, + worktype_id: worktypeId !== undefined ? worktypeId : IsNull(), + }, + }); + if (duplicateWorkflow.length !== 0) { + throw new AuthorIdAndWorktypeIdPairAlreadyExistsError( + 'workflow already exists', + ); + } + } + + // ワークフローのデータ作成 + const newWorkflow = this.makeWorkflow( + accountId, + authorId, + worktypeId, + templateId, + ); + + await workflowRepo.save(newWorkflow); + + // ルーティング候補のデータ作成 + const workflowTypists = typists.map((typist) => + this.makeWorkflowTypist( + newWorkflow.id, + typist.typistId, + typist.typistGroupId, + ), + ); + + await workflowTypistsRepo.save(workflowTypists); + }); + } + + /** + * DBに保存するワークフローデータを作成する + * @param accountId + * @param authorId + * @param worktypeId + * @param templateId + * @returns workflow + */ + private makeWorkflow( + accountId: number, + authorId: number, + worktypeId?: number | undefined, + templateId?: number | undefined, + ): Workflow { + const workflow = new Workflow(); + workflow.account_id = accountId; + workflow.author_id = authorId; + workflow.worktype_id = worktypeId; + workflow.template_id = templateId; + + return workflow; + } + + /** + * DBに保存するルーティング候補データを作成する + * @param workflowId + * @param typistId + * @param typistGroupId + * @returns workflow typist + */ + private makeWorkflowTypist( + workflowId: number, + typistId: number, + typistGroupId: number, + ): DbWorkflowTypist { + const workflowTypist = new DbWorkflowTypist(); + workflowTypist.workflow_id = workflowId; + workflowTypist.typist_id = typistId; + workflowTypist.typist_group_id = typistGroupId; + + return workflowTypist; + } +}
+ {t(getTranslationID("common.message.listEmpty"))} +
- {t(getTranslationID("common.message.listEmpty"))} -
+ {t(getTranslationID("workflowPage.label.addRoutingRule"))} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */} + +