Merge branch 'develop' into main

This commit is contained in:
makabe 2023-11-09 16:56:18 +09:00
commit 4dd3e646f4
55 changed files with 8921 additions and 193 deletions

View File

@ -1,5 +1,6 @@
// トークンの型やtypeGuardの関数を配置するファイル
export interface Token {
delegateUserId?: string;
userId: string;
role: string;
tier: number;

View File

@ -33,4 +33,20 @@ export const TIERS = {
* 401
* @const {string[]}
*/
export const UNAUTHORIZED_TO_CONTINUE_ERROR_CODES = ["E010209"];
export const UNAUTHORIZED_TO_CONTINUE_ERROR_CODES = [
"E010209",
"E010503",
"E010501",
];
/**
*
* @const {number}
*/
export const TOKEN_UPDATE_TIME = 5 * 60;
/**
*
* @const {number}
*/
export const TOKEN_UPDATE_INTERVAL_MS = 3 * 60 * 1000;

View File

@ -2,33 +2,56 @@ import React, { useCallback } from "react";
import { AppDispatch } from "app/store";
import { decodeToken } from "common/decodeToken";
import { useInterval } from "common/useInterval";
import { updateTokenAsync, loadAccessToken } from "features/auth";
import {
updateTokenAsync,
loadAccessToken,
updateDelegationTokenAsync,
} from "features/auth";
import { DateTime } from "luxon";
import { useDispatch } from "react-redux";
// アクセストークンを更新する基準の秒数
const TOKEN_UPDATE_TIME = 5 * 60;
// アクセストークンの更新チェックを行う間隔(ミリ秒)
const TOKEN_UPDATE_INTERVAL_MS = 3 * 60 * 1000;
import { useDispatch, useSelector } from "react-redux";
import { selectDelegationAccessToken } from "features/auth/selectors";
import { useNavigate } from "react-router-dom";
import { cleanupDelegateAccount } from "features/partner";
import { TOKEN_UPDATE_INTERVAL_MS, TOKEN_UPDATE_TIME } from "./constants";
export const UpdateTokenTimer = () => {
const dispatch: AppDispatch = useDispatch();
const navigate = useNavigate();
const delegattionToken = useSelector(selectDelegationAccessToken);
// 期限が分以内であれば更新APIを呼ぶ
const updateToken = useCallback(async () => {
// localStorageからトークンを取得
const jwt = loadAccessToken();
// 現在時刻を取得
const now = DateTime.local().toSeconds();
// selectorに以下の判定処理を移したかったが、初期表示時の値でしか判定できないのでComponent内に置く
if (jwt) {
const token = decodeToken(jwt);
if (token) {
const { exp } = token;
const now = DateTime.local().toSeconds();
if (exp - now <= TOKEN_UPDATE_TIME) {
await dispatch(updateTokenAsync());
}
}
}
}, [dispatch]);
// 代行操作トークン更新処理
if (delegattionToken) {
const token = decodeToken(delegattionToken);
if (token) {
const { exp } = token;
if (exp - now <= TOKEN_UPDATE_TIME) {
const { meta } = await dispatch(updateDelegationTokenAsync());
if (meta.requestStatus === "rejected") {
dispatch(cleanupDelegateAccount());
navigate("/partners");
}
}
}
}
}, [dispatch, delegattionToken, navigate]);
useInterval(updateToken, TOKEN_UPDATE_INTERVAL_MS);

View File

@ -21,10 +21,16 @@ export const DelegationBar: React.FC = (): JSX.Element => {
const navigate = useNavigate();
const onClickExit = useCallback(() => {
if (
/* eslint-disable-next-line no-alert */
!window.confirm(t(getTranslationID("common.message.dialogConfirm")))
) {
return;
}
dispatch(clearDelegationToken());
dispatch(cleanupDelegateAccount());
navigate("/partners");
}, [dispatch, navigate]);
}, [dispatch, navigate, t]);
return (
<div className={styles.manageInfo}>

View File

@ -70,3 +70,12 @@ export const TIER1_TO_TIER4_ONLY_TABS = [HEADER_MENUS_PARTNER];
* admin,standardでなく15
*/
export const INVALID_ACCOUNT_TABS = [];
/**
*
*/
export const DELEGATE_TABS = [
HEADER_MENUS_LICENSE,
HEADER_MENUS_USER,
HEADER_MENUS_WORKFLOW,
];

View File

@ -3,7 +3,7 @@ import styles from "styles/app.module.scss";
import { useDispatch, useSelector } from "react-redux";
import { AppDispatch } from "app/store";
import { useMsal } from "@azure/msal-react";
import { clearToken } from "features/auth";
import { clearToken, loadAccessToken } from "features/auth";
import { useTranslation } from "react-i18next";
import {
getUserInfoAsync,
@ -12,6 +12,7 @@ import {
clearUserInfo,
} from "features/login";
import { useNavigate } from "react-router-dom";
import { selectDelegationAccessToken } from "features/auth/selectors";
import { getFilteredMenus } from "./utils";
import logo from "../../assets/images/OMS_logo_black.svg";
import ac from "../../assets/images/account_circle.svg";
@ -27,31 +28,41 @@ interface HeaderProps {
// ログイン後のヘッダー
const LoginedHeader: React.FC<HeaderProps> = (props: HeaderProps) => {
const { activePath } = props;
const filterMenus = getFilteredMenus();
const dispatch: AppDispatch = useDispatch();
const { instance } = useMsal();
const { t } = useTranslation();
const navigate = useNavigate();
// メニューの代行操作表示制御
const isDelegation = useSelector(selectDelegationAccessToken) !== null;
const accessToken = loadAccessToken();
const filterMenus = getFilteredMenus(isDelegation);
// Headerのユーザー情報を取得する
const isUserNameEmpty = useSelector(selectIsUserNameEmpty);
const userName = useSelector(selectUserName);
useEffect(() => {
if (isUserNameEmpty) {
// ユーザー情報が空の場合、ユーザー情報を取得する(アクセストークンがある場合のみ)
// ログアウト時にユーザー情報とアクセストークンをクリアするため、失敗するそのタイミングでユーザー情報を取得しないようにする
if (accessToken !== null && isUserNameEmpty) {
dispatch(getUserInfoAsync());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, isUserNameEmpty]);
// ログアウト
const onSignoutButton = useCallback(
async () => {
// ダイアログ確認
// eslint-disable-next-line no-alert
if (window.confirm(t(getTranslationID("common.message.displayDialog")))) {
instance.logoutRedirect({ postLogoutRedirectUri: "/" });
if (
!isDelegation &&
// eslint-disable-next-line no-alert
window.confirm(t(getTranslationID("common.message.displayDialog")))
) {
dispatch(clearToken());
dispatch(clearUserInfo());
instance.logoutRedirect({ postLogoutRedirectUri: "/" });
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -87,7 +98,11 @@ const LoginedHeader: React.FC<HeaderProps> = (props: HeaderProps) => {
<img src={ac} alt="" className={styles.accountIcon} />
<span>{userName}</span>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<span className={styles.accountSignout} onClick={onSignoutButton}>
<span
className={styles.accountSignout}
onClick={onSignoutButton}
style={{ pointerEvents: isDelegation ? "none" : "auto" }}
>
<img src={logout} alt="" className={styles.accountIcon} />
{t(getTranslationID("common.label.signOutButton"))}
</span>

View File

@ -1,6 +1,7 @@
import React from "react";
import styles from "styles/app.module.scss";
import { useTranslation } from "react-i18next";
import logo from "../../assets/images/OMS_logo_black.svg";
import { HEADER_NAME } from "./constants";
@ -12,12 +13,13 @@ const NotLoginHeader: React.FC<NotLoginHeaderProps> = (
props: NotLoginHeaderProps
) => {
const { isMobile } = props;
const { t } = useTranslation();
return (
<header className={`${styles.header} ${isMobile && styles.home}`}>
<div className={`${styles.headerLogo}`}>
<img src={logo} alt="OM System" />
</div>
<p className={`${styles.headerSub}`}>{HEADER_NAME}</p>
<p className={`${styles.headerSub}`}>{t(HEADER_NAME)}</p>
</header>
);
};

View File

@ -5,6 +5,7 @@ import {
HEADER_MENUS,
TIER1_TO_TIER4_ONLY_TABS,
INVALID_ACCOUNT_TABS,
DELEGATE_TABS,
} from "./constants";
import { TIERS } from "../auth/constants";
@ -29,7 +30,7 @@ export const isLoginPaths = (d: string): d is LoginedPaths => {
}
};
// 権限、階層ごとに表示するヘッダーをわける
export const getFilteredMenus = () => {
export const getFilteredMenus = (isDelegation: boolean) => {
const isAdmin = isAdminUser();
const isStandard = isStandardUser();
const isTier5 = isApproveTier([TIERS.TIER5]);
@ -40,6 +41,10 @@ export const getFilteredMenus = () => {
TIERS.TIER4,
]);
if (isDelegation) {
return HEADER_MENUS.filter((item) => DELEGATE_TABS.includes(item.key));
}
if (tier1ToTier4) {
if (isAdmin) {
return HEADER_MENUS;

View File

@ -9,7 +9,11 @@ import {
removeRefreshToken,
} from "./utils";
import type { AuthState } from "./state";
import { getDelegationTokenAsync, updateTokenAsync } from "./operations";
import {
getDelegationTokenAsync,
updateDelegationTokenAsync,
updateTokenAsync,
} from "./operations";
const initialState: AuthState = {
configuration: initialConfig(),
@ -61,6 +65,14 @@ export const authSlice = createSlice({
state.delegationAccessToken = accessToken;
state.delegationRefreshToken = refreshToken;
});
builder.addCase(updateDelegationTokenAsync.fulfilled, (state, action) => {
const { accessToken } = action.payload;
state.delegationAccessToken = accessToken;
});
builder.addCase(updateDelegationTokenAsync.rejected, (state) => {
state.delegationAccessToken = null;
state.delegationRefreshToken = null;
});
},
});

View File

@ -6,10 +6,11 @@ import { ErrorObject, createErrorObject } from "common/errors";
import {
AccessTokenResponse,
AuthApi,
DelegationAccessTokenResponse,
DelegationTokenResponse,
} from "../../api/api";
import { Configuration } from "../../api/configuration";
import { getAccessToken, loadRefreshToken } from "./utils";
import { getAccessToken, getRefreshToken, loadRefreshToken } from "./utils";
export const updateTokenAsync = createAsyncThunk<
AccessTokenResponse,
@ -40,6 +41,50 @@ export const updateTokenAsync = createAsyncThunk<
}
});
export const updateDelegationTokenAsync = createAsyncThunk<
DelegationAccessTokenResponse,
void,
{
// rejectした時の返却値の型
rejectValue: {
/* Empty Object */
};
}
>("auth/updateDelegationTokenAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const refreshToken = getRefreshToken(state.auth);
const config = new Configuration(configuration);
const authApi = new AuthApi(config);
try {
const { data } = await authApi.delegationAccessToken({
headers: { authorization: `Bearer ${refreshToken}` },
});
return data;
} catch (e) {
const error = createErrorObject(e);
let errorMessage = getTranslationID("common.message.internalServerError");
if (error.code === "E010503") {
errorMessage = getTranslationID(
"partnerPage.message.delegateCancelError"
);
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message: errorMessage,
})
);
return thunkApi.rejectWithValue({});
}
});
// パートナーのアカウントを代理操作するトークンを取得する
export const getDelegationTokenAsync = createAsyncThunk<
// 正常時の戻り値の型

View File

@ -1,6 +1,6 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import type { RootState } from "app/store";
import { setToken, getAccessToken } from "features/auth";
import { getAccessToken, setToken } from "features/auth";
import {
AuthApi,
UsersApi,
@ -66,8 +66,7 @@ export const getUserInfoAsync = createAsyncThunk<
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const accessToken = getAccessToken(state.auth);
const { configuration, accessToken } = state.auth;
const config = new Configuration(configuration);
const usersApi = new UsersApi(config);

View File

@ -92,6 +92,12 @@ const PartnerPage: React.FC = (): JSX.Element => {
// 代理操作開始処理
const startDealerManagement = useCallback(
async (delegatedAccountId: number, delegatedCompanyName: string) => {
if (
/* eslint-disable-next-line no-alert */
!window.confirm(t(getTranslationID("common.message.dialogConfirm")))
) {
return;
}
dispatch(
changeDelegateAccount({ delegatedAccountId, delegatedCompanyName })
);
@ -100,7 +106,7 @@ const PartnerPage: React.FC = (): JSX.Element => {
navigate("/user");
}
},
[dispatch, navigate]
[dispatch, navigate, t]
);
// HTML

View File

@ -87,8 +87,9 @@ const UserListPage: React.FC = (): JSX.Element => {
const users = useSelector(selectUserViews);
const isLoading = useSelector(selectIsLoading);
// ユーザーが第5階層であるかどうかを判定する
const isTier5 = isApproveTier([TIERS.TIER5]);
// ユーザーが第5階層であるかどうかを判定する代行操作中は第5階層として扱う
const isTier5 =
isApproveTier([TIERS.TIER5]) || delegationAccessToken !== null;
return (
<>

View File

@ -14,6 +14,12 @@ services:
- "8082"
environment:
- CHOKIDAR_USEPOLLING=true
networks:
- external
networks:
external:
name: omds_network
external: true
# Data Volume として永続化する
volumes:

5
dictation_function/.env Normal file
View File

@ -0,0 +1,5 @@
DB_HOST=omds-mysql
DB_PORT=3306
DB_NAME=omds
DB_USERNAME=omdsdbuser
DB_PASSWORD=omdsdbpass

View File

@ -29,7 +29,6 @@ dist
.python_packages/
# Python Environments
.env
.venv
env/
venv/
@ -45,4 +44,8 @@ __pycache__/
# Azurite artifacts
__blobstorage__
__queuestorage__
__azurite_db*__.json
__azurite_db*__.json
# credentials
credentials
.env.local

File diff suppressed because it is too large Load Diff

View File

@ -9,15 +9,41 @@
"clean": "rimraf dist",
"prestart": "npm run clean && npm run build",
"start": "func start",
"test": "echo \"No tests yet...\""
"test": "jest"
},
"dependencies": {
"@azure/functions": "^4.0.0"
"@azure/functions": "^4.0.0",
"@sendgrid/mail": "^7.7.0",
"dotenv": "^16.0.3",
"mysql2": "^2.3.3",
"typeorm": "^0.3.10"
},
"devDependencies": {
"azure-functions-core-tools": "^4.x",
"@types/jest": "^27.5.0",
"@types/node": "18.x",
"typescript": "^4.0.0",
"rimraf": "^5.0.0"
"azure-functions-core-tools": "^4.x",
"jest": "^28.0.3",
"rimraf": "^5.0.0",
"sqlite3": "^5.1.6",
"supertest": "^6.1.3",
"ts-jest": "^28.0.1",
"typescript": "^4.0.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@ -0,0 +1,61 @@
import { bigintTransformer } from '.';
describe('bigintTransformer', () => {
describe('to', () => {
it('number型を整数を表す文字列に変換できる', () => {
expect(bigintTransformer.to(0)).toBe('0');
expect(bigintTransformer.to(1)).toBe('1');
expect(bigintTransformer.to(1234567890)).toBe('1234567890');
expect(bigintTransformer.to(9007199254740991)).toBe('9007199254740991');
expect(bigintTransformer.to(-1)).toBe('-1');
});
it('少数点以下がある場合はエラーとなる', () => {
expect(() => bigintTransformer.to(1.1)).toThrowError(
'1.1 is not integer.',
);
});
it('Number.MAX_SAFE_INTEGERを超える値を変換しようとするとエラーになる', () => {
expect(() => bigintTransformer.to(9007199254740992)).toThrowError(
'value is greater than 9007199254740991.',
);
expect(() => bigintTransformer.to(9223372036854775807)).toThrowError(
'value is greater than 9007199254740991.',
);
});
});
describe('from', () => {
it('bigint型の文字列をnumber型に変換できる', () => {
expect(bigintTransformer.from('0')).toBe(0);
expect(bigintTransformer.from('1')).toBe(1);
expect(bigintTransformer.from('1234567890')).toBe(1234567890);
expect(bigintTransformer.from('-1')).toBe(-1);
});
it('Number.MAX_SAFE_INTEGERを超える値を変換しようとするとエラーになる', () => {
expect(() => bigintTransformer.from('9007199254740992')).toThrowError(
'9007199254740992 is greater than 9007199254740991.',
);
expect(() => bigintTransformer.from('9223372036854775807')).toThrowError(
'9223372036854775807 is greater than 9007199254740991.',
);
});
it('number型の場合はそのまま返す', () => {
expect(bigintTransformer.from(0)).toBe(0);
expect(bigintTransformer.from(1)).toBe(1);
expect(bigintTransformer.from(1234567890)).toBe(1234567890);
expect(bigintTransformer.from(-1)).toBe(-1);
});
it('nullの場合はそのまま返す', () => {
expect(bigintTransformer.from(null)).toBe(null);
});
it('number型に変換できない場合はエラーとなる', () => {
expect(() => bigintTransformer.from('a')).toThrowError('a is not int.');
expect(() => bigintTransformer.from('')).toThrowError(' is not int.');
expect(() => bigintTransformer.from(undefined)).toThrowError(
'undefined is not string.',
);
expect(() => bigintTransformer.from({})).toThrowError(
'[object Object] is not string.',
);
});
});
});

View File

@ -0,0 +1,57 @@
import { ValueTransformer } from 'typeorm';
// DBのbigint型をnumber型に変換するためのtransformer
// DBのBigInt型をそのまま扱うと、JSのNumber型の最大値を超えると誤差が発生するため、本来はNumber型に変換すべきではないが、
// 影響範囲を最小限に抑えるため、Number型に変換する。使用するのはAutoIncrementされるIDのみの想定のため、
// Number.MAX_SAFE_INTEGERより大きい値は現実的には発生しない想定で変換する。
export const bigintTransformer: ValueTransformer = {
from: (value: any): number | null => {
// valueがnullであればそのまま返す
if (value === null) {
return value;
}
// valueがnumber型かどうかを判定
// 利用DBによってはbigint型であってもnumber型で返ってくる場合があるため、number型の場合はそのまま返す(sqliteの場合)
if (typeof value === 'number') {
return value;
}
// valueが文字列かどうかを判定
if (typeof value !== 'string') {
throw new Error(`${value} is not string.`);
}
// 数値に変換可能な文字列かどうかを判定
if (Number.isNaN(parseInt(value))) {
throw new Error(`${value} is not int.`);
}
// 文字列ならbigintに変換
// valueが整数でない場合は値が丸められてしまうが、TypeORMのEntityの定義上、整数を表す文字列以外はありえないため、少数点は考慮しない
const bigIntValue = BigInt(value);
// bigIntValueがNumber.MAX_SAFE_INTEGERより大きいかどうかを判定
if (bigIntValue > Number.MAX_SAFE_INTEGER) {
throw new Error(`${value} is greater than ${Number.MAX_SAFE_INTEGER}.`);
}
// number型で表現できる整数であればnumber型に変換して返す
return Number(bigIntValue);
},
to: (value: any): string | null | undefined => {
// valueがnullまたはundefinedであればそのまま返す
if (value === null || value === undefined) {
return value;
}
// valueがnumber型かどうかを判定
if (typeof value !== 'number') {
throw new Error(`${value} is not number.`);
}
// valueがNumber.MAX_SAFE_INTEGERより大きいかどうかを判定
if (value > Number.MAX_SAFE_INTEGER) {
throw new Error(`value is greater than ${Number.MAX_SAFE_INTEGER}.`);
}
// valueが整数かどうかを判定
if (!Number.isInteger(value)) {
throw new Error(`${value} is not integer.`);
}
return value.toString();
},
};

View File

@ -0,0 +1,71 @@
import { v4 as uuidv4 } from "uuid";
import { DataSource } from "typeorm";
import { User } from "../../entity/user.entity";
import { Account } from "../../entity/account.entity";
import { ADMIN_ROLES, USER_ROLES } from "../../constants";
type InitialTestDBState = {
tier1Accounts: { account: Account; users: User[] }[];
tier2Accounts: { account: Account; users: User[] }[];
tier3Accounts: { account: Account; users: User[] }[];
tier4Accounts: { account: Account; users: User[] }[];
tier5Accounts: { account: Account; users: User[] }[];
};
// 上書きされたら困る項目を除外したAccount型
type OverrideAccount = Omit<
Account,
"id" | "primary_admin_user_id" | "secondary_admin_user_id" | "user"
>;
// 上書きされたら困る項目を除外したUser型
type OverrideUser = Omit<
User,
"id" | "account" | "license" | "userGroupMembers"
>;
type AccountDefault = { [K in keyof OverrideAccount]?: OverrideAccount[K] };
type UserDefault = { [K in keyof OverrideUser]?: OverrideUser[K] };
/**
* ユーティリティ: 指定したプロパティを上書きしたユーザーを作成する
* @param dataSource
* @param defaultUserValue User型と同等かつoptionalなプロパティを持つ上書き箇所指定用オブジェクト
* @returns
*/
export const makeTestUser = async (
datasource: DataSource,
defaultUserValue?: UserDefault
): Promise<User> => {
const d = defaultUserValue;
const { identifiers } = await datasource.getRepository(User).insert({
account_id: d?.account_id ?? -1,
external_id: d?.external_id ?? uuidv4(),
role: d?.role ?? `${ADMIN_ROLES.STANDARD} ${USER_ROLES.NONE}`,
author_id: d?.author_id,
accepted_eula_version: d?.accepted_eula_version ?? "1.0",
accepted_dpa_version: d?.accepted_dpa_version ?? "1.0",
email_verified: d?.email_verified ?? true,
auto_renew: d?.auto_renew ?? true,
license_alert: d?.license_alert ?? true,
notification: d?.notification ?? true,
encryption: d?.encryption ?? true,
encryption_password: d?.encryption_password,
prompt: d?.prompt ?? true,
created_by: d?.created_by ?? "test_runner",
created_at: d?.created_at ?? new Date(),
updated_by: d?.updated_by ?? "updater",
updated_at: d?.updated_at ?? new Date(),
});
const result = identifiers.pop() as User;
const user = await datasource.getRepository(User).findOne({
where: {
id: result.id,
},
});
if (!user) {
throw new Error("Unexpected null");
}
return user;
};

View File

@ -0,0 +1,263 @@
/**
*
* @const {number}
*/
export const TIERS = {
//OMDS東京
TIER1: 1,
//OMDS現地法人
TIER2: 2,
//代理店
TIER3: 3,
//販売店
TIER4: 4,
//エンドユーザー
TIER5: 5,
} as const;
/**
* East USに保存する国リスト
* @const {number}
*/
export const BLOB_STORAGE_REGION_US = ['CA', 'KY', 'US'];
/**
* Australia Eastに保存する国リスト
* @const {number}
*/
export const BLOB_STORAGE_REGION_AU = ['AU', 'NZ'];
/**
* North Europeに保存する国リスト
* @const {number}
*/
export const BLOB_STORAGE_REGION_EU = [
'AT',
'BE',
'BG',
'HR',
'CY',
'CZ',
'DK',
'EE',
'FI',
'FR',
'DE',
'GR',
'HU',
'IS',
'IE',
'IT',
'LV',
'LI',
'LT',
'LU',
'MT',
'NL',
'NO',
'PL',
'PT',
'RO',
'RS',
'SK',
'SI',
'ZA',
'ES',
'SE',
'CH',
'TR',
'GB',
];
/**
*
* @const {string[]}
*/
export const ADMIN_ROLES = {
ADMIN: 'admin',
STANDARD: 'standard',
} as const;
/**
*
* @const {string[]}
*/
export const USER_ROLES = {
NONE: 'none',
AUTHOR: 'author',
TYPIST: 'typist',
} as const;
/**
*
* @const {string[]}
*/
export const LICENSE_ISSUE_STATUS = {
ISSUE_REQUESTING: 'Issue Requesting',
ISSUED: 'Issued',
CANCELED: 'Order Canceled',
};
/**
*
* @const {string[]}
*/
export const LICENSE_TYPE = {
TRIAL: 'TRIAL',
NORMAL: 'NORMAL',
CARD: 'CARD',
} as const;
/**
*
* @const {string[]}
*/
export const LICENSE_ALLOCATED_STATUS = {
UNALLOCATED: 'Unallocated',
ALLOCATED: 'Allocated',
REUSABLE: 'Reusable',
DELETED: 'Deleted',
} as const;
/**
*
* @const {string[]}
*/
export const SWITCH_FROM_TYPE = {
NONE: 'NONE',
CARD: 'CARD',
TRIAL: 'TRIAL',
} as const;
/**
*
* @const {number}
*/
export const LICENSE_EXPIRATION_THRESHOLD_DAYS = 14;
/**
*
* @const {number}
*/
export const LICENSE_EXPIRATION_DAYS = 365;
/**
*
* @const {number}
*/
export const CARD_LICENSE_LENGTH = 20;
/**
*
* @const {string}
*/
export const OPTION_ITEM_NUM = 10;
/**
*
* @const {string[]}
*/
export const TASK_STATUS = {
UPLOADED: 'Uploaded',
PENDING: 'Pending',
IN_PROGRESS: 'InProgress',
FINISHED: 'Finished',
BACKUP: 'Backup',
} as const;
/**
*
*/
export const TASK_LIST_SORTABLE_ATTRIBUTES = [
'JOB_NUMBER',
'STATUS',
'ENCRYPTION',
'AUTHOR_ID',
'WORK_TYPE',
'FILE_NAME',
'FILE_LENGTH',
'FILE_SIZE',
'RECORDING_STARTED_DATE',
'RECORDING_FINISHED_DATE',
'UPLOAD_DATE',
'TRANSCRIPTION_STARTED_DATE',
'TRANSCRIPTION_FINISHED_DATE',
] as const;
/**
*
*/
export const SORT_DIRECTIONS = ['ASC', 'DESC'] as const;
/**
*
* NotificationHubの仕様上タグ式のOR条件で使えるタグは20個まで
* https://learn.microsoft.com/ja-jp/azure/notification-hubs/notification-hubs-tags-segment-push-message#tag-expressions
*/
export const TAG_MAX_COUNT = 20;
/**
*
*/
export const PNS = {
WNS: 'wns',
APNS: 'apns',
};
/**
*
*/
export const USER_LICENSE_STATUS = {
NORMAL: 'Normal',
NO_LICENSE: 'NoLicense',
ALERT: 'Alert',
RENEW: 'Renew',
};
/**
*
* @const {number}
*/
export const TRIAL_LICENSE_EXPIRATION_DAYS = 30;
/**
*
* @const {number}
*/
export const TRIAL_LICENSE_ISSUE_NUM = 100;
/**
* worktypeの最大登録数
* @const {number}
*/
export const WORKTYPE_MAX_COUNT = 20;
/**
* worktypeのDefault値の取りうる値
**/
export const OPTION_ITEM_VALUE_TYPE = {
DEFAULT: 'Default',
BLANK: 'Blank',
LAST_INPUT: 'LastInput',
} as const;
/**
* ADB2Cユーザのidentity.signInType
* @const {string[]}
*/
export const ADB2C_SIGN_IN_TYPE = {
EMAILADDRESS: 'emailAddress',
} as const;
/**
* MANUAL_RECOVERY_REQUIRED
* @const {string}
*/
export const MANUAL_RECOVERY_REQUIRED = '[MANUAL_RECOVERY_REQUIRED]';
/**
*
* @const {string[]}
*/
export const TERM_TYPE = {
EULA: 'EULA',
DPA: 'DPA',
} as const;

View File

@ -0,0 +1,70 @@
import { bigintTransformer } from "../common/entity";
import { User } from "./user.entity";
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from "typeorm";
@Entity({ name: "accounts" })
export class Account {
@PrimaryGeneratedColumn()
id: number;
@Column({ nullable: true, type: "bigint", transformer: bigintTransformer })
parent_account_id: number | null;
@Column()
tier: number;
@Column()
country: string;
@Column({ default: false })
delegation_permission: boolean;
@Column({ default: false })
locked: boolean;
@Column()
company_name: string;
@Column({ default: false })
verified: boolean;
@Column({ nullable: true, type: "bigint", transformer: bigintTransformer })
primary_admin_user_id: number | null;
@Column({ nullable: true, type: "bigint", transformer: bigintTransformer })
secondary_admin_user_id: number | null;
@Column({ nullable: true, type: "bigint", transformer: bigintTransformer })
active_worktype_id: number | null;
@Column({ nullable: true, type: "datetime" })
deleted_at: Date | null;
@Column({ nullable: true, type: "datetime" })
created_by: string | null;
@CreateDateColumn({
default: () => "datetime('now', 'localtime')",
type: "datetime",
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
created_at: Date;
@Column({ nullable: true, type: "datetime" })
updated_by: string | null;
@UpdateDateColumn({
default: () => "datetime('now', 'localtime')",
type: "datetime",
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at: Date;
@OneToMany(() => User, (user) => user.id)
user: User[] | null;
}

View File

@ -0,0 +1,73 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity({ name: "users" })
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
external_id: string;
@Column()
account_id: number;
@Column()
role: string;
@Column({ nullable: true, type: "varchar" })
author_id: string | null;
@Column({ nullable: true, type: "varchar" })
accepted_eula_version: string | null;
@Column({ nullable: true, type: "varchar" })
accepted_dpa_version: string | null;
@Column({ default: false })
email_verified: boolean;
@Column({ default: true })
auto_renew: boolean;
@Column({ default: true })
license_alert: boolean;
@Column({ default: true })
notification: boolean;
@Column({ default: false })
encryption: boolean;
@Column({ nullable: true, type: "varchar" })
encryption_password: string | null;
@Column({ default: false })
prompt: boolean;
@Column({ nullable: true, type: "datetime" })
deleted_at: Date | null;
@Column({ nullable: true, type: "datetime" })
created_by: string | null;
@CreateDateColumn({
default: () => "datetime('now', 'localtime')",
type: "datetime",
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
created_at: Date;
@Column({ nullable: true, type: "datetime" })
updated_by: string | null;
@UpdateDateColumn({
default: () => "datetime('now', 'localtime')",
type: "datetime",
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at: Date;
}

View File

@ -1,4 +1,8 @@
import { app, InvocationContext, Timer } from "@azure/functions";
import { DataSource } from "typeorm";
import { User } from "../entity/user.entity";
import { SendGridService } from "../sendgrid/sendgrid.service";
import * as dotenv from "dotenv";
// タイマートリガー処理のサンプルです
// TODO:開発が進んだら削除すること
@ -7,6 +11,57 @@ export async function timerTriggerExample(
context: InvocationContext
): Promise<void> {
context.log("Timer function processed request.");
dotenv.config({ path: ".env" });
const datasource = new DataSource({
type: "mysql",
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [User],
});
try {
await datasource.initialize();
const userRepository = datasource.getRepository(User); // Userエンティティに対応するリポジトリを取得
// ユーザーを検索
const users = await userRepository.find();
console.log(users);
} catch (e) {
console.error(e);
} finally {
await datasource.destroy();
}
}
// test実行確認用サンプル
// TODO:開発が進んだら削除すること
export async function testExample(datasource: DataSource): Promise<User[]> {
let users: User[];
const userRepository = datasource.getRepository(User); // Userエンティティに対応するリポジトリを取得
// ユーザーを検索
users = await userRepository.find();
return users;
}
// test実行確認用サンプル
// TODO:開発が進んだら削除すること
export async function testSendgridExample(): Promise<string> {
const sendgrid = new SendGridService();
// メールを送信
await sendgrid.sendMail(
"oura.a89@gmail.com",
process.env.MAIL_FROM,
"testMail",
"test!",
"html"
);
return "sucsess";
}
app.timer("timerTriggerExample", {

View File

@ -0,0 +1,60 @@
import sendgrid from "@sendgrid/mail";
export class SendGridService {
constructor() {
sendgrid.setApiKey(process.env.SENDGRID_API_KEY);
}
/**
*
* @param accountId ID
* @param userId ID
* @param email
* @returns
*/
async createMailContent(
accountId: number,
userId: number,
email: string
): Promise<{ subject: string; text: string; html: string }> {
return {
subject: "Verify your new account",
text: `The verification URL.`,
html: `<p>The verification URL.<p>`,
};
}
/**
*
* @param to
* @param from
* @param subject
* @param text
* @param html
* @returns mail
*/
async sendMail(
to: string,
from: string,
subject: string,
text: string,
html: string
): Promise<void> {
try {
const res = await sendgrid
.send({
from: {
email: from,
},
to: {
email: to,
},
subject: subject,
text: text,
html: html,
})
.then((v) => v[0]);
} catch (e) {
throw e;
}
}
}

View File

@ -0,0 +1,42 @@
import { DataSource } from "typeorm";
import {
testExample,
testSendgridExample,
} from "../functions/timerTriggerExample";
import { makeTestUser } from "../common/test/utility";
import * as dotenv from "dotenv";
describe("timerTriggerExample", () => {
dotenv.config({ path: ".env.local", override: true });
let source: DataSource | null = null;
beforeEach(async () => {
source = new DataSource({
type: "sqlite",
database: ":memory:",
logging: false,
entities: [__dirname + "/../../**/*.entity{.ts,.js}"],
synchronize: true, // trueにすると自動的にmigrationが行われるため注意
});
return source.initialize();
});
afterEach(async () => {
if (!source) return;
await source.destroy();
source = null;
});
it("sample test(DB)", async () => {
const count = 5;
for (let i = 0; i < count; i++) {
await makeTestUser(source);
}
const result = await testExample(source);
expect(result.length).toEqual(count);
});
it("sample test(sendgrid)", async () => {
await testSendgridExample();
});
});

View File

@ -5,6 +5,9 @@
"outDir": "dist",
"rootDir": ".",
"sourceMap": true,
"strict": false
"strict": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"esModuleInterop": true
}
}

View File

@ -1 +1 @@
export const ADB2C_PREFIX = "adb2c-external-id:"
export const ADB2C_PREFIX = 'adb2c-external-id:';

View File

@ -6,8 +6,8 @@ import { ADB2C_PREFIX } from './constants';
* @returns
*/
export const makeADB2CKey = (externalId: string): string => {
return `${ADB2C_PREFIX}${externalId}`;
}
return `${ADB2C_PREFIX}${externalId}`;
};
/**
* ADB2Cのユーザー格納用のキーから外部ユーザーIDを取得する
@ -15,5 +15,5 @@ export const makeADB2CKey = (externalId: string): string => {
* @returns ID
*/
export const restoreAdB2cID = (key: string): string => {
return key.replace(ADB2C_PREFIX, '');
}
return key.replace(ADB2C_PREFIX, '');
};

View File

@ -120,3 +120,26 @@ describe('RoleGuard(Tier)', () => {
expect(guards.checkTier(TIERS.TIER4)).toBeFalsy();
});
});
describe('RoleGuard(代行操作)', () => {
it('代行操作許可設定時、delegateUserIdに値が含まれる場合、許可される', () => {
const guards = RoleGuard.requireds({
delegation: true,
});
expect(guards.checkDelegate('delegateUser')).toBeTruthy();
});
it('代行操作許可設定時、delegateUserIdに値が含まれない場合、許可される', () => {
const guards = RoleGuard.requireds({
delegation: true,
});
expect(guards.checkDelegate(undefined)).toBeTruthy();
});
it('代行操作許可未設定時、delegateUserIdに値が含まれる場合、拒否される', () => {
const guards = RoleGuard.requireds({});
expect(guards.checkDelegate('delegateUser')).toBeFalsy();
});
it('代行操作許可未設定時、delegateUserIdに値が含まれない場合、許可される', () => {
const guards = RoleGuard.requireds({});
expect(guards.checkDelegate(undefined)).toBeTruthy();
});
});

View File

@ -15,6 +15,7 @@ import { ADMIN_ROLES, USER_ROLES, TIERS } from '../../../constants';
export interface RoleSetting {
roles?: (Roles | Roles[])[];
tiers?: Tiers[];
delegation?: boolean;
}
export class RoleGuard implements CanActivate {
@ -64,6 +65,17 @@ export class RoleGuard implements CanActivate {
);
}
// アクセストークンに代行操作ユーザーの情報が含まれているか(含まれている場合は代行操作)
const isDelegationUser = payload.delegateUserId !== undefined;
// 代行操作ユーザーの場合、代行操作許可設定がない場合は例外を送出
if (isDelegationUser && !this.settings.delegation) {
throw new HttpException(
makeErrorResponse('E000108'),
HttpStatus.UNAUTHORIZED,
);
}
// 権限チェック
// settingsで設定されていない場合チェック自体行わないので初期値はtrueとする
let hasRolePermission = true;
@ -74,7 +86,9 @@ export class RoleGuard implements CanActivate {
if (this.settings.tiers) {
hasTierPermission = this.checkTier(payload.tier);
}
if (hasRolePermission && hasTierPermission) {
const hasDelegatePermission = this.checkDelegate(payload.delegateUserId);
if (hasRolePermission && hasTierPermission && hasDelegatePermission) {
return true;
}
@ -182,4 +196,24 @@ export class RoleGuard implements CanActivate {
// 宣言された階層中にパラメータの内容が含まれていればtrue
return settings.tiers.includes(tier as (typeof TIERS)[keyof typeof TIERS]);
}
/**
* publicメソッドとして切り出したもの
*
* @param delegateUserId delegateUserIdの値
* @returns true/false
*/
checkDelegate(delegateUserId?: string | undefined): boolean {
const settings = this.settings;
// 通常ユーザの場合は許可
if (delegateUserId === undefined) {
return true;
} else if (settings?.delegation ?? false) {
// 代行操作ユーザーの場合は許可設定がある場合のみ許可;
return true;
}
return false;
}
}

View File

@ -7,11 +7,5 @@ import type {
} from './types';
import { isIDToken } from './typeguard';
export type {
AccessToken,
B2cMetadata,
IDToken,
JwkSignKey,
RefreshToken,
};
export type { AccessToken, B2cMetadata, IDToken, JwkSignKey, RefreshToken };
export { isIDToken };

View File

@ -9,6 +9,10 @@ import { Assignee } from '../../features/tasks/types/types';
@ValidatorConstraint()
export class IsTypist implements ValidatorConstraintInterface {
validate(values: Assignee[]): boolean {
if (!values) {
return false;
}
return values.every((value) => {
const { typistUserId, typistGroupId, typistName } = value;
if (typistUserId === undefined && typistGroupId === undefined) {

View File

@ -159,7 +159,9 @@ export class AccountsController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Post('licenses/summary')
async getLicenseSummary(
@Req() req: Request,
@ -197,7 +199,9 @@ export class AccountsController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Get('me')
async getMyAccount(@Req() req: Request): Promise<GetMyAccountResponse> {
const accessToken = retrieveAuthorizationToken(req);
@ -246,7 +250,9 @@ export class AccountsController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Get('authors')
async getAuthors(@Req() req: Request): Promise<GetAuthorsResponse> {
const accessToken = retrieveAuthorizationToken(req);
@ -293,6 +299,7 @@ export class AccountsController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ delegation: true }))
@Get('typists')
async getTypists(@Req() req: Request): Promise<GetTypistsResponse> {
const accessToken = retrieveAuthorizationToken(req);
@ -338,6 +345,7 @@ export class AccountsController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ delegation: true }))
@Get('typist-groups')
async getTypistGroups(@Req() req: Request): Promise<GetTypistGroupsResponse> {
const accessToken = retrieveAuthorizationToken(req);
@ -388,7 +396,9 @@ export class AccountsController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Get('typist-groups/:typistGroupId')
async getTypistGroup(
@Req() req: Request,
@ -452,7 +462,9 @@ export class AccountsController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Post('typist-groups')
async createTypistGroup(
@Req() req: Request,
@ -513,7 +525,9 @@ export class AccountsController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Post('typist-groups/:typistGroupId')
async updateTypistGroup(
@Req() req: Request,
@ -680,6 +694,7 @@ export class AccountsController {
@UseGuards(
RoleGuard.requireds({
roles: [ADMIN_ROLES.ADMIN],
delegation: true,
}),
)
async getOrderHistories(
@ -858,7 +873,9 @@ export class AccountsController {
@ApiOperation({ operationId: 'getWorktypes' })
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
async getWorktypes(@Req() req: Request): Promise<GetWorktypesResponse> {
const accessToken = retrieveAuthorizationToken(req);
if (!accessToken) {
@ -906,7 +923,9 @@ export class AccountsController {
@ApiOperation({ operationId: 'createWorktype' })
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
async createWorktype(
@Req() req: Request,
@Body() body: CreateWorktypesRequest,
@ -964,7 +983,9 @@ export class AccountsController {
@ApiOperation({ operationId: 'updateWorktype' })
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
async updateWorktype(
@Req() req: Request,
@Param() param: UpdateWorktypeRequestParam,
@ -1026,7 +1047,9 @@ export class AccountsController {
@ApiOperation({ operationId: 'deleteWorktype' })
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
async deleteWorktype(
@Req() req: Request,
@Param() param: DeleteWorktypeRequestParam,
@ -1079,7 +1102,9 @@ export class AccountsController {
@ApiOperation({ operationId: 'getOptionItems' })
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
async getOptionItems(
@Req() req: Request,
@Param() param: GetOptionItemsRequestParam,
@ -1137,7 +1162,9 @@ export class AccountsController {
@ApiOperation({ operationId: 'updateOptionItems' })
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
async updateOptionItems(
@Req() req: Request,
@Param() param: UpdateOptionItemsRequestParam,
@ -1198,7 +1225,9 @@ export class AccountsController {
@ApiOperation({ operationId: 'activeWorktype' })
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
async activeWorktype(
@Req() req: Request,
@Body() body: PostActiveWorktypeRequest,

View File

@ -150,8 +150,9 @@ export const createWorktype = async (
},
);
}
return worktype;
return (await datasource
.getRepository(Worktype)
.findOne({ where: { id: worktype.id } })) as Worktype;
};
// Worktypeを取得する

View File

@ -337,7 +337,9 @@ export class FilesController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
async uploadTemplateLocation(
@Req() req: Request,
): Promise<TemplateUploadLocationResponse> {
@ -393,7 +395,9 @@ export class FilesController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Post('template/upload-finished')
async templateUploadFinished(
@Req() req: Request,

View File

@ -7,6 +7,8 @@ import { AudioOptionItemsRepositoryModule } from '../../repositories/audio_optio
import { TasksRepositoryModule } from '../../repositories/tasks/tasks.repository.module';
import { BlobstorageModule } from '../../gateways/blobstorage/blobstorage.module';
import { TemplateFilesRepositoryModule } from '../../repositories/template_files/template_files.repository.module';
import { UserGroupsRepositoryModule } from '../../repositories/user_groups/user_groups.repository.module';
import { NotificationhubModule } from '../../gateways/notificationhub/notificationhub.module';
@Module({
imports: [
@ -16,6 +18,8 @@ import { TemplateFilesRepositoryModule } from '../../repositories/template_files
TasksRepositoryModule,
BlobstorageModule,
TemplateFilesRepositoryModule,
UserGroupsRepositoryModule,
NotificationhubModule,
],
providers: [FilesService],
controllers: [FilesController],

View File

@ -7,7 +7,12 @@ import {
makeFilesServiceMock,
} from './test/files.service.mock';
import { DataSource } from 'typeorm';
import { createTask, makeTestingModuleWithBlob } from './test/utility';
import {
createTask,
createUserGroupAndMember,
getTaskFromJobNumber,
makeTestingModuleWithBlobAndNotification,
} from './test/utility';
import { FilesService } from './files.service';
import { makeContext } from '../../common/log';
import {
@ -22,6 +27,16 @@ import {
getTemplateFiles,
} from '../templates/test/utility';
import { TemplateFilesRepositoryService } from '../../repositories/template_files/template_files.repository.service';
import { makeDefaultNotificationhubServiceMockValue } from '../tasks/test/tasks.service.mock';
import {
createWorkflow,
createWorkflowTypist,
} from '../workflows/test/utility';
import { createWorktype } from '../accounts/test/utility';
import { TasksRepositoryService } from '../../repositories/tasks/tasks.repository.service';
import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service';
import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage';
import { getCheckoutPermissions, getTask } from '../tasks/test/utility';
describe('音声ファイルアップロードURL取得', () => {
it('アップロードSASトークンが乗っているURLを返却する', async () => {
@ -109,55 +124,535 @@ describe('音声ファイルアップロードURL取得', () => {
});
});
describe('タスク作成', () => {
it('文字起こしタスクを作成できる', async () => {
const blobParam = makeBlobstorageServiceMockValue();
const userRepoParam = makeDefaultUsersRepositoryMockValue();
const taskRepoParam = makeDefaultTasksRepositoryMockValue();
const service = await makeFilesServiceMock(
blobParam,
userRepoParam,
taskRepoParam,
);
expect(
await service.uploadFinished(
makeContext('trackingId'),
'userId',
'http://blob/url/file.zip',
'AUTHOR_01',
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
256,
'01',
'DS2',
'comment',
'workTypeID',
optionItemList,
false,
),
).toEqual({ jobNumber: '00000001' });
describe('タスク作成から自動ルーティング(DB使用)', () => {
let source: DataSource | null = null;
beforeEach(async () => {
source = new DataSource({
type: 'sqlite',
database: ':memory:',
logging: false,
entities: [__dirname + '/../../**/*.entity{.ts,.js}'],
synchronize: true, // trueにすると自動的にmigrationが行われるため注意
});
return source.initialize();
});
it('日付フォーマットが不正な場合、エラーを返却する', async () => {
const blobParam = makeBlobstorageServiceMockValue();
const userRepoParam = makeDefaultUsersRepositoryMockValue();
const taskRepoParam = makeDefaultTasksRepositoryMockValue();
const service = await makeFilesServiceMock(
blobParam,
userRepoParam,
taskRepoParam,
afterEach(async () => {
if (!source) return;
await source.destroy();
source = null;
});
it('タスク作成時に、自動ルーティングを行うことができるAPIの引数として渡されたAuthorIDとworkType', async () => {
if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
const {
external_id: authorExternalId,
id: authorUserId,
author_id: authorAuthorId,
} = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
const { id: typistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
author_id: undefined,
});
// ワークタイプを作成
const { id: worktypeId, custom_worktype_id } = await createWorktype(
source,
accountId,
'worktypeId',
);
// テンプレートファイルを作成
const { id: templateFileId } = await createTemplateFile(
source,
accountId,
'templateFile',
'http://blob/url/templateFile.zip',
);
// ワークフローを作成
const { id: workflowId } = await createWorkflow(
source,
accountId,
authorUserId,
worktypeId,
templateFileId,
);
// ワークフロータイピストを作成
await createWorkflowTypist(source, workflowId, typistUserId);
const blobParam = makeBlobstorageServiceMockValue();
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
const NotificationHubService = module.get<NotificationhubService>(
NotificationhubService,
);
const result = await service.uploadFinished(
makeContext('trackingId'),
authorExternalId,
'http://blob/url/file.zip',
authorAuthorId ?? '',
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
256,
'01',
'DS2',
'comment',
custom_worktype_id,
optionItemList,
false,
);
expect(result).toEqual({ jobNumber: '00000001' });
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId'),
[`user_${typistUserId}`],
makeNotifyMessage('M000101'),
);
// 作成したタスクを取得
const resultTask = await getTaskFromJobNumber(source, result.jobNumber);
// タスクのチェックアウト権限を取得
const resultCheckoutPermission = await getCheckoutPermissions(
source,
resultTask?.id ?? 0,
);
// タスクのテンプレートファイルIDを確認
expect(resultTask?.template_file_id).toEqual(templateFileId);
// タスクのチェックアウト権限が想定通りワークフローで設定されているのユーザーIDで作成されているか確認
expect(resultCheckoutPermission.length).toEqual(1);
expect(resultCheckoutPermission[0].user_id).toEqual(typistUserId);
});
it('別のタスクが既に存在する場合、タスク作成時に、自動ルーティングを行うことができるAPIの引数として渡されたAuthorIDとworkType', async () => {
if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
const {
external_id: authorExternalId,
id: authorUserId,
author_id: authorAuthorId,
} = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
const { id: typistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
author_id: undefined,
});
//タスクを作成
await createTask(
source,
accountId,
'http://blob/url/file.zip',
'file.zip',
'01',
typistUserId,
authorAuthorId ?? '',
);
// ワークタイプを作成
const { id: worktypeId, custom_worktype_id } = await createWorktype(
source,
accountId,
'worktypeId',
);
// ワークフローを作成
const { id: workflowId } = await createWorkflow(
source,
accountId,
authorUserId,
worktypeId,
);
// ワークフロータイピストを作成
await createWorkflowTypist(source, workflowId, typistUserId);
const blobParam = makeBlobstorageServiceMockValue();
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
const NotificationHubService = module.get<NotificationhubService>(
NotificationhubService,
);
const result = await service.uploadFinished(
makeContext('trackingId'),
authorExternalId,
'http://blob/url/file.zip',
authorAuthorId ?? '',
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
256,
'01',
'DS2',
'comment',
custom_worktype_id,
optionItemList,
false,
);
expect(result).toEqual({ jobNumber: '00000002' });
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId'),
[`user_${typistUserId}`],
makeNotifyMessage('M000101'),
);
// 作成したタスクを取得
const resultTask = await getTaskFromJobNumber(source, result.jobNumber);
// タスクのチェックアウト権限を取得
const resultCheckoutPermission = await getCheckoutPermissions(
source,
resultTask?.id ?? 0,
);
// タスクのテンプレートファイルIDを確認
expect(resultTask?.template_file_id).toBeNull();
// タスクのチェックアウト権限が想定通りワークフローで設定されているのユーザーIDで作成されているか確認
expect(resultCheckoutPermission.length).toEqual(1);
expect(resultCheckoutPermission[0].user_id).toEqual(typistUserId);
});
it('タスク作成時に、自動ルーティングを行うことができるAPI実行者のAuthorIDとworkType', async () => {
if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
// 音声ファイルの録音者のユーザー
const { author_id: authorAuthorId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
// ルーティング先のタイピストのユーザー
const { id: typistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
author_id: undefined,
});
// API実行者のユーザー
const { external_id: myExternalId, id: myUserId } = await makeTestUser(
source,
{
account_id: accountId,
external_id: 'my-author-user-external-id',
role: 'author',
author_id: 'MY_AUTHOR_ID',
},
);
// ワークタイプを作成
const { id: worktypeId, custom_worktype_id } = await createWorktype(
source,
accountId,
'worktypeId',
);
// テンプレートファイルを作成
const { id: templateFileId } = await createTemplateFile(
source,
accountId,
'templateFile',
'http://blob/url/templateFile.zip',
);
// ワークフローを作成
const { id: workflowId } = await createWorkflow(
source,
accountId,
myUserId, // API実行者のユーザーIDを設定
worktypeId,
templateFileId,
);
// ユーザーグループを作成
const { userGroupId } = await createUserGroupAndMember(
source,
accountId,
'userGroupName',
typistUserId, // ルーティング先のタイピストのユーザーIDを設定
);
// ワークフロータイピストを作成
await createWorkflowTypist(
source,
workflowId,
undefined,
userGroupId, // ルーティング先のユーザーグループIDを設定
);
const blobParam = makeBlobstorageServiceMockValue();
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
const NotificationHubService = module.get<NotificationhubService>(
NotificationhubService,
);
const result = await service.uploadFinished(
makeContext('trackingId'),
myExternalId, // API実行者のユーザーIDを設定
'http://blob/url/file.zip',
authorAuthorId ?? '', // 音声ファイルの情報には、録音者のAuthorIDが入る
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
256,
'01',
'DS2',
'comment',
custom_worktype_id,
optionItemList,
false,
);
expect(result).toEqual({ jobNumber: '00000001' });
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId'),
[`user_${typistUserId}`],
makeNotifyMessage('M000101'),
);
// 作成したタスクを取得
const resultTask = await getTaskFromJobNumber(source, result.jobNumber);
// タスクのチェックアウト権限を取得
const resultCheckoutPermission = await getCheckoutPermissions(
source,
resultTask?.id ?? 0,
);
// タスクのテンプレートファイルIDを確認
expect(resultTask?.template_file_id).toEqual(templateFileId);
// タスクのチェックアウト権限が想定通りワークフローで設定されているのユーザーIDで作成されているか確認
expect(resultCheckoutPermission.length).toEqual(1);
expect(resultCheckoutPermission[0].user_group_id).toEqual(userGroupId);
});
it('タスク作成時に、音声ファイルメタ情報のAuthorIDに存在しないものが入っていても自動ルーティングを行うことができるAPI実行者のAuthorIDとworkType', async () => {
if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
// 音声ファイルの録音者のユーザー
const { author_id: authorAuthorId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
// ルーティング先のタイピストのユーザー
const { id: typistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
author_id: undefined,
});
// API実行者のユーザー
const { external_id: myExternalId, id: myUserId } = await makeTestUser(
source,
{
account_id: accountId,
external_id: 'my-author-user-external-id',
role: 'author',
author_id: 'MY_AUTHOR_ID',
},
);
// ワークタイプを作成
const { id: worktypeId, custom_worktype_id } = await createWorktype(
source,
accountId,
'worktypeId',
);
// テンプレートファイルを作成
const { id: templateFileId } = await createTemplateFile(
source,
accountId,
'templateFile',
'http://blob/url/templateFile.zip',
);
// ワークフローを作成
const { id: workflowId } = await createWorkflow(
source,
accountId,
myUserId, // API実行者のユーザーIDを設定
worktypeId,
templateFileId,
);
// ユーザーグループを作成
const { userGroupId } = await createUserGroupAndMember(
source,
accountId,
'userGroupName',
typistUserId, // ルーティング先のタイピストのユーザーIDを設定
);
// ワークフロータイピストを作成
await createWorkflowTypist(
source,
workflowId,
undefined,
userGroupId, // ルーティング先のユーザーグループIDを設定
);
const blobParam = makeBlobstorageServiceMockValue();
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
const NotificationHubService = module.get<NotificationhubService>(
NotificationhubService,
);
const result = await service.uploadFinished(
makeContext('trackingId'),
myExternalId, // API実行者のユーザーIDを設定
'http://blob/url/file.zip',
'XXXXXXXXXX', // 音声ファイルの情報には、録音者のAuthorIDが入る
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
256,
'01',
'DS2',
'comment',
custom_worktype_id,
optionItemList,
false,
);
expect(result).toEqual({ jobNumber: '00000001' });
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId'),
[`user_${typistUserId}`],
makeNotifyMessage('M000101'),
);
// 作成したタスクを取得
const resultTask = await getTaskFromJobNumber(source, result.jobNumber);
// タスクのチェックアウト権限を取得
const resultCheckoutPermission = await getCheckoutPermissions(
source,
resultTask?.id ?? 0,
);
// タスクのテンプレートファイルIDを確認
expect(resultTask?.template_file_id).toEqual(templateFileId);
// タスクのチェックアウト権限が想定通りワークフローで設定されているのユーザーIDで作成されているか確認
expect(resultCheckoutPermission.length).toEqual(1);
expect(resultCheckoutPermission[0].user_group_id).toEqual(userGroupId);
}, 1000000);
it('ワークフローが見つからない場合、タスク作成時に、自動ルーティングを行うことができない', async () => {
if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
// 音声ファイルの録音者のユーザー
const {
external_id: authorExternalId,
id: authorUserId,
author_id: authorAuthorId,
} = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
const blobParam = makeBlobstorageServiceMockValue();
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
const result = await service.uploadFinished(
makeContext('trackingId'),
authorExternalId, // API実行者のユーザーIDを設定
'http://blob/url/file.zip',
authorAuthorId ?? '', // 音声ファイルの情報には、録音者のAuthorIDが入る
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
'2023-05-26T11:22:33.444',
256,
'01',
'DS2',
'comment',
'worktypeId',
optionItemList,
false,
);
expect(result).toEqual({ jobNumber: '00000001' });
// タスクを取得
const resultTask = await getTaskFromJobNumber(source, result.jobNumber);
// タスクのチェックアウト権限を取得
const resultCheckoutPermission = await getCheckoutPermissions(
source,
resultTask?.id ?? 0,
);
// タスクがあることを確認
expect(resultTask).not.toBeNull();
// 自動ルーティングが行われていないことを確認
expect(resultCheckoutPermission.length).toEqual(0);
});
it('日付フォーマットが不正な場合、エラーを返却する', async () => {
if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
const {
external_id: authorExternalId,
id: authorUserId,
author_id: authorAuthorId,
} = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
const blobParam = makeBlobstorageServiceMockValue();
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
await expect(
service.uploadFinished(
makeContext('trackingId'),
'userId',
authorExternalId,
'http://blob/url/file.zip',
'AUTHOR_01',
authorAuthorId ?? '',
'file.zip',
'11:22:33',
'yyyy-05-26T11:22:33.444',
@ -175,23 +670,36 @@ describe('タスク作成', () => {
new HttpException(makeErrorResponse('E010001'), HttpStatus.BAD_REQUEST),
);
});
it('オプションアイテムが10個ない場合、エラーを返却する', async () => {
if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
const {
external_id: authorExternalId,
id: authorUserId,
author_id: authorAuthorId,
} = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
const blobParam = makeBlobstorageServiceMockValue();
const userRepoParam = makeDefaultUsersRepositoryMockValue();
const taskRepoParam = makeDefaultTasksRepositoryMockValue();
const service = await makeFilesServiceMock(
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
userRepoParam,
taskRepoParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
await expect(
service.uploadFinished(
makeContext('trackingId'),
'userId',
authorExternalId,
'http://blob/url/file.zip',
'AUTHOR_01',
authorAuthorId ?? '',
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
@ -214,25 +722,25 @@ describe('タスク作成', () => {
new HttpException(makeErrorResponse('E010001'), HttpStatus.BAD_REQUEST),
);
});
it('タスク追加でユーザー情報の取得に失敗した場合、エラーを返却する', async () => {
if (!source) fail();
const blobParam = makeBlobstorageServiceMockValue();
const taskRepoParam = makeDefaultTasksRepositoryMockValue();
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const service = await makeFilesServiceMock(
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
{
findUserByExternalId: new Error(''),
},
taskRepoParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
await expect(
service.uploadFinished(
makeContext('trackingId'),
'userId',
'authorExternalId',
'http://blob/url/file.zip',
'AUTHOR_01',
'authorAuthorId',
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
@ -253,21 +761,37 @@ describe('タスク作成', () => {
),
);
});
it('タスクのDBへの追加に失敗した場合、エラーを返却する', async () => {
if (!source) fail();
const blobParam = makeBlobstorageServiceMockValue();
const userRepoParam = makeDefaultUsersRepositoryMockValue();
const service = await makeFilesServiceMock(blobParam, userRepoParam, {
create: new Error(''),
getTasksFromAccountId: new Error(),
});
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
const taskRepoService = module.get<TasksRepositoryService>(
TasksRepositoryService,
);
taskRepoService.create = jest.fn().mockRejectedValue(new Error(''));
const { id: accountId } = await makeTestSimpleAccount(source);
const { external_id: authorExternalId, author_id: authorAuthorId } =
await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
await expect(
service.uploadFinished(
makeContext('trackingId'),
'userId',
authorExternalId,
'http://blob/url/file.zip',
'AUTHOR_01',
authorAuthorId ?? '',
'file.zip',
'11:22:33',
'2023-05-26T11:22:33.444',
@ -338,7 +862,12 @@ describe('音声ファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -381,7 +910,12 @@ describe('音声ファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -430,7 +964,12 @@ describe('音声ファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -470,7 +1009,12 @@ describe('音声ファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -497,7 +1041,12 @@ describe('音声ファイルダウンロードURL取得', () => {
const blobParam = makeBlobstorageServiceMockValue();
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -541,7 +1090,12 @@ describe('音声ファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = false;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -604,7 +1158,12 @@ describe('テンプレートファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -641,7 +1200,12 @@ describe('テンプレートファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -686,7 +1250,12 @@ describe('テンプレートファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -726,7 +1295,12 @@ describe('テンプレートファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = true;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -753,7 +1327,12 @@ describe('テンプレートファイルダウンロードURL取得', () => {
const blobParam = makeBlobstorageServiceMockValue();
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);
@ -796,7 +1375,12 @@ describe('テンプレートファイルダウンロードURL取得', () => {
blobParam.publishDownloadSas = `${url}?sas-token`;
blobParam.fileExists = false;
const module = await makeTestingModuleWithBlob(source, blobParam);
const notificationParam = makeDefaultNotificationhubServiceMockValue();
const module = await makeTestingModuleWithBlobAndNotification(
source,
blobParam,
notificationParam,
);
if (!module) fail();
const service = module.get<FilesService>(FilesService);

View File

@ -24,6 +24,10 @@ import {
import { Context } from '../../common/log';
import { TemplateFilesRepositoryService } from '../../repositories/template_files/template_files.repository.service';
import { AccountNotFoundError } from '../../repositories/accounts/errors/types';
import { Task } from '../../repositories/tasks/entity/task.entity';
import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service';
import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage';
import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service';
@Injectable()
export class FilesService {
@ -34,6 +38,8 @@ export class FilesService {
private readonly tasksRepositoryService: TasksRepositoryService,
private readonly templateFilesRepository: TemplateFilesRepositoryService,
private readonly blobStorageService: BlobstorageService,
private readonly userGroupsRepositoryService: UserGroupsRepositoryService,
private readonly notificationhubService: NotificationhubService,
) {}
/**
@ -157,7 +163,7 @@ export class FilesService {
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
let task: Task;
try {
// URLにSASトークンがついている場合は取り除く
const urlObj = new URL(url);
@ -167,7 +173,7 @@ export class FilesService {
// 文字起こしタスク追加(音声ファイルとオプションアイテムも同時に追加)
// 追加時に末尾のJOBナンバーにインクリメントする
const task = await this.tasksRepositoryService.create(
task = await this.tasksRepositoryService.create(
user.account_id,
user.id,
priority,
@ -185,13 +191,56 @@ export class FilesService {
isEncrypted,
optionItemList,
);
return { jobNumber: task.job_number };
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
// ルーティング設定に従い、チェックアウト権限を付与する
const { typistGroupIds, typistIds } =
await this.tasksRepositoryService.autoRouting(
task.audio_file_id,
user.account_id,
user.author_id ?? undefined,
);
const groupMembers =
await this.userGroupsRepositoryService.getGroupMembersFromGroupIds(
typistGroupIds,
);
// 重複のない割り当て候補ユーザーID一覧を取得する
const distinctUserIds = [
...new Set([...typistIds, ...groupMembers.map((x) => x.user_id)]),
];
// 割り当てられたユーザーがいない場合は通知不要
if (distinctUserIds.length === 0) {
this.logger.log('No user assigned.');
return { jobNumber: task.job_number };
}
// タグを生成
const tags = distinctUserIds.map((x) => `user_${x}`);
this.logger.log(`tags: ${tags}`);
// タグ対象に通知送信
await this.notificationhubService.notify(
context,
tags,
makeNotifyMessage('M000101'),
);
// 追加したタスクのJOBナンバーを返却
return { jobNumber: task.job_number };
} catch (error) {
// 処理の本筋はタスク生成のため自動ルーティングに失敗してもエラーにしない
this.logger.error(`Automatic routing or notification failed.`);
this.logger.error(`error=${error}`);
return { jobNumber: task.job_number };
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.uploadFinished.name}`,

View File

@ -6,6 +6,8 @@ import { FilesService } from '../files.service';
import { TasksRepositoryService } from '../../../repositories/tasks/tasks.repository.service';
import { Task } from '../../../repositories/tasks/entity/task.entity';
import { TemplateFilesRepositoryService } from '../../../repositories/template_files/template_files.repository.service';
import { NotificationhubService } from '../../../gateways/notificationhub/notificationhub.service';
import { UserGroupsRepositoryService } from '../../../repositories/user_groups/user_groups.repository.service';
export type BlobstorageServiceMockValue = {
createContainer: void | Error;
@ -42,6 +44,10 @@ export const makeFilesServiceMock = async (
return makeTasksRepositoryMock(tasksRepositoryMockValue);
case TemplateFilesRepositoryService:
return {};
case NotificationhubService:
return {};
case UserGroupsRepositoryService:
return {};
}
})
.compile();
@ -186,6 +192,9 @@ export const makeDefaultTasksRepositoryMockValue =
option_items: null,
template_file: null,
typist_user: null,
created_by: null,
updated_by: null,
updated_at: new Date(),
},
getTasksFromAccountId: {
tasks: [],

View File

@ -37,6 +37,12 @@ import {
makeBlobstorageServiceMock,
} from './files.service.mock';
import { TemplateFile } from '../../../repositories/template_files/entity/template_file.entity';
import {
NotificationhubServiceMockValue,
makeNotificationhubServiceMock,
} from '../../tasks/test/tasks.service.mock';
import { UserGroup } from '../../../repositories/user_groups/entity/user_group.entity';
import { UserGroupMember } from '../../../repositories/user_groups/entity/user_group_member.entity';
export const createTask = async (
datasource: DataSource,
@ -95,9 +101,51 @@ export const createTask = async (
return { audioFileId: audioFile.id };
};
export const makeTestingModuleWithBlob = async (
export const getTaskFromJobNumber = async (
datasource: DataSource,
jobNumber: string,
): Promise<Task | null> => {
const task = await datasource.getRepository(Task).findOne({
where: {
job_number: jobNumber,
},
});
return task;
};
// ユーザーグループとユーザーグループメンバーを作成する
export const createUserGroupAndMember = async (
datasource: DataSource,
accountId: number,
name: string,
userId: number,
): Promise<{ userGroupId: number }> => {
const { identifiers } = await datasource.getRepository(UserGroup).insert({
account_id: accountId,
name: name,
deleted_at: null,
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
const userGroup = identifiers.pop() as UserGroup;
// ユーザーグループメンバーを作成する
await datasource.getRepository(UserGroupMember).insert({
user_group_id: userGroup.id,
user_id: userId,
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
return { userGroupId: userGroup.id };
};
export const makeTestingModuleWithBlobAndNotification = async (
datasource: DataSource,
blobStorageService: BlobstorageServiceMockValue,
notificationhubService: NotificationhubServiceMockValue,
): Promise<TestingModule | undefined> => {
try {
const module: TestingModule = await Test.createTestingModule({
@ -148,6 +196,8 @@ export const makeTestingModuleWithBlob = async (
})
.overrideProvider(BlobstorageService)
.useValue(makeBlobstorageServiceMock(blobStorageService))
.overrideProvider(NotificationhubService)
.useValue(makeNotificationhubServiceMock(notificationhubService))
.compile();
return module;

View File

@ -68,6 +68,7 @@ export class LicensesController {
RoleGuard.requireds({
roles: [ADMIN_ROLES.ADMIN],
tiers: [TIERS.TIER2, TIERS.TIER3, TIERS.TIER4, TIERS.TIER5],
delegation: true,
}),
)
@Post('/orders')
@ -175,7 +176,11 @@ export class LicensesController {
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], tiers: [TIERS.TIER5] }),
RoleGuard.requireds({
roles: [ADMIN_ROLES.ADMIN],
tiers: [TIERS.TIER5],
delegation: true,
}),
)
@Post('/cards/activate')
async activateCardLicenses(
@ -228,7 +233,11 @@ export class LicensesController {
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], tiers: [TIERS.TIER5] }),
RoleGuard.requireds({
roles: [ADMIN_ROLES.ADMIN],
tiers: [TIERS.TIER5],
delegation: true,
}),
)
@Get('/allocatable')
async getAllocatableLicenses(
@ -289,6 +298,7 @@ export class LicensesController {
RoleGuard.requireds({
roles: [ADMIN_ROLES.ADMIN],
tiers: [TIERS.TIER2, TIERS.TIER3, TIERS.TIER4, TIERS.TIER5],
delegation: true,
}),
)
@Post('/orders/cancel')

View File

@ -28,6 +28,15 @@ import {
import { ADMIN_ROLES, TASK_STATUS, USER_ROLES } from '../../constants';
import { makeTestingModule } from '../../common/test/modules';
import { createSortCriteria } from '../users/test/utility';
import { createWorktype } from '../accounts/test/utility';
import {
createWorkflow,
createWorkflowTypist,
} from '../workflows/test/utility';
import { createTemplateFile } from '../templates/test/utility';
import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service';
import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage';
import { Roles } from '../../common/types/role';
describe('TasksService', () => {
it('タスク一覧を取得できるadmin', async () => {
@ -208,6 +217,9 @@ describe('TasksService', () => {
template_file_id: null,
typist_user: null,
template_file: null,
created_by: null,
updated_by: null,
updated_at: new Date('2023-01-01T01:01:01.000'),
file: {
id: 1,
account_id: 1,
@ -2465,6 +2477,253 @@ describe('cancel', () => {
new HttpException(makeErrorResponse('E010603'), HttpStatus.NOT_FOUND),
);
});
it('API実行者のRoleがTypistの場合、自身が文字起こし実行中のタスクをキャンセルし、そのタスクの自動ルーティングを行う', async () => {
if (!source) fail();
const notificationhubServiceMockValue =
makeDefaultNotificationhubServiceMockValue();
const module = await makeTaskTestingModuleWithNotificaiton(
source,
notificationhubServiceMockValue,
);
if (!module) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
const { id: typistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
});
const { id: authorUserId, author_id } = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
//ワークタイプIDを作成
await createWorktype(source, accountId, '01');
// テンプレートファイルを作成
const { id: templateFileId } = await createTemplateFile(
source,
accountId,
'template-file-name',
'https://example.com',
);
// ワークフローを作成
const { id: workflowId } = await createWorkflow(
source,
accountId,
authorUserId,
undefined,
templateFileId,
);
// ワークフロータイピストを作成
await createWorkflowTypist(source, workflowId, typistUserId);
const { taskId } = await createTask(
source,
accountId,
authorUserId,
author_id ?? '',
'',
'01',
'00000001',
'InProgress',
typistUserId,
);
await createCheckoutPermissions(source, taskId, typistUserId);
const service = module.get<TasksService>(TasksService);
const NotificationHubService = module.get<NotificationhubService>(
NotificationhubService,
);
await service.cancel(
makeContext('trackingId'),
1,
'typist-user-external-id',
['typist', 'standard'],
);
const resultTask = await getTask(source, taskId);
const permisions = await getCheckoutPermissions(source, taskId);
expect(resultTask?.status).toEqual('Uploaded');
expect(resultTask?.typist_user_id).toEqual(null);
// タスクのテンプレートファイルIDを確認
expect(resultTask?.template_file_id).toEqual(templateFileId);
// タスクのチェックアウト権限が想定通りワークフローで設定されているのユーザーIDで作成されているか確認
expect(permisions.length).toEqual(1);
expect(permisions[0].user_id).toEqual(typistUserId);
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId'),
[`user_${typistUserId}`],
makeNotifyMessage('M000101'),
);
}, 1000000);
it('API実行者のRoleがAdminの場合、自身が文字起こし実行中のタスクをキャンセルし、そのタスクの自動ルーティングを行うAPI実行者のAuthorIDと音声ファイルに紐づくWorkType', async () => {
if (!source) fail();
const notificationhubServiceMockValue =
makeDefaultNotificationhubServiceMockValue();
const module = await makeTaskTestingModuleWithNotificaiton(
source,
notificationhubServiceMockValue,
);
if (!module) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
// タスクの文字起こし担当者
const { id: typistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
});
// 自動ルーティングされるタイピストユーザーを作成
const { id: autoRoutingTypistUserId } = await makeTestUser(source, {
account_id: accountId,
external_id: 'auto-routing-typist-user-external-id',
role: 'typist',
});
// API実行者
const {
id: myAuthorUserId,
external_id,
role,
} = await makeTestUser(source, {
account_id: accountId,
external_id: 'my-author-user-external-id',
role: 'author admin',
author_id: 'MY_AUTHOR_ID',
});
// 音声ファイルのアップロード者
const { id: authorUserId, author_id } = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
//ワークタイプIDを作成
const { id: workTypeId, custom_worktype_id } = await createWorktype(
source,
accountId,
'01',
);
// テンプレートファイルを作成
const { id: templateFileId } = await createTemplateFile(
source,
accountId,
'template-file-name',
'https://example.com',
);
// ワークフローを作成
const { id: workflowId } = await createWorkflow(
source,
accountId,
myAuthorUserId,
workTypeId,
templateFileId,
);
// ワークフロータイピストを作成
await createWorkflowTypist(source, workflowId, autoRoutingTypistUserId);
const { taskId } = await createTask(
source,
accountId,
authorUserId,
author_id ?? '',
custom_worktype_id,
'01',
'00000001',
'InProgress',
typistUserId,
);
await createCheckoutPermissions(source, taskId, typistUserId);
const service = module.get<TasksService>(TasksService);
const NotificationHubService = module.get<NotificationhubService>(
NotificationhubService,
);
await service.cancel(
makeContext('trackingId'),
1,
external_id,
role.split(' ') as Roles[],
);
const resultTask = await getTask(source, taskId);
const permisions = await getCheckoutPermissions(source, taskId);
expect(resultTask?.status).toEqual('Uploaded');
expect(resultTask?.typist_user_id).toEqual(null);
// タスクのテンプレートファイルIDを確認
expect(resultTask?.template_file_id).toEqual(templateFileId);
// タスクのチェックアウト権限が想定通りワークフローで設定されているのユーザーIDで作成されているか確認
expect(permisions.length).toEqual(1);
expect(permisions[0].user_id).toEqual(autoRoutingTypistUserId);
// 通知処理が想定通りの引数で呼ばれているか確認
expect(NotificationHubService.notify).toHaveBeenCalledWith(
makeContext('trackingId'),
[`user_${autoRoutingTypistUserId}`],
makeNotifyMessage('M000101'),
);
});
it('API実行者のRoleがTypistの場合、自身が文字起こし実行中のタスクをキャンセルするが、一致するワークフローがない場合は自動ルーティングを行うことができない', async () => {
if (!source) fail();
const notificationhubServiceMockValue =
makeDefaultNotificationhubServiceMockValue();
const module = await makeTaskTestingModuleWithNotificaiton(
source,
notificationhubServiceMockValue,
);
if (!module) fail();
const { id: accountId } = await makeTestSimpleAccount(source);
// タスクの文字起こし担当者
const {
id: typistUserId,
external_id,
role,
} = await makeTestUser(source, {
account_id: accountId,
external_id: 'typist-user-external-id',
role: 'typist',
});
// 音声ファイルのアップロード者
const { id: authorUserId, author_id } = await makeTestUser(source, {
account_id: accountId,
external_id: 'author-user-external-id',
role: 'author',
author_id: 'AUTHOR_ID',
});
const { taskId } = await createTask(
source,
accountId,
authorUserId,
author_id ?? '',
'custom_worktype_id',
'01',
'00000001',
'InProgress',
typistUserId,
);
await createCheckoutPermissions(source, taskId, typistUserId);
const service = module.get<TasksService>(TasksService);
const NotificationHubService = module.get<NotificationhubService>(
NotificationhubService,
);
await service.cancel(
makeContext('trackingId'),
1,
external_id,
role.split(' ') as Roles[],
);
const resultTask = await getTask(source, taskId);
const permisions = await getCheckoutPermissions(source, taskId);
expect(resultTask?.status).toEqual('Uploaded');
expect(resultTask?.typist_user_id).toEqual(null);
// タスクのチェックアウト権限が削除されていることを確認
expect(permisions.length).toEqual(0);
// 通知処理が想定通りの引数で呼ばれていないか確認
expect(NotificationHubService.notify).not.toHaveBeenCalled();
});
});
describe('getNextTask', () => {

View File

@ -33,6 +33,7 @@ import { NotificationhubService } from '../../gateways/notificationhub/notificat
import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service';
import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage';
import { Context } from '../../common/log';
import { User } from '../../repositories/users/entity/user.entity';
@Injectable()
export class TasksService {
@ -382,19 +383,29 @@ export class TasksService {
externalId: string,
role: Roles[],
): Promise<void> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.cancel.name} | params: { audioFileId: ${audioFileId}, externalId: ${externalId}, role: ${role} };`,
);
let user: User;
try {
this.logger.log(
`[IN] [${context.trackingId}] ${this.cancel.name} | params: { audioFileId: ${audioFileId}, externalId: ${externalId}, role: ${role} };`,
// ユーザー取得
user = await this.usersRepository.findUserByExternalId(externalId);
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.log(`[OUT] [${context.trackingId}] ${this.cancel.name}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
const { id, account_id } =
await this.usersRepository.findUserByExternalId(externalId);
}
try {
// roleにAdminが含まれていれば、文字起こし担当でなくてもキャンセルできるため、ユーザーIDは指定しない
return await this.taskRepository.cancel(
await this.taskRepository.cancel(
audioFileId,
[TASK_STATUS.IN_PROGRESS, TASK_STATUS.PENDING],
account_id,
role.includes(ADMIN_ROLES.ADMIN) ? undefined : id,
user.account_id,
role.includes(ADMIN_ROLES.ADMIN) ? undefined : user.id,
);
} catch (e) {
this.logger.error(`error=${e}`);
@ -422,6 +433,47 @@ export class TasksService {
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
// キャンセルしたタスクに自動ルーティングを行う
const { typistGroupIds, typistIds } =
await this.taskRepository.autoRouting(
audioFileId,
user.account_id,
user.author_id ?? undefined,
);
const groupMembers =
await this.userGroupsRepositoryService.getGroupMembersFromGroupIds(
typistGroupIds,
);
// 重複のない割り当て候補ユーザーID一覧を取得する
const distinctUserIds = [
...new Set([...typistIds, ...groupMembers.map((x) => x.user_id)]),
];
// 割り当てられたユーザーがいない場合は通知不要
if (distinctUserIds.length === 0) {
this.logger.log('No user assigned.');
return;
}
// タグを生成
const tags = distinctUserIds.map((x) => `user_${x}`);
this.logger.log(`tags: ${tags}`);
// タグ対象に通知送信
await this.notificationhubService.notify(
context,
tags,
makeNotifyMessage('M000101'),
);
} catch (e) {
// 処理の本筋はタスクキャンセルのため自動ルーティングに失敗してもエラーにしない
this.logger.error(`Automatic routing or notification failed.`);
this.logger.error(`error=${e}`);
} finally {
this.logger.log(`[OUT] [${context.trackingId}] ${this.cancel.name}`);
}

View File

@ -362,6 +362,9 @@ const defaultTasksRepositoryMockValue: {
template_file_id: null,
typist_user: null,
template_file: null,
created_by: null,
updated_by: null,
updated_at: new Date('2023-01-01T01:01:01.000Z'),
option_items: [
{
id: 1,

View File

@ -51,7 +51,9 @@ export class TemplatesController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Get()
async getTemplates(@Req() req: Request): Promise<GetTemplatesResponse> {
const accessToken = retrieveAuthorizationToken(req);

View File

@ -337,7 +337,7 @@ export const makeDefaultAdB2cMockValue = (): AdB2cMockValue => {
},
createUser: '001',
getUser: {
id: "xxxx-xxxxx-xxxxx-xxxx",
id: 'xxxx-xxxxx-xxxxx-xxxx',
displayName: 'Hanako Sato',
},
getUsers: AdB2cMockUsers,

View File

@ -54,7 +54,6 @@ import { RoleGuard } from '../../common/guards/role/roleguards';
import { makeContext } from '../../common/log';
import { UserRoles } from '../../common/types/role';
import { v4 as uuidv4 } from 'uuid';
import { userInfo } from 'os';
@ApiTags('users')
@Controller('users')
@ -129,7 +128,9 @@ export class UsersController {
@ApiOperation({ operationId: 'getUsers' })
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Get()
async getUsers(@Req() req: Request): Promise<GetUsersResponse> {
const accessToken = retrieveAuthorizationToken(req);
@ -176,7 +177,9 @@ export class UsersController {
@ApiBearerAuth()
@Post('/signup')
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
async signup(
@Req() req: Request,
@Body() body: SignupRequest,
@ -413,7 +416,9 @@ export class UsersController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Post('update')
async updateUser(
@Body() body: PostUpdateUserRequest,
@ -492,7 +497,11 @@ export class UsersController {
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], tiers: [TIERS.TIER5] }),
RoleGuard.requireds({
roles: [ADMIN_ROLES.ADMIN],
tiers: [TIERS.TIER5],
delegation: true,
}),
)
@Post('/license/allocate')
async allocateLicense(
@ -551,7 +560,11 @@ export class UsersController {
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], tiers: [TIERS.TIER5] }),
RoleGuard.requireds({
roles: [ADMIN_ROLES.ADMIN],
tiers: [TIERS.TIER5],
delegation: true,
}),
)
@Post('/license/deallocate')
async deallocateLicense(

View File

@ -416,7 +416,7 @@ export class UsersService {
// TODO [Task2163] ODMS側が正式にメッセージを決めるまで仮のメール内容とする
const subject = 'A temporary password has been issued.';
const text = 'temporary password: ' + ramdomPassword;
const html = `<p>OMDS TOP PAGE URL.<p><a href="${this.appDomain}">${this.appDomain}"</a><br>temporary password: ${ramdomPassword}`;
const html = `<p>OMDS TOP PAGE URL.<p><a href="${this.appDomain}">${this.appDomain}</a><br>temporary password: ${ramdomPassword}`;
// メールを送信
await this.sendgridService.sendMail(

View File

@ -63,7 +63,9 @@ export class WorkflowsController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Get()
async getWorkflows(@Req() req: Request): Promise<GetWorkflowsResponse> {
const accessToken = retrieveAuthorizationToken(req);
@ -115,7 +117,9 @@ export class WorkflowsController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Post()
async createWorkflows(
@Req() req: Request,
@ -178,7 +182,9 @@ export class WorkflowsController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Post('/:workflowId')
async updateWorkflow(
@Req() req: Request,
@ -244,7 +250,9 @@ export class WorkflowsController {
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], delegation: true }),
)
@Post('/:workflowId/delete')
async deleteWorkflow(
@Req() req: Request,

View File

@ -80,7 +80,7 @@ export class RedisService {
*/
async mget<T>(keys: string[]): Promise<{ key: string; value: T | null }[]> {
if (keys.length === 0) return []; // mget操作は0件の時エラーとなるため、0件は特別扱いする
try {
const records = await this.cacheManager.store.mget(...keys);
// getで取得した順序とKeysの順序は一致するはずなので、indexを利用してペアになるよう加工する

View File

@ -54,7 +54,7 @@ export class SendGridService {
return {
subject: 'Verify your new account',
text: `The verification URL. ${this.appDomain}${path}?verify=${token}`,
html: `<p>The verification URL.<p><a href="${this.appDomain}${path}?verify=${token}">${this.appDomain}${path}?verify=${token}"</a>`,
html: `<p>The verification URL.<p><a href="${this.appDomain}${path}?verify=${token}">${this.appDomain}${path}?verify=${token}</a>`,
};
}
@ -87,7 +87,7 @@ export class SendGridService {
return {
subject: 'Verify your new account',
text: `The verification URL. ${this.appDomain}${path}?verify=${token}`,
html: `<p>The verification URL.<p><a href="${this.appDomain}${path}?verify=${token}">${this.appDomain}${path}?verify=${token}"</a>`,
html: `<p>The verification URL.<p><a href="${this.appDomain}${path}?verify=${token}">${this.appDomain}${path}?verify=${token}</a>`,
};
}

View File

@ -10,6 +10,8 @@ import {
JoinColumn,
OneToMany,
ManyToOne,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { bigintTransformer } from '../../../common/entity';
@ -37,8 +39,24 @@ export class Task {
started_at: Date | null;
@Column({ nullable: true, type: 'datetime' })
finished_at: Date | null;
@Column({})
@Column({ nullable: true, type: 'datetime' })
created_by: string | null;
@CreateDateColumn({
default: () => "datetime('now', 'localtime')",
type: 'datetime',
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
created_at: Date;
@Column({ nullable: true, type: 'datetime' })
updated_by: string | null;
@UpdateDateColumn({
default: () => "datetime('now', 'localtime')",
type: 'datetime',
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at: Date;
@OneToOne(() => AudioFile, (audiofile) => audiofile.task)
@JoinColumn({ name: 'audio_file_id' })
file: AudioFile | null;

View File

@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common';
import {
DataSource,
EntityManager,
FindOptionsOrder,
FindOptionsOrderValue,
In,
IsNull,
Repository,
} from 'typeorm';
import { Task } from './entity/task.entity';
import { ADMIN_ROLES, TASK_STATUS, USER_ROLES } from '../../constants';
@ -35,6 +37,8 @@ import {
import { Roles } from '../../common/types/role';
import { TaskStatus, isTaskStatus } from '../../common/types/taskStatus';
import { SortCriteria } from '../sort_criteria/entity/sort_criteria.entity';
import { Workflow } from '../workflows/entity/workflow.entity';
import { Worktype } from '../worktypes/entity/worktype.entity';
@Injectable()
export class TasksRepositoryService {
@ -710,18 +714,6 @@ export class TasksRepositoryService {
task.audio_file_id = savedAudioFile.id;
const optionItems = paramOptionItems.map((x) => {
return {
audio_file_id: savedAudioFile.id,
label: x.optionItemLabel,
value: x.optionItemValue,
};
});
const optionItemRepo = entityManager.getRepository(AudioOptionItem);
const newAudioOptionItems = optionItemRepo.create(optionItems);
await optionItemRepo.save(newAudioOptionItems);
const taskRepo = entityManager.getRepository(Task);
// アカウント内でJOBナンバーが有効なタスクのうち最新のものを取得
@ -743,8 +735,19 @@ export class TasksRepositoryService {
}
task.job_number = newJobNumber;
const newTask = taskRepo.create(task);
const persisted = await taskRepo.save(newTask);
const persisted = await taskRepo.save(task);
const optionItems = paramOptionItems.map((x) => {
return {
audio_file_id: persisted.audio_file_id,
label: x.optionItemLabel,
value: x.optionItemValue,
};
});
const optionItemRepo = entityManager.getRepository(AudioOptionItem);
const newAudioOptionItems = optionItemRepo.create(optionItems);
await optionItemRepo.save(newAudioOptionItems);
return persisted;
},
);
@ -952,6 +955,227 @@ export class TasksRepositoryService {
return tasks;
});
}
/**
*
* @param audioFileId
* @param accountId
* @param [myAuthorId]
* @returns typistIds: タイピストIDの一覧 / typistGroupIds: タイピストグループIDの一覧
*/
async autoRouting(
audioFileId: number,
accountId: number,
myAuthorId?: string, // API実行者のAuthorId
): Promise<{ typistIds: number[]; typistGroupIds: number[] }> {
return await this.dataSource.transaction(async (entityManager) => {
// 音声ファイルを取得
const audioFileRepo = entityManager.getRepository(AudioFile);
const audioFile = await audioFileRepo.findOne({
relations: {
task: true,
},
where: {
id: audioFileId,
account_id: accountId,
},
});
if (!audioFile) {
throw new Error(
`audio file not found. audio_file_id:${audioFileId}, accountId:${accountId}`,
);
}
const { task } = audioFile;
if (!task) {
throw new Error(
`task not found. audio_file_id:${audioFileId}, accountId:${accountId}`,
);
}
// authorIdをもとにユーザーを取得
const userRepo = entityManager.getRepository(User);
const authorUser = await userRepo.findOne({
where: {
author_id: audioFile.author_id,
account_id: accountId,
},
});
// 音声ファイル上のworktypeIdをもとにworktypeを取得
const worktypeRepo = entityManager.getRepository(Worktype);
const worktypeRecord = await worktypeRepo.findOne({
where: {
custom_worktype_id: audioFile.work_type_id,
account_id: accountId,
},
});
// 音声ファイル上のworktypeIdが設定されているが、一致するworktypeが存在しない場合はエラーを出して終了
if (!worktypeRecord && audioFile.work_type_id !== '') {
throw new Error(
`worktype not found. worktype:${audioFile.work_type_id}, accountId:${accountId}`,
);
}
// Workflowルーティングルールを取得
const workflowRepo = entityManager.getRepository(Workflow);
const workflow = await workflowRepo.findOne({
relations: {
workflowTypists: true,
},
where: {
account_id: accountId,
author_id: authorUser?.id ?? IsNull(), // authorUserが存在しない場合は、必ずヒットしないようにNULLを設定する
worktype_id: worktypeRecord?.id ?? IsNull(),
},
});
// Workflowルーティングルールがあればタスクのチェックアウト権限を設定する
if (workflow) {
return await this.setCheckoutPermissionAndTemplate(
workflow,
task,
accountId,
entityManager,
userRepo,
);
}
// 音声ファイルの情報からルーティングルールを取得できない場合は、
// API実行者のAuthorIdと音声ファイルのWorktypeをもとにルーティングルールを取得する
// API実行者のAuthorIdがない場合はエラーを出して終了
if (!myAuthorId) {
throw new Error(`There is no AuthorId for the API executor.`);
}
// API実行者のAuthorIdをもとにユーザーを取得
const myAuthorUser = await userRepo.findOne({
where: {
author_id: myAuthorId,
account_id: accountId,
},
});
if (!myAuthorUser) {
throw new Error(
`user not found. authorId:${myAuthorId}, accountId:${accountId}`,
);
}
const defaultWorkflow = await workflowRepo.findOne({
relations: {
workflowTypists: true,
},
where: {
account_id: accountId,
author_id: myAuthorUser.id,
worktype_id: worktypeRecord?.id ?? IsNull(),
},
});
// API実行者のAuthorIdと音声ファイルのWorktypeをもとにルーティングルールを取得できない場合はエラーを出して終了
if (!defaultWorkflow) {
throw new Error(
`workflow not found. authorUserId:${myAuthorUser.id}, accountId:${accountId}, worktypeId:${worktypeRecord?.id}`,
);
}
// Workflowルーティングルールがあればタスクのチェックアウト権限を設定する
return await this.setCheckoutPermissionAndTemplate(
defaultWorkflow,
task,
accountId,
entityManager,
userRepo,
);
});
}
/**
* workflowに紐づけられているタイピスト
* workflowに紐づけられているテンプレートファイルIDをタスクに設定
*
* @param workflow
* @param task
* @param accountId
* @param entityManager
* @param userRepo
* @returns checkout permission
*/
private async setCheckoutPermissionAndTemplate(
workflow: Workflow,
task: Task,
accountId: number,
entityManager: EntityManager,
userRepo: Repository<User>,
): Promise<{ typistIds: number[]; typistGroupIds: number[] }> {
const { workflowTypists, template_id } = workflow;
if (!workflowTypists) {
throw new Error(`workflowTypists not found. workflowId:${workflow.id}`);
}
// タスクのテンプレートIDを更新
const taskRepo = entityManager.getRepository(Task);
await taskRepo.update(
{ id: task.id },
{
template_file_id: template_id,
},
);
// 取得したルーティングルールのタイピストまたはタイピストグループをチェックアウト権限に設定する
// ルーティング候補ユーザーの存在確認
const typistIds = workflowTypists.flatMap((typist) =>
typist.typist_id ? [typist.typist_id] : [],
);
const typistUsers = await userRepo.find({
where: { account_id: accountId, id: In(typistIds) },
});
if (typistUsers.length !== typistIds.length) {
throw new Error(`typist not found. ids: ${typistIds}`);
}
// ルーティング候補ユーザーグループの存在確認
const groupIds = workflowTypists.flatMap((typist) => {
return typist.typist_group_id ? [typist.typist_group_id] : [];
});
const userGroupRepo = entityManager.getRepository(UserGroup);
const typistGroups = await userGroupRepo.find({
where: { account_id: accountId, id: In(groupIds) },
});
if (typistGroups.length !== groupIds.length) {
throw new Error(`typist group not found. ids: ${groupIds}`);
}
const checkoutPermissionRepo =
entityManager.getRepository(CheckoutPermission);
// 当該タスクに紐づく既存checkoutPermissionをdelete
await checkoutPermissionRepo.delete({
task_id: task.id,
});
// ルーティング候補ユーザーのチェックアウト権限を作成
const typistPermissions = typistUsers.map((typistUser) => {
const permission = new CheckoutPermission();
permission.task_id = task.id;
permission.user_id = typistUser.id;
return permission;
});
// ルーティング候補ユーザーグループのチェックアウト権限を作成
const typistGroupPermissions = typistGroups.map((typistGroup) => {
const permission = new CheckoutPermission();
permission.task_id = task.id;
permission.user_group_id = typistGroup.id;
return permission;
});
const permissions = [...typistPermissions, ...typistGroupPermissions];
await checkoutPermissionRepo.save(permissions);
// user_idsとuser_group_idsを返却する
return {
typistIds: typistIds,
typistGroupIds: groupIds,
};
}
}
// ソート用オブジェクトを生成する