From 8b04adf09595441f16dff679c54aaeac89d1df9e Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Tue, 28 Nov 2023 06:21:23 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=20587:=20=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=EF=BC=88=E3=83=80=E3=82=A6=E3=83=B3=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=83=89=E5=87=A6=E7=90=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task3122: 画面実装(ダウンロード処理)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3122) - ファイルバックアップポップアップからのダウンロード実行処理を実装しました。 - ファイルURL取得をAdminが適切に実施できるように権限を修正しました。 - SASトークンの開始時刻を修正しました。 - ダウンロード処理を実行できるようにするためにblobストレージのCORS設定を修正しました。 - faviconとタイトルを正式なものに差し替えました。 ## レビューポイント - faviconはアセットフォルダに一緒に入れてしまっていますが配置場所として問題ないでしょうか? - `Operation`内でループで一件ずつダウンロード・バックアップ処理を実行していますが認識あっていますでしょうか? - SASトークンの生成時の開始時刻10秒前にしていますが問題ないでしょうか? - SASトークン付きURLの発行直後にURLでダウンロード実行するとSASトークンエラーとなることがありましたので、その対応です。 - 発行直後に使うはずなのでSASトークンの開始を10秒前にしても影響はないはずと考えています。 - ほかの対応としてトークン取得後に数秒待つことも考えましたが、動作全体が遅くなってしまうのとSASトークンの作りの問題だとがんが得ているのでこのような対応をしています。 ## UIの変更 - [Task3122](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/Task3122?csf=1&web=1&e=2JPi0J) ## 動作確認状況 - ローカルで確認 --- dictation_client/index.html | 8 +- .../src/assets/images/favicon.ico | Bin 0 -> 4286 bytes dictation_client/src/favicon.svg | 15 --- .../src/features/dictation/dictationSlice.ts | 11 ++ .../src/features/dictation/operations.ts | 98 +++++++++++++++++- .../src/features/dictation/selectors.ts | 3 + .../src/features/dictation/state.ts | 1 + .../src/pages/DictationPage/backupPopup.tsx | 59 ++++++++++- .../src/pages/DictationPage/index.tsx | 4 - dictation_client/src/translation/de.json | 2 +- dictation_client/src/translation/en.json | 2 +- dictation_client/src/translation/es.json | 2 +- dictation_client/src/translation/fr.json | 2 +- .../src/features/files/files.controller.ts | 4 +- .../src/features/files/files.service.ts | 37 ++++--- .../blobstorage/blobstorage.service.ts | 6 +- 16 files changed, 208 insertions(+), 46 deletions(-) create mode 100644 dictation_client/src/assets/images/favicon.ico delete mode 100644 dictation_client/src/favicon.svg diff --git a/dictation_client/index.html b/dictation_client/index.html index d23453a..17b7944 100644 --- a/dictation_client/index.html +++ b/dictation_client/index.html @@ -2,9 +2,13 @@ - + - Vite App + ODMS Cloud diff --git a/dictation_client/src/assets/images/favicon.ico b/dictation_client/src/assets/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f3319f39a6b42adcaac8912113efcace185ea3d5 GIT binary patch literal 4286 zcmeH|J1lis6vvPE+Xyk8w_*w^(TD_vN+AjriHzta3Y}NfBvcwiG?OT(#3SMnAsPhH zs!<5O3Dp-}>f%=8Tzsitp~5eb(OV^4~7}h-{CB@g`MQK7pg3FSUl1zNw4c5_p zMn*=chg^2a$;r;o%*=F(Og!v`9hIl1riS+D=j7yswrOc;MlA4|vj4BWa1;wvJn4hQ zTKvMoLaVE*vxS8P+uGW)-Q8VVSy{1;jtQpR@(6J zu=7hwN<99ao*o+*7_gzCp%6nj!UbEoD=jUx>FH_H`276z<;%;9)BXK@tF5iIrluyT z*xK5f`yLq?ao)$rhxPaOJ8xrS!@j=0?EU>c!~jqEAm>_VWn~%q4B7bRuMd9lb9;Mh zZEbBvY_G4cw!Xe@_&Gm657kdkPaS_}XD8$--_U5^P)}xid%JTUl9| z_4W1H<>jTz?(gsI>gvkZ=u@B8)>hv?JUn>pY-(z1Y;|?jo}ZuHFSzsy=LKK+S1gN* zi|zyL@$vC6FN&M^R0nX$(c9ab_sHz*tlPrVn3|fhy}dnc|K(49MIRp@J)am97Z-c} z=ySzMj~pEx`B}ixJUl#f3~Iv=B~Qo4$Dy9?iOvhIYL860=Zv~v+1c412lYXJb92)% z_^3CFMc) z&zqYY7f}nvsynVY^xi2a6%`eJKKbOH(O1NEc6Ju_2L8sz#$1h9xq}Se8GQ4;OiWBT zkK7}NBBS1_IeX5nJ#`>=+#v?_Q!hCq_94$RGc(rM*!YiJPy^z}C%I*+5|I6Sm}5?>BqS%Dbr^C5P%~VL=ND3f!I^h}t9jt}pp_zUmb98(kBN;ujwt all^y@bmH{yevIcLo{OKT2VzJ4|Hhw6fK=H4 literal 0 HcmV?d00001 diff --git a/dictation_client/src/favicon.svg b/dictation_client/src/favicon.svg deleted file mode 100644 index de4aedd..0000000 --- a/dictation_client/src/favicon.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/dictation_client/src/features/dictation/dictationSlice.ts b/dictation_client/src/features/dictation/dictationSlice.ts index 66559fb..c51fd3d 100644 --- a/dictation_client/src/features/dictation/dictationSlice.ts +++ b/dictation_client/src/features/dictation/dictationSlice.ts @@ -2,6 +2,7 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { Assignee, Task } from "api/api"; import { DictationState } from "./state"; import { + backupTasksAsync, getSortColumnAsync, listBackupPopupTasksAsync, listTasksAsync, @@ -46,6 +47,7 @@ const initialState: DictationState = { }, isLoading: true, isBackupListLoading: false, + isDownloading: false, }, }; @@ -203,6 +205,15 @@ export const dictationSlice = createSlice({ builder.addCase(listBackupPopupTasksAsync.rejected, (state) => { state.apps.isBackupListLoading = false; }); + builder.addCase(backupTasksAsync.pending, (state) => { + state.apps.isDownloading = true; + }); + builder.addCase(backupTasksAsync.fulfilled, (state) => { + state.apps.isDownloading = false; + }); + builder.addCase(backupTasksAsync.rejected, (state) => { + state.apps.isDownloading = false; + }); }, }); diff --git a/dictation_client/src/features/dictation/operations.ts b/dictation_client/src/features/dictation/operations.ts index b78e67f..23b98f4 100644 --- a/dictation_client/src/features/dictation/operations.ts +++ b/dictation_client/src/features/dictation/operations.ts @@ -3,6 +3,7 @@ import type { RootState } from "app/store"; import { getTranslationID } from "translation"; import { openSnackbar } from "features/ui/uiSlice"; import { getAccessToken } from "features/auth"; +import { BlobClient } from "@azure/storage-blob"; import { TasksResponse, TasksApi, @@ -11,6 +12,7 @@ import { GetTypistsResponse, GetTypistGroupsResponse, Assignee, + FilesApi, } from "../../api/api"; import { Configuration } from "../../api/configuration"; import { ErrorObject, createErrorObject } from "../../common/errors"; @@ -22,6 +24,7 @@ import { SORTABLE_COLUMN, SortableColumnType, } from "./constants"; +import { BackupTask } from "./types"; export const listTasksAsync = createAsyncThunk< TasksResponse, @@ -455,7 +458,7 @@ export const listBackupPopupTasksAsync = createAsyncThunk< BACKUP_POPUP_LIST_SIZE, offset, BACKUP_POPUP_LIST_STATUS.join(","), // ステータスはFinished,Backupのみ - DIRECTION.ASC, + DIRECTION.DESC, SORTABLE_COLUMN.Status, { headers: { authorization: `Bearer ${accessToken}` }, @@ -476,3 +479,96 @@ export const listBackupPopupTasksAsync = createAsyncThunk< return thunkApi.rejectWithValue({ error }); } }); + +export const backupTasksAsync = createAsyncThunk< + { + // empty + }, + { + // パラメータ + tasks: BackupTask[]; + }, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("dictations/backupTasksAsync", async (args, thunkApi) => { + const { tasks } = args; + + // apiのConfigurationを取得する + const { getState } = thunkApi; + const state = getState() as RootState; + const { configuration } = state.auth; + const accessToken = getAccessToken(state.auth); + const config = new Configuration(configuration); + const tasksApi = new TasksApi(config); + const filesApi = new FilesApi(config); + + try { + // eslint-disable-next-line no-restricted-syntax + for (const task of tasks) { + if (task.checked) { + // eslint-disable-next-line no-await-in-loop + const { data } = await filesApi.downloadLocation(task.audioFileId, { + headers: { authorization: `Bearer ${accessToken}` }, + }); + + const { url } = data; + const { pathname } = new URL(url); + + const paths = pathname.split("/").filter((p) => p !== ""); + if (paths.length < 2) { + throw new Error("invalid path"); + } + const blobName = paths[1]; + + // コンテナとBlobを取得 + const blobClient = new BlobClient(url); + // Blobをダウンロード + // eslint-disable-next-line no-await-in-loop + const blobDownloadResponse = await blobClient.download(); + // eslint-disable-next-line no-await-in-loop + const blobBody = await blobDownloadResponse.blobBody; + + if (!blobBody) { + throw new Error("invalid blobBody"); + } + + // ダウンロードしたBlobをローカルに保存するリンクを作成してクリックする + const blobURL = window.URL.createObjectURL(blobBody); + const a = document.createElement("a"); + a.href = blobURL; + a.download = blobName; + document.body.appendChild(a); + a.click(); + a.parentNode?.removeChild(a); + + // eslint-disable-next-line no-await-in-loop + await tasksApi.backup(task.audioFileId, { + headers: { authorization: `Bearer ${accessToken}` }, + }); + } + } + + thunkApi.dispatch( + openSnackbar({ + level: "info", + message: getTranslationID("common.message.success"), + }) + ); + return {}; + } catch (e) { + // e ⇒ errorObjectに変換" + const error = createErrorObject(e); + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("dictationPage.message.backupFailedError"), + }) + ); + + return thunkApi.rejectWithValue({ error }); + } +}); diff --git a/dictation_client/src/features/dictation/selectors.ts b/dictation_client/src/features/dictation/selectors.ts index bd61ca4..12cda85 100644 --- a/dictation_client/src/features/dictation/selectors.ts +++ b/dictation_client/src/features/dictation/selectors.ts @@ -68,3 +68,6 @@ export const selectBackupAllChecked = (state: RootState) => { export const selectIsBackupListLoading = (state: RootState) => state.dictation.apps.isBackupListLoading; + +export const selectIsDownloading = (state: RootState) => + state.dictation.apps.isDownloading; diff --git a/dictation_client/src/features/dictation/state.ts b/dictation_client/src/features/dictation/state.ts index 90a20c9..f8243ef 100644 --- a/dictation_client/src/features/dictation/state.ts +++ b/dictation_client/src/features/dictation/state.ts @@ -32,6 +32,7 @@ export interface Apps { }; isLoading: boolean; isBackupListLoading: boolean; + isDownloading: boolean; } export interface Backup { diff --git a/dictation_client/src/pages/DictationPage/backupPopup.tsx b/dictation_client/src/pages/DictationPage/backupPopup.tsx index c622aae..2e5e3eb 100644 --- a/dictation_client/src/pages/DictationPage/backupPopup.tsx +++ b/dictation_client/src/pages/DictationPage/backupPopup.tsx @@ -3,6 +3,7 @@ import styles from "styles/app.module.scss"; import { useDispatch, useSelector } from "react-redux"; import { BACKUP_POPUP_LIST_SIZE, + backupTasksAsync, changeBackupTaskAllCheched, changeBackupTaskChecked, listBackupPopupTasksAsync, @@ -11,6 +12,7 @@ import { selectBackupTotal, selectCurrentBackupPage, selectIsBackupListLoading, + selectIsDownloading, selectTotalBackupPage, } from "features/dictation"; import { AppDispatch } from "app/store"; @@ -30,6 +32,7 @@ export const BackupPopup: React.FC = (props) => { const [t] = useTranslation(); const isBackupListLoading = useSelector(selectIsBackupListLoading); + const isDownloading = useSelector(selectIsDownloading); const backupTasks = useSelector(selectBackupTasks); @@ -41,8 +44,11 @@ export const BackupPopup: React.FC = (props) => { // ポップアップを閉じる処理 const closePopup = useCallback(() => { + if (isDownloading) { + return; + } onClose(false); - }, [onClose]); + }, [onClose, isDownloading]); useEffect(() => { if (isOpen) { @@ -50,6 +56,33 @@ export const BackupPopup: React.FC = (props) => { } }, [dispatch, isOpen]); + // ブラウザのウィンドウが閉じられようとしている場合に発火するイベントハンドラ + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + // ファイルダウンロード中に閉じられようとしている場合、ダイアログを表示させる + if (isDownloading) { + e.preventDefault(); + // ChromeではreturnValueが必要 + e.returnValue = ""; + } + }; + + // コンポーネントがマウントされた時にイベントハンドラを登録する + useEffect(() => { + window.addEventListener("beforeunload", handleBeforeUnload); + // コンポーネントがアンマウントされるときにイベントハンドラを解除する + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + }); + + // バックアップ処理 + const onBackup = useCallback(async () => { + await dispatch(backupTasksAsync({ tasks: backupTasks })); + + // バックアップ処理の終了後、バックアップ対象タスク一覧を再取得する + dispatch(listBackupPopupTasksAsync({ offset: 0 })); + }, [dispatch, backupTasks]); + // ページネーションの制御 const getFirstPage = useCallback(() => { dispatch(listBackupPopupTasksAsync({ offset: 0 })); @@ -75,7 +108,11 @@ export const BackupPopup: React.FC = (props) => {

{t(getTranslationID("dictationPage.label.fileBackup"))} -

@@ -97,6 +134,7 @@ export const BackupPopup: React.FC = (props) => { }) ) } + disabled={isDownloading} /> @@ -133,6 +171,7 @@ export const BackupPopup: React.FC = (props) => { }) ); }} + disabled={isDownloading} /> {task.jobNumber} @@ -208,13 +247,25 @@ export const BackupPopup: React.FC = (props) => {
!x.checked) + ? "" + : styles.isActive + }`} + onClick={onBackup} /> + {isDownloading && ( + Loading + )}
diff --git a/dictation_client/src/pages/DictationPage/index.tsx b/dictation_client/src/pages/DictationPage/index.tsx index ce0d896..320993a 100644 --- a/dictation_client/src/pages/DictationPage/index.tsx +++ b/dictation_client/src/pages/DictationPage/index.tsx @@ -32,7 +32,6 @@ import { playbackAsync, cancelAsync, } from "features/dictation"; -import { selectUserName } from "features/login/index"; import { getTranslationID } from "translation"; import { Task } from "api/api"; import { isAdminUser, isAuthorUser, isTypistUser } from "features/auth"; @@ -75,9 +74,6 @@ const DictationPage: React.FC = (): JSX.Element => { [dispatch, setIsChangeTranscriptionistPopupOpen] ); - // ログイン中のユーザ名 - const myName = useSelector(selectUserName); - // 各カラムの表示/非表示 const displayColumn = useSelector(selectDisplayInfo); diff --git a/dictation_client/src/translation/de.json b/dictation_client/src/translation/de.json index d9594e9..50957fc 100644 --- a/dictation_client/src/translation/de.json +++ b/dictation_client/src/translation/de.json @@ -532,4 +532,4 @@ "button": "(de)Continue" } } -} \ No newline at end of file +} diff --git a/dictation_client/src/translation/en.json b/dictation_client/src/translation/en.json index 7d9fe63..0bde925 100644 --- a/dictation_client/src/translation/en.json +++ b/dictation_client/src/translation/en.json @@ -532,4 +532,4 @@ "button": "Continue" } } -} \ No newline at end of file +} diff --git a/dictation_client/src/translation/es.json b/dictation_client/src/translation/es.json index 0cf0ebe..481c79d 100644 --- a/dictation_client/src/translation/es.json +++ b/dictation_client/src/translation/es.json @@ -532,4 +532,4 @@ "button": "(es)Continue" } } -} \ No newline at end of file +} diff --git a/dictation_client/src/translation/fr.json b/dictation_client/src/translation/fr.json index 623936d..6ffc1bd 100644 --- a/dictation_client/src/translation/fr.json +++ b/dictation_client/src/translation/fr.json @@ -532,4 +532,4 @@ "button": "(fr)Continue" } } -} \ No newline at end of file +} diff --git a/dictation_server/src/features/files/files.controller.ts b/dictation_server/src/features/files/files.controller.ts index 20e05f0..05e941f 100644 --- a/dictation_server/src/features/files/files.controller.ts +++ b/dictation_server/src/features/files/files.controller.ts @@ -220,7 +220,9 @@ export class FilesController { @ApiBearerAuth() @UseGuards(AuthGuard) @UseGuards( - RoleGuard.requireds({ roles: [USER_ROLES.AUTHOR, USER_ROLES.TYPIST] }), + RoleGuard.requireds({ + roles: [ADMIN_ROLES.ADMIN, USER_ROLES.AUTHOR, USER_ROLES.TYPIST], + }), ) async downloadLocation( @Req() req: Request, diff --git a/dictation_server/src/features/files/files.service.ts b/dictation_server/src/features/files/files.service.ts index 0699419..f9f4550 100644 --- a/dictation_server/src/features/files/files.service.ts +++ b/dictation_server/src/features/files/files.service.ts @@ -376,6 +376,7 @@ export class FilesService { let country: string; let isTypist: boolean; let authorId: string | undefined; + let isAdmin: boolean; try { const user = await this.usersRepository.findUserByExternalId(externalId); if (!user.account) { @@ -399,6 +400,9 @@ export class FilesService { country = user.account.country; isTypist = user.role === USER_ROLES.TYPIST; authorId = user.author_id ?? undefined; + isAdmin = + user.account.primary_admin_user_id === user.id || + user.account.secondary_admin_user_id === user.id; } catch (e) { this.logger.error(`[${context.getTrackingId()}] error=${e}`); this.logger.log( @@ -426,9 +430,11 @@ export class FilesService { } try { - const status = isTypist - ? [TASK_STATUS.IN_PROGRESS, TASK_STATUS.PENDING] - : Object.values(TASK_STATUS); + // ユーザーが管理者でないTypistの場合はステータスがIN_PROGRESSかPENDINGのタスクのみ取得できる + const status = + !isAdmin && isTypist + ? [TASK_STATUS.IN_PROGRESS, TASK_STATUS.PENDING] + : Object.values(TASK_STATUS); const task = await this.tasksRepository.getTaskAndAudioFile( audioFileId, @@ -445,18 +451,21 @@ export class FilesService { ); } - // ユーザーがAuthorの場合、自身が追加したタスクでない場合はエラー - if (!isTypist && file.author_id !== authorId) { - throw new AuthorUserNotMatchError( - `task author is not match. audio_file_id:${audioFileId}, task.file.author_id:${file.author_id}, authorId:${authorId}`, - ); - } + // ユーザーがAdminの場合は特にチェックしない + if (!isAdmin) { + // ユーザーがAuthorの場合、自身が追加したタスクでない場合はエラー + if (!isTypist && file.author_id !== authorId) { + throw new AuthorUserNotMatchError( + `task author is not match. audio_file_id:${audioFileId}, task.file.author_id:${file.author_id}, authorId:${authorId}`, + ); + } - // ユーザーがTypistの場合、自身が担当したタスクでない場合はエラー - if (isTypist && task.typist_user_id !== userId) { - throw new AuthorUserNotMatchError( - `task typist is not match. audio_file_id:${audioFileId}, task.typist_user_id:${task.typist_user_id}, userId:${userId}`, - ); + // ユーザーがTypistの場合、自身が担当したタスクでない場合はエラー + if (isTypist && task.typist_user_id !== userId) { + throw new AuthorUserNotMatchError( + `task typist is not match. audio_file_id:${audioFileId}, task.typist_user_id:${task.typist_user_id}, userId:${userId}`, + ); + } } const filePath = `${file.file_name}`; diff --git a/dictation_server/src/gateways/blobstorage/blobstorage.service.ts b/dictation_server/src/gateways/blobstorage/blobstorage.service.ts index 78767f5..2381148 100644 --- a/dictation_server/src/gateways/blobstorage/blobstorage.service.ts +++ b/dictation_server/src/gateways/blobstorage/blobstorage.service.ts @@ -368,6 +368,10 @@ export class BlobstorageService { throw e; } + // SASの開始時刻より前に実行するとエラーになるため、開始時刻を15分前に設定 + const startDate = new Date(); + startDate.setTime(startDate.getTime() - 15 * 60 * 1000); + //SASの有効期限を設定 const expiryDate = new Date(); expiryDate.setHours(expiryDate.getHours() + this.sasTokenExpireHour); @@ -382,7 +386,7 @@ export class BlobstorageService { containerName: containerClient.containerName, blobName: blobClient.name, permissions: permissions, - startsOn: new Date(), + startsOn: startDate, expiresOn: expiryDate, }, sharedKeyCredential,