makabe.t 8b04adf095 Merged PR 587: 画面実装(ダウンロード処理)
## 概要
[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)

## 動作確認状況
- ローカルで確認
2023-11-28 06:21:23 +00:00

575 lines
15 KiB
TypeScript

import { createAsyncThunk } from "@reduxjs/toolkit";
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,
UsersApi,
AccountsApi,
GetTypistsResponse,
GetTypistGroupsResponse,
Assignee,
FilesApi,
} from "../../api/api";
import { Configuration } from "../../api/configuration";
import { ErrorObject, createErrorObject } from "../../common/errors";
import {
BACKUP_POPUP_LIST_SIZE,
BACKUP_POPUP_LIST_STATUS,
DIRECTION,
DirectionType,
SORTABLE_COLUMN,
SortableColumnType,
} from "./constants";
import { BackupTask } from "./types";
export const listTasksAsync = createAsyncThunk<
TasksResponse,
{
// パラメータ
limit: number;
offset: number;
filter?: string;
direction: DirectionType;
paramName: SortableColumnType;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/listTasksAsync", async (args, thunkApi) => {
const { limit, offset, filter, direction, paramName } = 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);
try {
const res = await tasksApi.getTasks(
limit,
offset,
filter,
direction,
paramName,
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
return res.data;
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const getSortColumnAsync = createAsyncThunk<
{
direction: DirectionType;
paramName: SortableColumnType;
},
void,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/getSortColumnAsync", async (args, thunkApi) => {
// 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 usersApi = new UsersApi(config);
try {
const sort = await usersApi.getSortCriteria({
headers: { authorization: `Bearer ${accessToken}` },
});
const { direction, paramName } = sort.data;
if (
Object.values<string>(DIRECTION).includes(direction) &&
Object.values<string>(SORTABLE_COLUMN).includes(paramName)
) {
return {
direction: direction as DirectionType,
paramName: paramName as SortableColumnType,
};
}
throw new Error(
`invalid param. direction=${direction}, paramName=${paramName}`
);
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const listTypistsAsync = createAsyncThunk<
GetTypistsResponse,
void,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/listTypistsAsync", async (args, thunkApi) => {
// 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 accountsApi = new AccountsApi(config);
try {
const typists = await accountsApi.getTypists({
headers: { authorization: `Bearer ${accessToken}` },
});
return typists.data;
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const listTypistGroupsAsync = createAsyncThunk<
GetTypistGroupsResponse,
void,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/listTypistGroupsAsync", async (args, thunkApi) => {
// 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 accountsApi = new AccountsApi(config);
try {
const typistGroup = await accountsApi.getTypistGroups({
headers: { authorization: `Bearer ${accessToken}` },
});
return typistGroup.data;
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const updateAssigneeAsync = createAsyncThunk<
{
/** empty */
},
{
audioFileId: number;
assignees: Assignee[];
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/updateAssigneeAsync", async (args, thunkApi) => {
const { audioFileId, assignees } = 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);
try {
await tasksApi.changeCheckoutPermission(
audioFileId,
{ assignees },
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
// ステータスがUploaded以外、タスクが存在しない場合
if (error.code === "E010601") {
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("dictationPage.message.taskNotEditable"),
})
);
return thunkApi.rejectWithValue({ error });
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const playbackAsync = createAsyncThunk<
{
/** empty */
},
{
direction: DirectionType;
paramName: SortableColumnType;
audioFileId: number;
isTypist: boolean;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/playbackAsync", async (args, thunkApi) => {
const { audioFileId, direction, paramName, isTypist } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const tasksApi = new TasksApi(config);
const usersApi = new UsersApi(config);
try {
// ユーザーがタイピストである場合に、ソート条件を保存する
if (isTypist) {
await usersApi.updateSortCriteria(
{ direction, paramName },
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
}
await tasksApi.checkout(audioFileId, {
headers: { authorization: `Bearer ${accessToken}` },
});
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
// ステータスが[Uploaded,Inprogress,Pending]以外、またはタスクが存在しない場合
if (error.code === "E010601") {
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID(
"dictationPage.message.taskToPlaybackNoExists"
),
})
);
return thunkApi.rejectWithValue({ error });
}
// タスクをチェックアウトする権限がない
if (error.code === "E010602") {
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID(
"dictationPage.message.noPlaybackAuthorization"
),
})
);
return thunkApi.rejectWithValue({ error });
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const cancelAsync = createAsyncThunk<
{
/** empty */
},
{
direction: DirectionType;
paramName: SortableColumnType;
audioFileId: number;
isTypist: boolean;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/cancelAsync", async (args, thunkApi) => {
const { audioFileId, direction, paramName, isTypist } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const config = new Configuration(configuration);
const tasksApi = new TasksApi(config);
const usersApi = new UsersApi(config);
try {
// ユーザーがタイピストである場合に、ソート条件を保存する
if (isTypist) {
await usersApi.updateSortCriteria(
{ direction, paramName },
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
}
await tasksApi.cancel(audioFileId, {
headers: { authorization: `Bearer ${accessToken}` },
});
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
// ステータスが[Inprogress,Pending]以外、またはタスクが存在しない場合、またはtypistで自分のタスクでない場合
if (error.code === "E010601" || error.code === "E010603") {
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("dictationPage.message.cancelFailedError"),
})
);
return thunkApi.rejectWithValue({ error });
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const listBackupPopupTasksAsync = createAsyncThunk<
TasksResponse,
{
// パラメータ
offset: number;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/listBackupPopupTasksAsync", async (args, thunkApi) => {
const { offset } = 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);
try {
const res = await tasksApi.getTasks(
BACKUP_POPUP_LIST_SIZE,
offset,
BACKUP_POPUP_LIST_STATUS.join(","), // ステータスはFinished,Backupのみ
DIRECTION.DESC,
SORTABLE_COLUMN.Status,
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
return res.data;
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const 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 });
}
});