From 5cfe069b5845475a3e82e5569f524f06aa8e2cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B0=B4=E6=9C=AC=20=E7=A5=90=E5=B8=8C?= Date: Mon, 2 Oct 2023 01:19:18 +0000 Subject: [PATCH 01/22] =?UTF-8?q?Merged=20PR=20444:=20=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=EF=BC=88=E3=82=A2=E3=82=AB=E3=82=A6=E3=83=B3?= =?UTF-8?q?=E3=83=88=E5=89=8A=E9=99=A4=E6=88=90=E5=8A=9F=E3=83=9A=E3=83=BC?= =?UTF-8?q?=E3=82=B8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2715: 画面実装(アカウント削除成功ページ)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2715) - 元PBI or タスクへのリンク(内容・目的などはそちらにあるはず) - 何をどう変更したか、追加したライブラリなど ・アカウント削除成功後に、アカウント削除成功ページに遷移するよう実装 ・ログオフ状態にする - このPull Requestでの対象/対象外 - 影響範囲(他の機能にも影響があるか) ## レビューポイント - 特にレビューしてほしい箇所 ・レイアウトやメッセージの文言 ・ログオフ状態にするタイミング →Back to TOP Pageを押下時にログオフ状態になるようにしています ・Delete Accountボタン(ポップアップ上の)押下時のページ遷移のコード ・その他、漏れがないか - 軽微なものや自明なものは記載不要 - 修正範囲が大きい場合などに記載 - 全体的にや仕様を満たしているか等は本当に必要な時のみ記載 ## UIの変更 - Before/Afterのスクショなど - スクショ置き場 https://ndstokyo.sharepoint.com/:f:/r/sites/Piranha/Shared%20Documents/General/OMDS/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88/Task2715?csf=1&web=1&e=0M85t6 ## 動作確認状況 - ローカルで確認 ## 補足 - 相談、参考資料などがあれば --- dictation_client/src/AppRouter.tsx | 3 ++ .../AccountPage/accountDeleteSuccess.tsx | 54 +++++++++++++++++++ .../pages/AccountPage/deleteAccountPopup.tsx | 27 +++++++--- dictation_client/src/translation/de.json | 7 +++ dictation_client/src/translation/en.json | 7 +++ dictation_client/src/translation/es.json | 7 +++ dictation_client/src/translation/fr.json | 7 +++ 7 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 dictation_client/src/pages/AccountPage/accountDeleteSuccess.tsx 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/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 = (
Date: Mon, 2 Oct 2023 01:45:04 +0000 Subject: [PATCH 02/22] =?UTF-8?q?Merged=20PR=20446:=20DB=E3=83=9E=E3=82=A4?= =?UTF-8?q?=E3=82=B0=E3=83=AC=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2733: DBマイグレーション](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2733) - 以下のテーブルを追加するマイグレーションファイルを追加しました。 - ワークフローテーブル - ルーティング候補テーブル ## レビューポイント - テーブル名、カラム名は適切か - 外部キー制約は適切か ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 - migurate up/down --- .../db/migrations/041_create_workflow.sql | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 dictation_server/db/migrations/041_create_workflow.sql 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 From 3e0c483b57cc177e4ab6eb82401fa2f7cf4de341 Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Mon, 2 Oct 2023 07:13:34 +0000 Subject: [PATCH 03/22] =?UTF-8?q?Merged=20PR=20456:=20NestJS=E3=81=AE?= =?UTF-8?q?=E7=92=B0=E5=A2=83=E5=A4=89=E6=95=B0=E3=82=92=E6=95=B4=E7=90=86?= =?UTF-8?q?=E3=81=97=E3=81=A6=E3=83=90=E3=83=AA=E3=83=87=E3=83=BC=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2769: NestJSの環境変数を整理してバリデーションを修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2769) - 非必須の環境変数をチェックしないようにオプショナルを修正しました - ローカルでのDBマイグレーションに必要なため、コンテナ起動時の`.env`ファイル読み込みを追加しました。 - 環境変数についてDB関連項目を残し、すべて`.env.local`に移動しました。 ## レビューポイント - 環境変数に対するチェックのオプショナル設定は適切か - 環境変数をlocalに移動させたが問題ないか。 ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 ## 補足 - 不足している環境変数についてレビュー完了後にAppService、Wikiを整備しておきます。 --- .../.devcontainer/docker-compose.yml | 1 + dictation_server/.env | 11 --- dictation_server/.env.local.example | 13 ++- .../src/common/validators/env.validator.ts | 90 ++++++++----------- 4 files changed, 45 insertions(+), 70 deletions(-) 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..eda234a 100644 --- a/dictation_server/.env.local.example +++ b/dictation_server/.env.local.example @@ -1,10 +1,10 @@ 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 @@ -17,6 +17,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 +26,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/src/common/validators/env.validator.ts b/dictation_server/src/common/validators/env.validator.ts index 9ceea2a..22cfbfe 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,7 +69,7 @@ export class EnvValidator { @IsString() ADB2C_CLIENT_SECRET: string; - @IsNotEmpty() + @IsOptional() @IsString() ADB2C_ORIGIN: string; @@ -145,6 +101,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 +144,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) { From 65f80b9a5b0c2729119a520411d2ec95a58b672e Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Mon, 2 Oct 2023 08:11:36 +0000 Subject: [PATCH 04/22] =?UTF-8?q?Merged=20PR=20459:=20=E3=83=91=E3=82=A4?= =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=A4=E3=83=B3=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=A8=E3=83=A9=E3=83=BC=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2773: パイプラインテストエラー修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2773) - パイプラインでのテストエラー対応のため環境変数のチェックを外しました。 ## レビューポイント - 共有 ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 --- .../src/gateways/blobstorage/blobstorage.service.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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; } /** From 1cc7a0141d0f638d4a0aafc50073d1e7e11fcfb4 Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Tue, 3 Oct 2023 01:14:18 +0000 Subject: [PATCH 05/22] =?UTF-8?q?Merged=20PR=20453:=20=E3=83=AF=E3=83=BC?= =?UTF-8?q?=E3=82=AF=E3=83=95=E3=83=AD=E3=83=BC=E4=B8=80=E8=A6=A7=E5=8F=96?= =?UTF-8?q?=E5=BE=97API=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2736: ワークフロー一覧取得API実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2736) - ワークフロー一覧取得APIとテストを実装しました ## レビューポイント - リポジトリの取得処理は適切か(リレーションなど) - ADB2Cからの取得処理は適切か - サービスでのワークフローの整形処理は適切か - テストケースは適切か ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 --- dictation_server/src/app.module.ts | 2 + .../src/features/workflows/test/utility.ts | 61 +++++ .../workflows/workflows.controller.ts | 5 +- .../features/workflows/workflows.module.ts | 4 +- .../workflows/workflows.service.spec.ts | 225 ++++++++++++++++++ .../features/workflows/workflows.service.ts | 106 ++++++++- .../workflows/entity/workflow.entity.ts | 59 +++++ .../entity/workflow_typists.entity.ts | 51 ++++ .../workflows/workflows.repository.module.ts | 12 + .../workflows/workflows.repository.service.ts | 34 +++ 10 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 dictation_server/src/features/workflows/test/utility.ts create mode 100644 dictation_server/src/features/workflows/workflows.service.spec.ts create mode 100644 dictation_server/src/repositories/workflows/entity/workflow.entity.ts create mode 100644 dictation_server/src/repositories/workflows/entity/workflow_typists.entity.ts create mode 100644 dictation_server/src/repositories/workflows/workflows.repository.module.ts create mode 100644 dictation_server/src/repositories/workflows/workflows.repository.service.ts diff --git a/dictation_server/src/app.module.ts b/dictation_server/src/app.module.ts index c99f882..ee977ec 100644 --- a/dictation_server/src/app.module.ts +++ b/dictation_server/src/app.module.ts @@ -48,6 +48,7 @@ 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'; @Module({ imports: [ @@ -86,6 +87,7 @@ import { validate } from './common/validators/env.validator'; CheckoutPermissionsRepositoryModule, UserGroupsRepositoryModule, TemplateFilesRepositoryModule, + WorkflowsRepositoryModule, TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ 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..6ff9f29 --- /dev/null +++ b/dictation_server/src/features/workflows/test/utility.ts @@ -0,0 +1,61 @@ +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 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; +}; diff --git a/dictation_server/src/features/workflows/workflows.controller.ts b/dictation_server/src/features/workflows/workflows.controller.ts index 9b256a7..a1521c4 100644 --- a/dictation_server/src/features/workflows/workflows.controller.ts +++ b/dictation_server/src/features/workflows/workflows.controller.ts @@ -62,9 +62,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({ 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..0873a5a --- /dev/null +++ b/dictation_server/src/features/workflows/workflows.service.spec.ts @@ -0,0 +1,225 @@ +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, + 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('getWorktypes', () => { + 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(); + } + } + }); +}); diff --git a/dictation_server/src/features/workflows/workflows.service.ts b/dictation_server/src/features/workflows/workflows.service.ts index 9b32526..9c41266 100644 --- a/dictation_server/src/features/workflows/workflows.service.ts +++ b/dictation_server/src/features/workflows/workflows.service.ts @@ -1,7 +1,109 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; +import { Context } from '../../common/log'; +import { Workflow } from './types/types'; +import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; +import { WorkflowsRepositoryService } from '../../repositories/workflows/workflows.repository.service'; +import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; @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}`, + ); + } + } } 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/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..3b6bd34 --- /dev/null +++ b/dictation_server/src/repositories/workflows/workflows.repository.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Workflow } from './entity/workflow.entity'; + +@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, + }, + }, + }); + + return workflows; + }); + } +} From 088e6afc856d55329af8fa39f2c12a3df8790f6b Mon Sep 17 00:00:00 2001 From: "saito.k" Date: Tue, 3 Oct 2023 02:15:57 +0000 Subject: [PATCH 06/22] =?UTF-8?q?Merged=20PR=20454:=20=E3=83=AF=E3=83=BC?= =?UTF-8?q?=E3=82=AF=E3=83=95=E3=83=AD=E3=83=BC=E4=B8=80=E8=A6=A7=E7=94=BB?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2735: ワークフロー一覧画面](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2735) - ワークフロー一覧画面のデザイン反映 - 多言語対応 - 一覧取得API呼び出し ## レビューポイント - デザイン反映に問題はないか - フォルダ構成はこれでよいか - workflow配下に直置き ## UIの変更 - https://ndstokyo.sharepoint.com/:f:/r/sites/Piranha/Shared%20Documents/General/OMDS/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88/Task2735?csf=1&web=1&e=IelNET ## 動作確認状況 - ローカルで確認 ## 補足 - 相談、参考資料などがあれば --- dictation_client/src/api/api.ts | 410 ++++++++++++++++++ dictation_client/src/app/store.ts | 2 + .../src/assets/images/download.svg | 10 + dictation_client/src/assets/images/exit.svg | 11 + .../src/assets/images/group_setting.svg | 25 ++ dictation_client/src/assets/images/logout.svg | 10 + .../src/assets/images/rule_add.svg | 12 + .../src/assets/images/template_setting.svg | 14 + .../src/assets/images/worktype_setting.svg | 17 + .../src/features/workflow/index.ts | 4 + .../src/features/workflow/operations.ts | 42 ++ .../src/features/workflow/selectors.ts | 7 + .../src/features/workflow/state.ts | 14 + .../src/features/workflow/workflowSlice.ts | 32 ++ .../src/pages/WorkflowPage/index.tsx | 183 ++++++-- dictation_client/src/translation/de.json | 11 +- dictation_client/src/translation/en.json | 11 +- dictation_client/src/translation/es.json | 11 +- dictation_client/src/translation/fr.json | 11 +- 19 files changed, 806 insertions(+), 31 deletions(-) create mode 100644 dictation_client/src/assets/images/download.svg create mode 100644 dictation_client/src/assets/images/exit.svg create mode 100644 dictation_client/src/assets/images/group_setting.svg create mode 100644 dictation_client/src/assets/images/logout.svg create mode 100644 dictation_client/src/assets/images/rule_add.svg create mode 100644 dictation_client/src/assets/images/template_setting.svg create mode 100644 dictation_client/src/assets/images/worktype_setting.svg create mode 100644 dictation_client/src/features/workflow/index.ts create mode 100644 dictation_client/src/features/workflow/operations.ts create mode 100644 dictation_client/src/features/workflow/selectors.ts create mode 100644 dictation_client/src/features/workflow/state.ts create mode 100644 dictation_client/src/features/workflow/workflowSlice.ts 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/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..41957aa --- /dev/null +++ b/dictation_client/src/features/workflow/operations.ts @@ -0,0 +1,42 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { Configuration, GetWorkflowsResponse, WorkflowsApi } from "api"; +import type { RootState } from "app/store"; +import { ErrorObject, createErrorObject } from "common/errors"; +import { openSnackbar } from "features/ui/uiSlice"; +import { getTranslationID } from "translation"; + +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 }); + } +}); diff --git a/dictation_client/src/features/workflow/selectors.ts b/dictation_client/src/features/workflow/selectors.ts new file mode 100644 index 0000000..57745ed --- /dev/null +++ b/dictation_client/src/features/workflow/selectors.ts @@ -0,0 +1,7 @@ +import { RootState } from "app/store"; + +export const selectWorkflows = (state: RootState) => + state.workflow.domain.workflows; + +export const selectIsLoading = (state: RootState) => + state.workflow.apps.isLoading; diff --git a/dictation_client/src/features/workflow/state.ts b/dictation_client/src/features/workflow/state.ts new file mode 100644 index 0000000..6a9d838 --- /dev/null +++ b/dictation_client/src/features/workflow/state.ts @@ -0,0 +1,14 @@ +import { Workflow } from "api"; + +export interface WorkflowState { + apps: Apps; + domain: Domain; +} + +export interface Apps { + isLoading: boolean; +} + +export interface Domain { + workflows?: Workflow[]; +} diff --git a/dictation_client/src/features/workflow/workflowSlice.ts b/dictation_client/src/features/workflow/workflowSlice.ts new file mode 100644 index 0000000..dd94a79 --- /dev/null +++ b/dictation_client/src/features/workflow/workflowSlice.ts @@ -0,0 +1,32 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { WorkflowState } from "./state"; +import { listWorkflowAsync } from "./operations"; + +const initialState: WorkflowState = { + apps: { + isLoading: false, + }, + domain: {}, +}; + +export const workflowSlice = createSlice({ + name: "workflow", + initialState, + reducers: {}, + 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; + }); + }, +}); + +export default workflowSlice.reducer; diff --git a/dictation_client/src/pages/WorkflowPage/index.tsx b/dictation_client/src/pages/WorkflowPage/index.tsx index 0df685b..c7e8351 100644 --- a/dictation_client/src/pages/WorkflowPage/index.tsx +++ b/dictation_client/src/pages/WorkflowPage/index.tsx @@ -1,34 +1,163 @@ -import React from "react"; +import React, { useEffect } 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"; -const WorkflowPage: React.FC = (): JSX.Element => ( - -); +const WorkflowPage: React.FC = (): JSX.Element => { + const dispatch: AppDispatch = useDispatch(); + const [t] = useTranslation(); + const workflows = useSelector(selectWorkflows); + const isLoading = useSelector(selectIsLoading); + + useEffect(() => { + dispatch(listWorkflowAsync()); + }, [dispatch]); + return ( +
+
+ +
+
+
+

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

+
+
+
+ + + + + + + + + + {workflows?.map((workflow) => ( + + + + + + + + + ))} +
{/** empty th */}{t(getTranslationID("workflowPage.label.authorID"))}{t(getTranslationID("workflowPage.label.worktype"))} + {t(getTranslationID("workflowPage.label.transcriptionist"))} + {t(getTranslationID("workflowPage.label.template"))}
+ + {workflow.author.authorId}{workflow.worktype?.worktypeId ?? "-"} + {workflow.typists.map((typist, i) => ( + <> + {typist.typistName} + {i !== workflow.typists.length - 1 &&
} + + ))} +
{workflow.template?.fileName ?? "-"}
+ {!isLoading && workflows?.length === 0 && ( +

+ {t(getTranslationID("common.message.listEmpty"))} +

+ )} + {isLoading && ( + Loading + )} +
+
+
+
+
+
+ ); +}; export default WorkflowPage; diff --git a/dictation_client/src/translation/de.json b/dictation_client/src/translation/de.json index 08a4c0f..2c89bc8 100644 --- a/dictation_client/src/translation/de.json +++ b/dictation_client/src/translation/de.json @@ -356,7 +356,16 @@ }, "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", + "transcriptionist": "Transkriptionist", + "template": "(de)Template", + "editRule": "(de)Edit Rule" } }, "typistGroupSetting": { diff --git a/dictation_client/src/translation/en.json b/dictation_client/src/translation/en.json index 8db7771..b3ac4c8 100644 --- a/dictation_client/src/translation/en.json +++ b/dictation_client/src/translation/en.json @@ -356,7 +356,16 @@ }, "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", + "transcriptionist": "Transcriptionist", + "template": "Template", + "editRule": "Edit Rule" } }, "typistGroupSetting": { diff --git a/dictation_client/src/translation/es.json b/dictation_client/src/translation/es.json index d3c5ebb..fc13455 100644 --- a/dictation_client/src/translation/es.json +++ b/dictation_client/src/translation/es.json @@ -356,7 +356,16 @@ }, "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", + "transcriptionist": "Transcriptor", + "template": "(es)Template", + "editRule": "(es)Edit Rule" } }, "typistGroupSetting": { diff --git a/dictation_client/src/translation/fr.json b/dictation_client/src/translation/fr.json index f7f1676..ebc5dcd 100644 --- a/dictation_client/src/translation/fr.json +++ b/dictation_client/src/translation/fr.json @@ -356,7 +356,16 @@ }, "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", + "transcriptionist": "Transcriptionniste", + "template": "(fr)Template", + "editRule": "(fr)Edit Rule" } }, "typistGroupSetting": { From d942dc73f1c5d21df8d0592f3ddd0617faefdc10 Mon Sep 17 00:00:00 2001 From: "oura.a" Date: Tue, 3 Oct 2023 02:22:19 +0000 Subject: [PATCH 07/22] =?UTF-8?q?Merged=20PR=20457:=20Account=E3=83=AC?= =?UTF-8?q?=E3=82=B3=E3=83=BC=E3=83=89=E5=89=8A=E9=99=A4=E6=99=82=E3=81=AB?= =?UTF-8?q?=E5=90=8C=E6=99=82=E3=81=AB=E5=89=8A=E9=99=A4=E3=81=95=E3=82=8C?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E5=A4=96=E9=83=A8=E3=82=AD=E3=83=BC?= =?UTF-8?q?=E5=88=B6=E7=B4=84=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2766: Accountレコード削除時に同時に削除されるよう外部キー制約を追加](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2766) 各テーブルに外部キー制約を追加しました。 アカウント削除時にアカウントテーブルのデータを削除した際、ON DELETE CASCADEにより関連項目をすべて削除する用途になります。 ## レビューポイント 設定内容は適切か。 設定箇所に過不足はないか。 マイグレーションの途中で元データの不整合などで失敗した場合、それまでに外部キー制約の追加・削除に成功していた分が巻き戻らなかったのですが、何か巻き戻す方法はあるでしょうか? ## UIの変更 なし ## 動作確認状況 ローカルでmigrate Up/Downの動作を確認 ## 補足 なし --- ...042-add-foreign-key-for-account-delete.sql | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 dictation_server/db/migrations/042-add-foreign-key-for-account-delete.sql 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`; From 664e815ef97a7e67bb0764e1381a5dc318510665 Mon Sep 17 00:00:00 2001 From: "maruyama.t" Date: Tue, 3 Oct 2023 06:20:36 +0000 Subject: [PATCH 08/22] =?UTF-8?q?Merged=20PR=20429:=20API=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=EF=BC=88=E3=82=A2=E3=82=AB=E3=82=A6=E3=83=B3=E3=83=88?= =?UTF-8?q?=E5=89=8A=E9=99=A4API=EF=BC=9A=E3=83=A1=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E5=87=A6=E7=90=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2670: API実装(アカウント削除API:メイン処理)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2670) アカウント削除APIを実装しました。 APIとしてはこれで実装完了ですが、DBに外部キー制約をつけていないので、現時点で削除できるものは以下のみです。 ・アカウントテーブル ・ADB2Cのユーザー ・BLOBストレージ ## レビューポイント 内容が重めの処理なので全体的に見ていただけると嬉しいです。 ## UIの変更 なし ## 動作確認状況 ローカルで以下の動作を確認 ・RDBのアカウントが削除される ・ADB2Cのユーザーが削除される ・RDBのユーザーが退避テーブルに登録される ・BLOBストレージが削除される ## 補足 UTは別タスクに切り出しているので、本タスクでは実装していません。 --- dictation_server/src/constants/index.ts | 6 ++ .../features/accounts/accounts.controller.ts | 11 +- .../src/features/accounts/accounts.service.ts | 102 +++++++++++++++++- .../src/features/users/users.service.ts | 5 +- .../src/gateways/adb2c/adb2c.service.ts | 25 +++++ .../accounts/accounts.repository.service.ts | 32 +++++- .../repositories/users/entity/user.entity.ts | 58 ++++++++++ .../users/users.repository.module.ts | 4 +- 8 files changed, 227 insertions(+), 16 deletions(-) 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..4ac1c64 100644 --- a/dictation_server/src/features/accounts/accounts.controller.ts +++ b/dictation_server/src/features/accounts/accounts.controller.ts @@ -1071,7 +1071,7 @@ export class AccountsController { description: 'DBアクセスに失敗しログインできる状態で処理が終了した場合', type: ErrorResponse, }) - @ApiOperation({ operationId: 'deleteAccount' }) + @ApiOperation({ operationId: 'deleteAccountAndData' }) @ApiBearerAuth() @UseGuards(AuthGuard) @UseGuards( @@ -1079,7 +1079,7 @@ export class AccountsController { roles: [ADMIN_ROLES.ADMIN], }), ) - async deleteAccount( + async deleteAccountAndData( @Req() req: Request, @Body() body: DeleteAccountRequest, ): Promise { @@ -1088,12 +1088,7 @@ 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; } } diff --git a/dictation_server/src/features/accounts/accounts.service.ts b/dictation_server/src/features/accounts/accounts.service.ts index 2c13616..495fee5 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 { @@ -319,7 +320,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 +339,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 +362,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}`, ); } } @@ -1684,4 +1685,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/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/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/repositories/accounts/accounts.repository.service.ts b/dictation_server/src/repositories/accounts/accounts.repository.service.ts index 5da84e7..510a865 100644 --- a/dictation_server/src/repositories/accounts/accounts.repository.service.ts +++ b/dictation_server/src/repositories/accounts/accounts.repository.service.ts @@ -10,7 +10,7 @@ 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 { SortCriteria } from '../sort_criteria/entity/sort_criteria.entity'; @@ -902,4 +902,34 @@ 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 accountRepo = entityManager.getRepository(Account); + await accountRepo.delete({ id: accountId }); + return users; + }); + } } diff --git a/dictation_server/src/repositories/users/entity/user.entity.ts b/dictation_server/src/repositories/users/entity/user.entity.ts index 51cc19c..9e375df 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'; @@ -80,6 +81,63 @@ 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_terms_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], }) From 6baeb0b04994c216f876e5c5677d647dc2b841fa Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Thu, 5 Oct 2023 00:11:41 +0000 Subject: [PATCH 09/22] =?UTF-8?q?Merged=20PR=20463:=20API=20IF=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2775: API IF実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2775) - ワークフロー編集APIのIFを実装し、OpenAPIを更新しました。 ## レビューポイント - パスは想定通りか - パラメータは適切か ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 --- dictation_server/src/api/odms/openapi.json | 85 ++++++++++++++++++- .../src/features/workflows/types/types.ts | 41 ++++++++- .../workflows/workflows.controller.ts | 51 +++++++++++ 3 files changed, 174 insertions(+), 3 deletions(-) diff --git a/dictation_server/src/api/odms/openapi.json b/dictation_server/src/api/odms/openapi.json index 4e50b27..935d920 100644 --- a/dictation_server/src/api/odms/openapi.json +++ b/dictation_server/src/api/odms/openapi.json @@ -1219,7 +1219,7 @@ }, "/accounts/delete": { "post": { - "operationId": "deleteAccount", + "operationId": "deleteAccountAndData", "summary": "", "parameters": [], "requestBody": { @@ -2976,6 +2976,68 @@ "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": [] }] + } + }, "/notification/register": { "post": { "operationId": "register", @@ -4251,7 +4313,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 +4329,25 @@ "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": {} }, "RegisterRequest": { "type": "object", "properties": { diff --git a/dictation_server/src/features/workflows/types/types.ts b/dictation_server/src/features/workflows/types/types.ts index 3bd82f9..d5a99e8 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,42 @@ 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 {} diff --git a/dictation_server/src/features/workflows/workflows.controller.ts b/dictation_server/src/features/workflows/workflows.controller.ts index a1521c4..269ca52 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,9 @@ import { GetWorkflowsResponse, CreateWorkflowsRequest, CreateWorkflowsResponse, + UpdateWorkflowResponse, + UpdateWorkflowRequest, + UpdateWorkflowRequestParam, } from './types/types'; import { AuthGuard } from '../../common/guards/auth/authguards'; import { RoleGuard } from '../../common/guards/role/roleguards'; @@ -110,4 +114,51 @@ export class WorkflowsController { 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 } = body; + const { workflowId } = param; + const token = retrieveAuthorizationToken(req); + const { userId } = jwt.decode(token, { json: true }) as AccessToken; + console.log('updateWorkflow'); + + const context = makeContext(userId); + console.log(context.trackingId); + console.log(authorId); + console.log(workflowId); + + return {}; + } } From c4c2038e6e5a1460b4654813d094af3ea28212b2 Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Thu, 5 Oct 2023 00:20:42 +0000 Subject: [PATCH 10/22] =?UTF-8?q?Merged=20PR=20458:=20=E3=83=AF=E3=83=BC?= =?UTF-8?q?=E3=82=AF=E3=83=95=E3=83=AD=E3=83=BC=E8=BF=BD=E5=8A=A0API?= =?UTF-8?q?=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2739: ワークフロー追加API実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2739) - ワークフロー追加APIとテストを実装しました。 ## レビューポイント - リポジトリのチェックロジックは適切か - テストケースは適切か ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 --- dictation_server/src/common/error/code.ts | 2 + dictation_server/src/common/error/message.ts | 2 + .../src/features/workflows/test/utility.ts | 35 +- .../workflows/workflows.controller.ts | 12 +- .../workflows/workflows.service.spec.ts | 704 +++++++++++++++++- .../features/workflows/workflows.service.ts | 98 ++- .../template_files/errors/types.ts | 2 + .../repositories/workflows/errors/types.ts | 2 + .../workflows/workflows.repository.service.ts | 171 ++++- 9 files changed, 1018 insertions(+), 10 deletions(-) create mode 100644 dictation_server/src/repositories/template_files/errors/types.ts create mode 100644 dictation_server/src/repositories/workflows/errors/types.ts diff --git a/dictation_server/src/common/error/code.ts b/dictation_server/src/common/error/code.ts index c639807..f450c4e 100644 --- a/dictation_server/src/common/error/code.ts +++ b/dictation_server/src/common/error/code.ts @@ -56,4 +56,6 @@ export const ErrorCodes = [ 'E011001', // ワークタイプ重複エラー 'E011002', // ワークタイプ登録上限超過エラー 'E011003', // ワークタイプ不在エラー + 'E012001', // テンプレートファイル不在エラー + 'E013001', // ワークフローのAuthorIDとWorktypeIDのペア重複エラー ] as const; diff --git a/dictation_server/src/common/error/message.ts b/dictation_server/src/common/error/message.ts index 484e9a4..177b4cd 100644 --- a/dictation_server/src/common/error/message.ts +++ b/dictation_server/src/common/error/message.ts @@ -45,4 +45,6 @@ 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', }; diff --git a/dictation_server/src/features/workflows/test/utility.ts b/dictation_server/src/features/workflows/test/utility.ts index 6ff9f29..473f9df 100644 --- a/dictation_server/src/features/workflows/test/utility.ts +++ b/dictation_server/src/features/workflows/test/utility.ts @@ -25,7 +25,7 @@ export const createWorkflow = async ( return workflow; }; -// Workflowを取得する +// Workflow一覧を取得する export const getWorkflows = async ( datasource: DataSource, accountId: number, @@ -37,6 +37,20 @@ export const getWorkflows = async ( }); }; +// 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, @@ -59,3 +73,22 @@ export const createWorkflowTypist = async ( 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/workflows.controller.ts b/dictation_server/src/features/workflows/workflows.controller.ts index 269ca52..628b27d 100644 --- a/dictation_server/src/features/workflows/workflows.controller.ts +++ b/dictation_server/src/features/workflows/workflows.controller.ts @@ -104,13 +104,19 @@ 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 {}; } diff --git a/dictation_server/src/features/workflows/workflows.service.spec.ts b/dictation_server/src/features/workflows/workflows.service.spec.ts index 0873a5a..355c6d2 100644 --- a/dictation_server/src/features/workflows/workflows.service.spec.ts +++ b/dictation_server/src/features/workflows/workflows.service.spec.ts @@ -9,6 +9,8 @@ import { createWorktype } from '../accounts/test/utility'; import { createWorkflow, createWorkflowTypist, + getAllWorkflowTypists, + getWorkflowTypists, getWorkflows, } from './test/utility'; import { createUserGroup } from '../users/test/utility'; @@ -17,7 +19,7 @@ import { WorkflowsRepositoryService } from '../../repositories/workflows/workflo import { HttpException, HttpStatus } from '@nestjs/common'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; -describe('getWorktypes', () => { +describe('getWorkflows', () => { let source: DataSource = null; beforeEach(async () => { source = new DataSource({ @@ -223,3 +225,703 @@ describe('getWorktypes', () => { } }); }); + +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('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(); + } + } + }); +}); diff --git a/dictation_server/src/features/workflows/workflows.service.ts b/dictation_server/src/features/workflows/workflows.service.ts index 9c41266..795cd6a 100644 --- a/dictation_server/src/features/workflows/workflows.service.ts +++ b/dictation_server/src/features/workflows/workflows.service.ts @@ -1,9 +1,15 @@ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; -import { makeErrorResponse } from '../../common/error/makeErrorResponse'; -import { Context } from '../../common/log'; -import { Workflow } from './types/types'; -import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; import { WorkflowsRepositoryService } from '../../repositories/workflows/workflows.repository.service'; +import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; +import { WorkflowTypist } from './types/types'; +import { Context } from '../../common/log'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; +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 } from '../../repositories/workflows/errors/types'; +import { Workflow } from './types/types'; import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; @Injectable() @@ -106,4 +112,88 @@ export class WorkflowsService { ); } } + + /** + * ワークフローを作成する + * @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}`, + ); + } + } } 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/workflows/errors/types.ts b/dictation_server/src/repositories/workflows/errors/types.ts new file mode 100644 index 0000000..271d55e --- /dev/null +++ b/dictation_server/src/repositories/workflows/errors/types.ts @@ -0,0 +1,2 @@ +// AuthorIDとWorktypeIDのペア重複エラー +export class AuthorIdAndWorktypeIdPairAlreadyExistsError extends Error {} diff --git a/dictation_server/src/repositories/workflows/workflows.repository.service.ts b/dictation_server/src/repositories/workflows/workflows.repository.service.ts index 3b6bd34..39e9b0c 100644 --- a/dictation_server/src/repositories/workflows/workflows.repository.service.ts +++ b/dictation_server/src/repositories/workflows/workflows.repository.service.ts @@ -1,6 +1,17 @@ import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, In } 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 } from './errors/types'; @Injectable() export class WorkflowsRepositoryService { @@ -31,4 +42,162 @@ export class WorkflowsRepositoryService { 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, + }, + }); + 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); + }); + } + + /** + * 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; + } } From 964077a480adffadc77a7d7a6edfe243a318d8a3 Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Thu, 5 Oct 2023 07:49:55 +0000 Subject: [PATCH 11/22] =?UTF-8?q?Merged=20PR=20460:=20Author=E4=B8=80?= =?UTF-8?q?=E8=A6=A7=E5=8F=96=E5=BE=97API=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2748: Author一覧取得API実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2748) - Author一覧を取得するAPIとテストを実装しました。 ## レビューポイント - テストケースは適切か ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 --- .../features/accounts/accounts.controller.ts | 4 +- .../accounts/accounts.service.spec.ts | 108 ++++++++++++++++++ .../src/features/accounts/accounts.service.ts | 59 ++++++++++ .../users/users.repository.service.ts | 19 +++ 4 files changed, 188 insertions(+), 2 deletions(-) diff --git a/dictation_server/src/features/accounts/accounts.controller.ts b/dictation_server/src/features/accounts/accounts.controller.ts index 4ac1c64..21becf3 100644 --- a/dictation_server/src/features/accounts/accounts.controller.ts +++ b/dictation_server/src/features/accounts/accounts.controller.ts @@ -231,9 +231,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({ diff --git a/dictation_server/src/features/accounts/accounts.service.spec.ts b/dictation_server/src/features/accounts/accounts.service.spec.ts index 7fe4171..170f607 100644 --- a/dictation_server/src/features/accounts/accounts.service.spec.ts +++ b/dictation_server/src/features/accounts/accounts.service.spec.ts @@ -66,6 +66,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; @@ -5204,3 +5205,110 @@ 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(); + } + } + }); +}); diff --git a/dictation_server/src/features/accounts/accounts.service.ts b/dictation_server/src/features/accounts/accounts.service.ts index 495fee5..361ab8b 100644 --- a/dictation_server/src/features/accounts/accounts.service.ts +++ b/dictation_server/src/features/accounts/accounts.service.ts @@ -32,6 +32,7 @@ import { GetOptionItemsResponse, GetPartnersResponse, PostWorktypeOptionItem, + Author, } from './types/types'; import { DateWithZeroTime, @@ -554,6 +555,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 パートナーの会社名 diff --git a/dictation_server/src/repositories/users/users.repository.service.ts b/dictation_server/src/repositories/users/users.repository.service.ts index b4c388d..4ecb668 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -375,6 +375,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 From a8bacefc5f1fd9440d098f628a773d4b7c32e68e Mon Sep 17 00:00:00 2001 From: "maruyama.t" Date: Thu, 5 Oct 2023 08:16:00 +0000 Subject: [PATCH 12/22] =?UTF-8?q?Merged=20PR=20461:=20API=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E5=AE=9F=E6=96=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2672: APIテスト実施](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2672) - 元PBI or タスクへのリンク(内容・目的などはそちらにあるはず) - 何をどう変更したか、追加したライブラリなど - このPull Requestでの対象/対象外 詳細なレコード(ライセンス、タスク、ユーザーグループなど)は別途dev動作確認にてデータを用意して行います。 現時点では、各レコードの削除はMySQL用にmigrationファイルにて記述したON DELETE CASCADEの機能にて削除を行う為、SQLiteを用いた本ユニットテストでは動作確認対象外としています。 - 影響範囲(他の機能にも影響があるか) entityの定義(accounts - users)のON DELETE CASCADEを明記 ## レビューポイント - 本ユニットテストは正常系の動作確認と、それぞれのservice内部で異常発生時もAPI自体は正常終了し、[MANUAL_RECOVERY_REQUIRED]ログが表示されることの確認を主な目的として実装しています。 ## UIの変更 なし ## 動作確認状況 - ローカルで確認(ユニットテスト) ## 補足 - 相談、参考資料などがあれば --- dictation_server/src/common/test/overrides.ts | 14 ++ .../accounts/accounts.service.spec.ts | 230 +++++++++++++++++- .../accounts/accounts.repository.service.ts | 2 - .../repositories/users/entity/user.entity.ts | 2 +- 4 files changed, 244 insertions(+), 4 deletions(-) diff --git a/dictation_server/src/common/test/overrides.ts b/dictation_server/src/common/test/overrides.ts index 31ce97c..be8404f 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, @@ -232,6 +239,7 @@ export const overrideAccountsRepositoryService = ( adminUserAcceptedTermsVersion: string, ) => Promise<{ newAccount: Account; adminUser: User }>; deleteAccount?: (accountId: number, userId: number) => Promise; + deleteAccountAndInsertArchives?: (accountId: number) => Promise; }, ): void => { // テストコードでのみ許される強引な方法でprivateメンバ変数の参照を取得 @@ -248,4 +256,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/features/accounts/accounts.service.spec.ts b/dictation_server/src/features/accounts/accounts.service.spec.ts index 170f607..e911f36 100644 --- a/dictation_server/src/features/accounts/accounts.service.spec.ts +++ b/dictation_server/src/features/accounts/accounts.service.spec.ts @@ -34,6 +34,7 @@ import { getUsers, makeTestUser, makeHierarchicalAccounts, + getUser, } from '../../common/test/utility'; import { AccountsService } from './accounts.service'; import { Context, makeContext } from '../../common/log'; @@ -5205,7 +5206,6 @@ describe('getAccountInfo', () => { } }); }); - describe('getAuthors', () => { let source: DataSource = null; beforeEach(async () => { @@ -5312,3 +5312,231 @@ describe('getAuthors', () => { } }); }); +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, + }); + // 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); + }); + 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/repositories/accounts/accounts.repository.service.ts b/dictation_server/src/repositories/accounts/accounts.repository.service.ts index 510a865..2df7ab7 100644 --- a/dictation_server/src/repositories/accounts/accounts.repository.service.ts +++ b/dictation_server/src/repositories/accounts/accounts.repository.service.ts @@ -916,7 +916,6 @@ export class AccountsRepositoryService { account_id: accountId, }, }); - const userArchiveRepo = entityManager.getRepository(UserArchive); await userArchiveRepo .createQueryBuilder() @@ -924,7 +923,6 @@ export class AccountsRepositoryService { .into(UserArchive) .values(users) .execute(); - // アカウントを削除 // アカウントを削除することで、外部キー制約がで紐づいている関連テーブルのデータも削除される const accountRepo = entityManager.getRepository(Account); diff --git a/dictation_server/src/repositories/users/entity/user.entity.ts b/dictation_server/src/repositories/users/entity/user.entity.ts index 9e375df..007be46 100644 --- a/dictation_server/src/repositories/users/entity/user.entity.ts +++ b/dictation_server/src/repositories/users/entity/user.entity.ts @@ -70,7 +70,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; From 983726eaf369f40e1166f1b6c579f9042b8b2e36 Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Thu, 5 Oct 2023 09:41:20 +0000 Subject: [PATCH 13/22] =?UTF-8?q?Merged=20PR=20467:=20API=20IF=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2784: API IF実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2784) - ワークフロー削除APIのIFを実装しOpenAPI定義を更新しました。 - アプリで使用しない環境変数を削除し、チェック対象から外しました。 - `KEY_VAULT_NAME` ## レビューポイント - APIのパスは適切か - 対応する環境変数は足りているか ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 --- dictation_server/.env.local.example | 1 - dictation_server/src/api/odms/openapi.json | 47 +++++++++++++++++++ .../src/common/validators/env.validator.ts | 4 -- .../src/features/workflows/types/types.ts | 10 ++++ .../workflows/workflows.controller.ts | 39 +++++++++++++++ 5 files changed, 96 insertions(+), 5 deletions(-) diff --git a/dictation_server/.env.local.example b/dictation_server/.env.local.example index eda234a..3aa12ac 100644 --- a/dictation_server/.env.local.example +++ b/dictation_server/.env.local.example @@ -9,7 +9,6 @@ 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 diff --git a/dictation_server/src/api/odms/openapi.json b/dictation_server/src/api/odms/openapi.json index 935d920..ace1893 100644 --- a/dictation_server/src/api/odms/openapi.json +++ b/dictation_server/src/api/odms/openapi.json @@ -3038,6 +3038,52 @@ "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", @@ -4348,6 +4394,7 @@ "required": ["authorId", "typists"] }, "UpdateWorkflowResponse": { "type": "object", "properties": {} }, + "DeleteWorkflowResponse": { "type": "object", "properties": {} }, "RegisterRequest": { "type": "object", "properties": { diff --git a/dictation_server/src/common/validators/env.validator.ts b/dictation_server/src/common/validators/env.validator.ts index 22cfbfe..836de47 100644 --- a/dictation_server/src/common/validators/env.validator.ts +++ b/dictation_server/src/common/validators/env.validator.ts @@ -73,10 +73,6 @@ export class EnvValidator { @IsString() ADB2C_ORIGIN: string; - @IsNotEmpty() - @IsString() - KEY_VAULT_NAME: string; - @IsNotEmpty() @IsString() JWT_PRIVATE_KEY: string; diff --git a/dictation_server/src/features/workflows/types/types.ts b/dictation_server/src/features/workflows/types/types.ts index d5a99e8..ced5b59 100644 --- a/dictation_server/src/features/workflows/types/types.ts +++ b/dictation_server/src/features/workflows/types/types.ts @@ -118,3 +118,13 @@ export class UpdateWorkflowRequest { } 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 628b27d..d337c58 100644 --- a/dictation_server/src/features/workflows/workflows.controller.ts +++ b/dictation_server/src/features/workflows/workflows.controller.ts @@ -24,6 +24,8 @@ import { UpdateWorkflowResponse, UpdateWorkflowRequest, UpdateWorkflowRequestParam, + DeleteWorkflowRequestParam, + DeleteWorkflowResponse, } from './types/types'; import { AuthGuard } from '../../common/guards/auth/authguards'; import { RoleGuard } from '../../common/guards/role/roleguards'; @@ -167,4 +169,41 @@ export class WorkflowsController { 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 {}; + } } From f70e266e859a04c7fd237203a05b42b26e0ce5e7 Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Fri, 6 Oct 2023 00:10:47 +0000 Subject: [PATCH 14/22] =?UTF-8?q?Merged=20PR=20465:=20API=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=EF=BC=88=E3=83=AF=E3=83=BC=E3=82=AF=E3=83=95=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E6=9B=B4=E6=96=B0API=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2776: API実装(ワークフロー更新API)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2776) - ワークフロー編集APIとテストを実装しました。 ## レビューポイント - リポジトリでのチェック処理は適切か - テストケースは適切か ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 --- dictation_server/src/common/error/code.ts | 1 + dictation_server/src/common/error/message.ts | 1 + .../workflows/workflows.controller.ts | 15 +- .../workflows/workflows.service.spec.ts | 897 ++++++++++++++++++ .../features/workflows/workflows.service.ts | 102 +- .../repositories/workflows/errors/types.ts | 2 + .../workflows/workflows.repository.service.ts | 144 ++- 7 files changed, 1152 insertions(+), 10 deletions(-) diff --git a/dictation_server/src/common/error/code.ts b/dictation_server/src/common/error/code.ts index f450c4e..448aa82 100644 --- a/dictation_server/src/common/error/code.ts +++ b/dictation_server/src/common/error/code.ts @@ -58,4 +58,5 @@ export const ErrorCodes = [ '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 177b4cd..eeee5b3 100644 --- a/dictation_server/src/common/error/message.ts +++ b/dictation_server/src/common/error/message.ts @@ -47,4 +47,5 @@ export const errors: Errors = { 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/features/workflows/workflows.controller.ts b/dictation_server/src/features/workflows/workflows.controller.ts index d337c58..c571842 100644 --- a/dictation_server/src/features/workflows/workflows.controller.ts +++ b/dictation_server/src/features/workflows/workflows.controller.ts @@ -156,16 +156,21 @@ export class WorkflowsController { @Param() param: UpdateWorkflowRequestParam, @Body() body: UpdateWorkflowRequest, ): Promise { - const { authorId } = body; + const { authorId, worktypeId, templateId, typists } = body; const { workflowId } = param; const token = retrieveAuthorizationToken(req); const { userId } = jwt.decode(token, { json: true }) as AccessToken; - console.log('updateWorkflow'); const context = makeContext(userId); - console.log(context.trackingId); - console.log(authorId); - console.log(workflowId); + await this.workflowsService.updateWorkflow( + context, + userId, + workflowId, + authorId, + worktypeId, + templateId, + typists, + ); return {}; } diff --git a/dictation_server/src/features/workflows/workflows.service.spec.ts b/dictation_server/src/features/workflows/workflows.service.spec.ts index 355c6d2..7adcda4 100644 --- a/dictation_server/src/features/workflows/workflows.service.spec.ts +++ b/dictation_server/src/features/workflows/workflows.service.spec.ts @@ -925,3 +925,900 @@ describe('createWorkflows', () => { } }); }); + +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('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 795cd6a..288629a 100644 --- a/dictation_server/src/features/workflows/workflows.service.ts +++ b/dictation_server/src/features/workflows/workflows.service.ts @@ -1,16 +1,18 @@ 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 { WorkflowTypist } from './types/types'; 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 } from '../../repositories/workflows/errors/types'; -import { Workflow } from './types/types'; -import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; +import { + AuthorIdAndWorktypeIdPairAlreadyExistsError, + WorkflowIdNotFoundError, +} from '../../repositories/workflows/errors/types'; @Injectable() export class WorkflowsService { @@ -196,4 +198,96 @@ export class WorkflowsService { ); } } + /** + * アカウント内のワークフローを更新する + * @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/repositories/workflows/errors/types.ts b/dictation_server/src/repositories/workflows/errors/types.ts index 271d55e..8680e30 100644 --- a/dictation_server/src/repositories/workflows/errors/types.ts +++ b/dictation_server/src/repositories/workflows/errors/types.ts @@ -1,2 +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.service.ts b/dictation_server/src/repositories/workflows/workflows.repository.service.ts index 39e9b0c..7a06e55 100644 --- a/dictation_server/src/repositories/workflows/workflows.repository.service.ts +++ b/dictation_server/src/repositories/workflows/workflows.repository.service.ts @@ -11,7 +11,10 @@ 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 } from './errors/types'; +import { + AuthorIdAndWorktypeIdPairAlreadyExistsError, + WorkflowIdNotFoundError, +} from './errors/types'; @Injectable() export class WorkflowsRepositoryService { @@ -37,6 +40,9 @@ export class WorkflowsRepositoryService { typistGroup: true, }, }, + order: { + id: 'ASC', + }, }); return workflows; @@ -158,6 +164,142 @@ export class WorkflowsRepositoryService { }); } + /** + * ワークフローを更新する + * @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, + }, + }); + 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 From 34cf80d636fd219c54cb79bc524a26935624a707 Mon Sep 17 00:00:00 2001 From: "oura.a" Date: Fri, 6 Oct 2023 05:17:43 +0000 Subject: [PATCH 15/22] =?UTF-8?q?Merged=20PR=20469:=20API-IF=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2806: API-IF実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2806) 以下APIのIFを実装しました。 ・アカウント情報取得(未認証時最小アクセス)API ・利用規約情報取得API ・同意済バージョン更新API またトークン生成APIのIFにコメントを追加しました。 ## レビューポイント なし ## UIの変更 なし ## 動作確認状況 swaggerUIで動作確認 ## 補足 なし --- dictation_server/src/api/odms/openapi.json | 154 +++++++++++++++++- dictation_server/src/app.module.ts | 2 + .../features/accounts/accounts.controller.ts | 30 ++++ .../src/features/accounts/types/types.ts | 10 ++ .../src/features/auth/auth.controller.ts | 2 +- .../features/terms/terms.controller.spec.ts | 18 ++ .../src/features/terms/terms.controller.ts | 40 +++++ .../src/features/terms/terms.module.ts | 9 + .../src/features/terms/terms.service.spec.ts | 18 ++ .../src/features/terms/terms.service.ts | 4 + .../src/features/terms/types/types.ts | 12 ++ .../src/features/users/types/types.ts | 11 ++ .../src/features/users/users.controller.ts | 33 ++++ 13 files changed, 340 insertions(+), 3 deletions(-) create mode 100644 dictation_server/src/features/terms/terms.controller.spec.ts create mode 100644 dictation_server/src/features/terms/terms.controller.ts create mode 100644 dictation_server/src/features/terms/terms.module.ts create mode 100644 dictation_server/src/features/terms/terms.service.spec.ts create mode 100644 dictation_server/src/features/terms/terms.service.ts create mode 100644 dictation_server/src/features/terms/types/types.ts diff --git a/dictation_server/src/api/odms/openapi.json b/dictation_server/src/api/odms/openapi.json index ace1893..3b567df 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" } @@ -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", @@ -3134,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": { @@ -3705,6 +3826,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" } }, @@ -3925,6 +4058,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": { @@ -4406,7 +4555,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 ee977ec..f8c0664 100644 --- a/dictation_server/src/app.module.ts +++ b/dictation_server/src/app.module.ts @@ -49,6 +49,7 @@ 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: [ @@ -108,6 +109,7 @@ import { WorkflowsRepositoryModule } from './repositories/workflows/workflows.re AuthGuardsModule, SortCriteriaRepositoryModule, WorktypesRepositoryModule, + TermsModule, ], controllers: [ HealthController, diff --git a/dictation_server/src/features/accounts/accounts.controller.ts b/dictation_server/src/features/accounts/accounts.controller.ts index 21becf3..e6039e8 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'; @@ -1091,4 +1093,32 @@ export class AccountsController { 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/types/types.ts b/dictation_server/src/features/accounts/types/types.ts index 8a8afb6..f5aae67 100644 --- a/dictation_server/src/features/accounts/types/types.ts +++ b/dictation_server/src/features/accounts/types/types.ts @@ -577,3 +577,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/terms/terms.controller.spec.ts b/dictation_server/src/features/terms/terms.controller.spec.ts new file mode 100644 index 0000000..d15564a --- /dev/null +++ b/dictation_server/src/features/terms/terms.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TermsController } from './terms.controller'; + +describe('TermsController', () => { + let controller: TermsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TermsController], + }).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/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 {}; + } } From 1eedc5c0be7118d1a42d630dbc0e71e819e0f2dd Mon Sep 17 00:00:00 2001 From: "oura.a" Date: Fri, 6 Oct 2023 05:50:30 +0000 Subject: [PATCH 16/22] =?UTF-8?q?Merged=20PR=20474:=20=E3=83=93=E3=83=AB?= =?UTF-8?q?=E3=83=89=E3=82=A8=E3=83=A9=E3=83=BC=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2815: ビルドエラー修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2815) ビルドエラー修正 ## レビューポイント なし ## UIの変更 なし ## 動作確認状況 UT確認 ## 補足 なし --- dictation_server/src/features/terms/terms.controller.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dictation_server/src/features/terms/terms.controller.spec.ts b/dictation_server/src/features/terms/terms.controller.spec.ts index d15564a..7473f05 100644 --- a/dictation_server/src/features/terms/terms.controller.spec.ts +++ b/dictation_server/src/features/terms/terms.controller.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TermsController } from './terms.controller'; +import { TermsService } from './terms.service'; describe('TermsController', () => { let controller: TermsController; @@ -7,6 +8,7 @@ describe('TermsController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [TermsController], + providers: [TermsService], }).compile(); controller = module.get(TermsController); From aef6a35a7dfb7e09f4ccbbc94cc946320a5b3a4c Mon Sep 17 00:00:00 2001 From: "oura.a" Date: Fri, 6 Oct 2023 06:04:11 +0000 Subject: [PATCH 17/22] =?UTF-8?q?Merged=20PR=20466:=20[PBI1220=E6=AE=8B]?= =?UTF-8?q?=E9=80=80=E9=81=BF=E3=83=86=E3=83=BC=E3=83=96=E3=83=AB=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=EF=BC=88=E6=9C=88=E3=81=AE=E9=80=94=E4=B8=AD=E3=81=A7?= =?UTF-8?q?=E9=80=80=E4=BC=9A=E3=81=97=E3=81=9F=E3=82=A2=E3=82=AB=E3=82=A6?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=81=AE=E9=9B=86=E8=A8=88=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2767: [PBI1220残]退避テーブル対応(月の途中で退会したアカウントの集計対応)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2767) アカウント削除時に以下のテーブルの削除対象データを退避する処理を追加しました。 ・ライセンステーブル ・ライセンス割り当て履歴テーブル ## レビューポイント なし ## UIの変更 なし ## 動作確認状況 UT,ローカル動作確認を実施済み ## 補足 なし --- dictation_server/src/common/test/utility.ts | 13 ++- .../accounts/accounts.service.spec.ts | 36 ++++++++ .../src/features/licenses/test/utility.ts | 24 +++++ .../accounts/accounts.repository.service.ts | 41 ++++++++- .../licenses/entity/license.entity.ts | 88 +++++++++++++++++++ .../licenses/licenses.repository.module.ts | 4 + 6 files changed, 204 insertions(+), 2 deletions(-) diff --git a/dictation_server/src/common/test/utility.ts b/dictation_server/src/common/test/utility.ts index a5c998b..04b2589 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'; @@ -368,3 +368,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/features/accounts/accounts.service.spec.ts b/dictation_server/src/features/accounts/accounts.service.spec.ts index e911f36..22d0e81 100644 --- a/dictation_server/src/features/accounts/accounts.service.spec.ts +++ b/dictation_server/src/features/accounts/accounts.service.spec.ts @@ -35,6 +35,7 @@ import { makeTestUser, makeHierarchicalAccounts, getUser, + getUserArchive, } from '../../common/test/utility'; import { AccountsService } from './accounts.service'; import { Context, makeContext } from '../../common/log'; @@ -59,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'; @@ -5350,6 +5354,28 @@ describe('deleteAccountAndData', () => { 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(), @@ -5371,6 +5397,16 @@ describe('deleteAccountAndData', () => { 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); 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/repositories/accounts/accounts.repository.service.ts b/dictation_server/src/repositories/accounts/accounts.repository.service.ts index 2df7ab7..d94ae57 100644 --- a/dictation_server/src/repositories/accounts/accounts.repository.service.ts +++ b/dictation_server/src/repositories/accounts/accounts.repository.service.ts @@ -12,7 +12,13 @@ import { } from 'typeorm'; 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, @@ -923,6 +929,39 @@ export class AccountsRepositoryService { .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); 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], From 7682c41ba57db973dc817c4de2dbf936de5d9a03 Mon Sep 17 00:00:00 2001 From: "saito.k" Date: Tue, 10 Oct 2023 00:57:11 +0000 Subject: [PATCH 18/22] =?UTF-8?q?Merged=20PR=20462:=20=E3=83=AF=E3=83=BC?= =?UTF-8?q?=E3=82=AF=E3=83=95=E3=83=AD=E3=83=BC=E8=BF=BD=E5=8A=A0=E3=83=9D?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=82=A2=E3=83=83=E3=83=97=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task2740: ワークフロー追加ポップアップ実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2740) - ワークフロー追加Popupを実装 - 多言語対応 ## レビューポイント - タイピスト・タイピストグループの変更処理 - 選択・除外で処理を分けたが一緒にしたほうが良い? - 各パラメータの変更処理 - 値のチェックは足りているか ## UIの変更 - https://ndstokyo.sharepoint.com/:f:/r/sites/Piranha/Shared%20Documents/General/OMDS/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88/Task2740?csf=1&web=1&e=UHGFtv ## 動作確認状況 - ローカルで確認 ## 補足 - 相談、参考資料などがあれば --- dictation_client/src/common/errors/code.ts | 1 + .../src/features/workflow/operations.ts | 173 +++++++++- .../src/features/workflow/selectors.ts | 45 +++ .../src/features/workflow/state.ts | 15 +- .../src/features/workflow/workflowSlice.ts | 116 ++++++- .../pages/WorkflowPage/addworkflowPopup.tsx | 293 +++++++++++++++++ .../src/pages/WorkflowPage/index.tsx | 295 ++++++++++-------- dictation_client/src/translation/de.json | 17 +- dictation_client/src/translation/en.json | 17 +- dictation_client/src/translation/es.json | 17 +- dictation_client/src/translation/fr.json | 17 +- 11 files changed, 861 insertions(+), 145 deletions(-) create mode 100644 dictation_client/src/pages/WorkflowPage/addworkflowPopup.tsx 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/workflow/operations.ts b/dictation_client/src/features/workflow/operations.ts index 41957aa..76e84a5 100644 --- a/dictation_client/src/features/workflow/operations.ts +++ b/dictation_client/src/features/workflow/operations.ts @@ -1,9 +1,22 @@ import { createAsyncThunk } from "@reduxjs/toolkit"; -import { Configuration, GetWorkflowsResponse, WorkflowsApi } from "api"; +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, @@ -40,3 +53,161 @@ export const listWorkflowAsync = createAsyncThunk< 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 index 57745ed..a71ba4a 100644 --- a/dictation_client/src/features/workflow/selectors.ts +++ b/dictation_client/src/features/workflow/selectors.ts @@ -1,3 +1,4 @@ +import { Assignee } from "api"; import { RootState } from "app/store"; export const selectWorkflows = (state: RootState) => @@ -5,3 +6,47 @@ export const selectWorkflows = (state: RootState) => 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 index 6a9d838..e99a186 100644 --- a/dictation_client/src/features/workflow/state.ts +++ b/dictation_client/src/features/workflow/state.ts @@ -1,4 +1,4 @@ -import { Workflow } from "api"; +import { Assignee, Author, TemplateFile, Workflow, Worktype } from "api"; export interface WorkflowState { apps: Apps; @@ -7,8 +7,21 @@ export interface WorkflowState { 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 index dd94a79..bb6aa9f 100644 --- a/dictation_client/src/features/workflow/workflowSlice.ts +++ b/dictation_client/src/features/workflow/workflowSlice.ts @@ -1,10 +1,17 @@ -import { createSlice } from "@reduxjs/toolkit"; +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { Assignee } from "api"; +import { + createWorkflowAsync, + getworkflowRelationsAsync, + listWorkflowAsync, +} from "./operations"; import { WorkflowState } from "./state"; -import { listWorkflowAsync } from "./operations"; const initialState: WorkflowState = { apps: { isLoading: false, + isAddLoading: false, + selectedAssignees: [], }, domain: {}, }; @@ -12,7 +19,55 @@ const initialState: WorkflowState = { export const workflowSlice = createSlice({ name: "workflow", initialState, - reducers: {}, + 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; @@ -26,7 +81,62 @@ export const workflowSlice = createSlice({ 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/WorkflowPage/addworkflowPopup.tsx b/dictation_client/src/pages/WorkflowPage/addworkflowPopup.tsx new file mode 100644 index 0000000..0192cd3 --- /dev/null +++ b/dictation_client/src/pages/WorkflowPage/addworkflowPopup.tsx @@ -0,0 +1,293 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { AppDispatch } from "app/store"; +import progress_activit from "assets/images/progress_activit.svg"; +import { + addAssignee, + removeAssignee, + changeAuthor, + changeTemplate, + changeWorktype, + clearWorkflow, + selectIsAddLoading, + selectWorkflowAssinee, + selectWorkflowError, + selectWorkflowRelations, +} from "features/workflow"; +import { + createWorkflowAsync, + getworkflowRelationsAsync, + listWorkflowAsync, +} from "features/workflow/operations"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import styles from "styles/app.module.scss"; +import { getTranslationID } from "translation"; +import close from "../../assets/images/close.svg"; + +interface AddWorkflowPopupProps { + onClose: () => 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("worktypeIdSetting.label.addWorktypeId"))} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */} + close +

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

+

    +
  • + {t(getTranslationID("workflowPage.label.pool"))} +
  • + {poolAssignees?.map((x) => { + const key = `${x.typistName}_${ + x.typistUserId ?? x.typistGroupId + }`; + return ( +
  • + dispatch(addAssignee({ assignee: x }))} + /> + +
  • + ); + })} +
+ {isPushAddButton && hasSelectedWorkflowAssineeEmptyError && ( + + {t( + getTranslationID( + "workflowPage.message.selectedTypistEmptyError" + ) + )} + + )} +
+
+ {t(getTranslationID("workflowPage.label.templateOptional"))} +
+
+ +
+
+ + {isLoading && ( + Loading + )} +
+
+
+
+
+ ); +}; diff --git a/dictation_client/src/pages/WorkflowPage/index.tsx b/dictation_client/src/pages/WorkflowPage/index.tsx index c7e8351..355891c 100644 --- a/dictation_client/src/pages/WorkflowPage/index.tsx +++ b/dictation_client/src/pages/WorkflowPage/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } 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"; @@ -14,10 +14,13 @@ 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 => { const dispatch: AppDispatch = useDispatch(); const [t] = useTranslation(); + // 追加Popupの表示制御 + const [isShowAddPopup, setIsShowAddPopup] = useState(false); const workflows = useSelector(selectWorkflows); const isLoading = useSelector(selectIsLoading); @@ -25,138 +28,166 @@ const WorkflowPage: React.FC = (): JSX.Element => { dispatch(listWorkflowAsync()); }, [dispatch]); return ( -
-
- -
-
-
-

- {t(getTranslationID("workflowPage.label.title"))} -

-
-
-
- - - - - - - - - - {workflows?.map((workflow) => ( - - - - - - - - - ))} -
{/** empty th */}{t(getTranslationID("workflowPage.label.authorID"))}{t(getTranslationID("workflowPage.label.worktype"))} - {t(getTranslationID("workflowPage.label.transcriptionist"))} - {t(getTranslationID("workflowPage.label.template"))}
- - {workflow.author.authorId}{workflow.worktype?.worktypeId ?? "-"} - {workflow.typists.map((typist, i) => ( - <> - {typist.typistName} - {i !== workflow.typists.length - 1 &&
} - - ))} -
{workflow.template?.fileName ?? "-"}
- {!isLoading && workflows?.length === 0 && ( -

- {t(getTranslationID("common.message.listEmpty"))} -

- )} - {isLoading && ( - Loading - )} + <> + {isShowAddPopup && ( + { + setIsShowAddPopup(false); + }} + /> + )} +
+
+ +
+
+
+

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

-
-
-
-
-
+
+
+ + + + + + + + + + {workflows?.map((workflow) => ( + + + + + + + + + ))} +
{/** empty th */} + {t(getTranslationID("workflowPage.label.authorID"))} + + {t(getTranslationID("workflowPage.label.worktype"))} + + {t( + getTranslationID("workflowPage.label.transcriptionist") + )} + + {t(getTranslationID("workflowPage.label.template"))} +
+ + {workflow.author.authorId}{workflow.worktype?.worktypeId ?? "-"} + {workflow.typists.map((typist, i) => ( + <> + {typist.typistName} + {i !== workflow.typists.length - 1 &&
} + + ))} +
{workflow.template?.fileName ?? "-"}
+ {!isLoading && workflows?.length === 0 && ( +

+ {t(getTranslationID("common.message.listEmpty"))} +

+ )} + {isLoading && ( + Loading + )} +
+
+ + +