Merge branch 'develop'

This commit is contained in:
saito.k 2023-08-24 11:04:19 +09:00
commit fa1b3aa5a0
81 changed files with 12873 additions and 4497 deletions

View File

@ -16,6 +16,8 @@ import UserListPage from "pages/UserListPage";
import LicensePage from "pages/LicensePage";
import DictationPage from "pages/DictationPage";
import PartnerPage from "pages/PartnerPage";
import WorkflowPage from "pages/WorkflowPage";
import TypistGroupSettingPage from "pages/TypistGroupSettingPage";
const AppRouter: React.FC = () => (
<Routes>
@ -58,7 +60,11 @@ const AppRouter: React.FC = () => (
/>
<Route
path="/workflow"
element={<RouteAuthGuard component={<SamplePage />} />}
element={<RouteAuthGuard component={<WorkflowPage />} />}
/>
<Route
path="/workflow/typist-group"
element={<RouteAuthGuard component={<TypistGroupSettingPage />} />}
/>
<Route
path="/partners"

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@ import partnerLicense from "features/license/partnerLicense/partnerLicenseSlice"
import dictation from "features/dictation/dictationSlice";
import partner from "features/partner/partnerSlice";
import licenseOrderHistory from "features/license/licenseOrderHistory/licenseOrderHistorySlice";
import typistGroup from "features/workflow/typistGroup/typistGroupSlice";
export const store = configureStore({
reducer: {
@ -30,6 +31,7 @@ export const store = configureStore({
partnerLicense,
dictation,
partner,
typistGroup,
},
});

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.7.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="レイヤー_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<style type="text/css">
.st0{fill:#282828;}
</style>
<g>
<path class="st0" d="M25.7,23.7c2.5,0.6,4.8,0.2,6.7-1.2s2.9-3.4,2.9-6.1s-1-4.8-2.9-6.1c-1.9-1.3-4.1-1.7-6.7-1.1
c0.9,1.1,1.5,2.2,1.9,3.3s0.6,2.5,0.6,4s-0.2,2.8-0.6,3.9S26.6,22.6,25.7,23.7z"/>
<path class="st0" d="M17.8,24c2.2,0,4-0.7,5.4-2.1s2.1-3.2,2.1-5.4s-0.7-4-2.1-5.4C21.8,9.6,20,9,17.8,9s-4,0.7-5.4,2.1
s-2.1,3.2-2.1,5.4s0.7,4,2.1,5.4C13.8,23.2,15.5,24,17.8,24z M14.5,13.2c0.8-0.8,1.9-1.3,3.2-1.3s2.4,0.4,3.2,1.3
c0.9,0.9,1.3,1.9,1.3,3.2s-0.4,2.4-1.3,3.2C20.1,20.5,19,21,17.8,21s-2.4-0.4-3.2-1.3c-0.9-0.8-1.3-1.9-1.3-3.2
C13.2,15.1,13.7,14.1,14.5,13.2z"/>
<path class="st0" d="M5,37v-1.7c0-0.5,0.1-1,0.4-1.5s0.7-0.8,1.2-1.1c2.4-1.1,4.3-1.8,5.9-2.2c1.6-0.4,3.3-0.5,5.2-0.5
s3.7,0.2,5.2,0.5c0.4,0.1,0.9,0.2,1.4,0.4c0.7-0.8,1.5-1.6,2.4-2.3c-1.1-0.4-2.2-0.7-3.1-1c-1.9-0.5-3.8-0.7-5.9-0.7
s-4,0.2-5.9,0.7s-4,1.2-6.4,2.3c-1,0.5-1.9,1.2-2.5,2.1C2.3,33,2,34,2,35.2V40h19.1c0-1,0.1-2,0.3-3H5z"/>
<polygon class="st0" points="33.4,43.9 33.4,37.4 26.9,37.4 26.9,33.5 33.4,33.5 33.4,27 37.3,27 37.3,33.5 43.8,33.5 43.8,37.4
37.3,37.4 37.3,43.9 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,10 @@
// タイムゾーンを考慮し、ローカルからUTCとなるよう日付を変換する関数
export const convertLocalToUTCDate = (date: Date) => {
const MILLISECONDS_IN_A_MINUTE = 60 * 1000;
const timezoneOffsetMinutes = date.getTimezoneOffset();
const timezoneOffsetMilliseconds =
timezoneOffsetMinutes * MILLISECONDS_IN_A_MINUTE;
return new Date(date.getTime() + timezoneOffsetMilliseconds);
};

View File

@ -42,4 +42,8 @@ export const errorCodes = [
"E010701", // Blobファイル不在エラー
"E010801", // ライセンス不在エラー
"E010802", // ライセンス取り込み済みエラー
"E010803", // ライセンス発行済みエラー
"E010804", // ライセンス数不足エラー
"E010805", // ライセンス有効期限切れエラー
"E010806", // ライセンス割り当て不可エラー
] as const;

View File

@ -26,7 +26,9 @@ export const HEADER_NAME = "ODMS Cloud";
export const ADMIN_ONLY_TABS = [
HEADER_MENUS_LICENSE,
HEADER_MENUS_USER,
HEADER_MENUS_WORKFLOW,
HEADER_MENUS_PARTNER,
HEADER_MENUS_WORKFLOW,
];
/**

View File

@ -11,7 +11,15 @@ interface HeaderProps {
const Header: React.FC<HeaderProps> = (props) => {
const { userName } = props;
const location = useLocation();
return getHeader(location.pathname, userName);
const splitPaths = location.pathname.split("/");
let path = location.pathname;
if (splitPaths.length >= 2) {
path = `/${splitPaths[1]}`;
}
return getHeader(path, userName);
};
export default Header;

View File

@ -1,6 +1,6 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { LicenseOrderHistoryState } from "./state";
import { getLicenseOrderHistoriesAsync } from "./operations";
import { getLicenseOrderHistoriesAsync, issueLicenseAsync } from "./operations";
import { LIMIT_ORDER_HISORY_NUM } from "./constants";
const initialState: LicenseOrderHistoryState = {
@ -52,6 +52,15 @@ export const licenseOrderHistorySlice = createSlice({
builder.addCase(getLicenseOrderHistoriesAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(issueLicenseAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(issueLicenseAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(issueLicenseAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});

View File

@ -74,3 +74,70 @@ export const getLicenseOrderHistoriesAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
export const issueLicenseAsync = createAsyncThunk<
{
/* Empty Object */
},
{
// パラメータ
orderedAccountId: number;
poNumber: string;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("licenses/issueLicenseAsync", async (args, thunkApi) => {
const { orderedAccountId, poNumber } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, accessToken } = state.auth;
const config = new Configuration(configuration);
const accountsApi = new AccountsApi(config);
try {
await accountsApi.issueLicense(
{
orderedAccountId,
poNumber,
},
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
let errorMessage = getTranslationID("common.message.internalServerError");
if (error.code === "E010803") {
errorMessage = getTranslationID(
"orderHistoriesPage.message.alreadyIssueLicense"
);
} else if (error.code === "E010804") {
errorMessage = getTranslationID(
"orderHistoriesPage.message.notEnoughOfNumberOfLicense"
);
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message: errorMessage,
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -3,7 +3,11 @@ import type { RootState } from "app/store";
import { ErrorObject, createErrorObject } from "common/errors";
import { getTranslationID } from "translation";
import { closeSnackbar, openSnackbar } from "features/ui/uiSlice";
import { AccountsApi, CreateAccountRequest } from "../../api/api";
import {
AccountsApi,
CreateAccountRequest,
GetDealersResponse,
} from "../../api/api";
import { Configuration } from "../../api/configuration";
export const signupAsync = createAsyncThunk<
@ -56,3 +60,36 @@ export const signupAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
export const getDealersAsync = createAsyncThunk<
GetDealersResponse,
void,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("login/getDealersAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration } = state.auth;
const config = new Configuration(configuration);
const accountApi = new AccountsApi(config);
try {
const res = await accountApi.getDealers();
return res.data;
} catch (e) {
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -1,3 +1,4 @@
import { Dealer } from "api/api";
import { RootState } from "app/store";
export const selectInputValidationErrors = (state: RootState) => {
@ -56,3 +57,18 @@ export const selectPassword = (state: RootState) => state.signup.apps.password;
export const selectPageState = (state: RootState) =>
state.signup.apps.pageState;
export const selectAllDealers = (state: RootState) =>
state.signup.domain.dealers;
export const selectSameCountryDealers = (state: RootState) => {
const { dealers } = state.signup.domain;
const { country } = state.signup.apps;
return dealers.filter((x: Dealer) => x.country === country);
};
export const selectSelectedDealer = (state: RootState) => {
const { dealers } = state.signup.domain;
const { dealer } = state.signup.apps;
return dealers.find((x: Dealer) => x.id === dealer);
};

View File

@ -1,16 +1,20 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { SignupState } from "./state";
import { signupAsync } from "./operations";
import { getDealersAsync, signupAsync } from "./operations";
const initialState: SignupState = {
apps: {
pageState: "input",
company: "",
country: "",
dealer: "",
dealer: undefined,
adminName: "",
email: "",
password: "",
dealers: [],
},
domain: {
dealers: [],
},
};
@ -32,10 +36,11 @@ export const signupSlice = createSlice({
changeCountry: (state, action: PayloadAction<{ country: string }>) => {
const { country } = action.payload;
state.apps.country = country;
state.apps.dealer = undefined;
},
changeDealer: (state, action: PayloadAction<{ dealer: string }>) => {
changeDealer: (state, action: PayloadAction<{ dealer: number }>) => {
const { dealer } = action.payload;
state.apps.dealer = dealer;
state.apps.dealer = Number.isNaN(dealer) ? undefined : dealer;
},
changeAdminName: (state, action: PayloadAction<{ adminName: string }>) => {
const { adminName } = action.payload;
@ -60,6 +65,15 @@ export const signupSlice = createSlice({
builder.addCase(signupAsync.rejected, () => {
//
});
builder.addCase(getDealersAsync.pending, () => {
//
});
builder.addCase(getDealersAsync.fulfilled, (state, action) => {
state.domain.dealers = action.payload.dealers;
});
builder.addCase(getDealersAsync.rejected, () => {
//
});
},
});
export const {

View File

@ -1,13 +1,21 @@
import { Dealer } from "api/api";
export interface SignupState {
apps: Apps;
domain: Domain;
}
export interface Apps {
pageState: "input" | "confirm" | "complete";
company: string;
country: string;
dealer: string;
dealer?: number | undefined;
adminName: string;
email: string;
password: string;
dealers: Dealer[];
}
export interface Domain {
dealers: Dealer[];
}

View File

@ -5,3 +5,9 @@ export const LICENSE_STATUS = {
ALERT: "Alert",
RENEW: "Renew",
} as const;
// Licenseの割り当て状態
export const LICENSE_ALLOCATE_STATUS = {
ALLOCATED: "Allocated",
NOTALLOCATED: "Not Allocated",
} as const;

View File

@ -3,7 +3,12 @@ import type { RootState } from "app/store";
import { USER_ROLES } from "components/auth/constants";
import { openSnackbar } from "features/ui/uiSlice";
import { getTranslationID } from "translation";
import { GetUsersResponse, UsersApi } from "../../api/api";
import {
GetUsersResponse,
UsersApi,
LicensesApi,
GetAllocatableLicensesResponse,
} from "../../api/api";
import { Configuration } from "../../api/configuration";
import { ErrorObject, createErrorObject } from "../../common/errors";
@ -59,16 +64,17 @@ export const addUserAsync = createAsyncThunk<
const config = new Configuration(configuration);
const usersApi = new UsersApi(config);
const { addUser } = state.user.apps;
const newUser = { ...addUser };
// roleがAUTHOR以外の場合、不要なプロパティをundefinedにする
if (addUser.role !== USER_ROLES.AUTHOR) {
addUser.authorId = undefined;
addUser.encryption = undefined;
addUser.prompt = undefined;
addUser.encryptionPassword = undefined;
if (newUser.role !== USER_ROLES.AUTHOR) {
newUser.authorId = undefined;
newUser.encryption = undefined;
newUser.prompt = undefined;
newUser.encryptionPassword = undefined;
}
try {
await usersApi.signup(addUser, {
await usersApi.signup(newUser, {
headers: { authorization: `Bearer ${accessToken}` },
});
thunkApi.dispatch(
@ -188,3 +194,115 @@ export const updateUserAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
export const getAllocatableLicensesAsync = createAsyncThunk<
// 正常時の戻り値の型
GetAllocatableLicensesResponse,
// 引数
void,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("users/getAllocatableLicensesAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, accessToken } = state.auth;
const config = new Configuration(configuration);
const licensesApi = new LicensesApi(config);
try {
const res = await licensesApi.getAllocatableLicenses({
headers: { authorization: `Bearer ${accessToken}` },
});
return res.data;
} catch (e) {
// e ⇒ errorObjectに変換
const error = createErrorObject(e);
const errorMessage = getTranslationID("common.message.internalServerError");
thunkApi.dispatch(
openSnackbar({
level: "error",
message: errorMessage,
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const allocateLicenseAsync = createAsyncThunk<
// 正常時の戻り値の型
{
/* Empty Object */
},
// 引数
{
userId: number;
newLicenseId: number;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("users/allocateLicenseAsync", async (args, thunkApi) => {
const { userId, newLicenseId } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, accessToken } = state.auth;
const config = new Configuration(configuration);
const usersApi = new UsersApi(config);
try {
await usersApi.allocateLicense(
{
userId,
newLicenseId,
},
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
thunkApi.dispatch(
openSnackbar({
level: "info",
message: getTranslationID("common.message.success"),
})
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換
const error = createErrorObject(e);
let errorMessage = getTranslationID("common.message.internalServerError");
if (error.code === "E010805") {
errorMessage = getTranslationID(
"allocateLicensePopupPage.message.licenseAllocationFailure"
);
} else if (error.code === "E010806") {
errorMessage = getTranslationID(
"allocateLicensePopupPage.message.licenseAllocationFailure"
);
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message: errorMessage,
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -1,5 +1,6 @@
import { RootState } from "app/store";
import { USER_ROLES } from "components/auth/constants";
import { convertLocalToUTCDate } from "common/convertLocalToUTCDate";
import {
AddUser,
RoleType,
@ -7,7 +8,7 @@ import {
isLicenseStatusType,
isRoleType,
} from "./types";
import { LICENSE_STATUS } from "./constants";
import { LICENSE_STATUS, LICENSE_ALLOCATE_STATUS } from "./constants";
export const selectInputValidationErrors = (state: RootState) => {
const { name, email, role, authorId, encryption, encryptionPassword } =
@ -63,11 +64,8 @@ export const selectUpdateValidationErrors = (state: RootState) => {
);
if (passwordError) {
// 最初にEncryptionがfasleで、Encryptionがtrueに変更された場合、EncryptionPasswordが必須
if (!initEncryption) {
hasErrorIncorrectEncryptionPassword = true;
// Encryptionがある状態で変更がある場合、EncryptionPasswordが空でもエラーにしない
} else if (!encryptionPassword || encryptionPassword === "") {
// 最初にEncryptionがtrueで、EncryptionPassword変更されていない場合はエラーとしない
if (initEncryption && encryptionPassword === undefined) {
hasErrorIncorrectEncryptionPassword = false;
} else {
hasErrorIncorrectEncryptionPassword = true;
@ -227,3 +225,105 @@ export const selectUpdateUser = (state: RootState) =>
export const selectHasPasswordMask = (state: RootState) =>
state.user.apps.hasPasswordMask;
export const selectLicenseAllocateUserId = (state: RootState) =>
state.user.apps.licenseAllocateUser.id;
export const selectLicenseAllocateUserEmail = (state: RootState) =>
state.user.apps.licenseAllocateUser.email;
export const selectLicenseAllocateUserName = (state: RootState) =>
state.user.apps.licenseAllocateUser.name;
export const selectLicenseAllocateUserAuthorId = (state: RootState) =>
state.user.apps.licenseAllocateUser.authorId;
export const selectLicenseAllocateUserStatus = (state: RootState) =>
// ライセンスが割り当てられてるかどうかのステータスを返却する。NORMAL,ALERT,RENEWはすべてライセンス割り当て扱い
state.user.apps.licenseAllocateUser.licenseStatus === LICENSE_STATUS.NOLICENSE
? LICENSE_ALLOCATE_STATUS.NOTALLOCATED
: LICENSE_ALLOCATE_STATUS.ALLOCATED;
export const selectLicenseAllocateUserExpirationDate = (state: RootState) => {
const { licenseStatus, remaining, expiration } =
state.user.apps.licenseAllocateUser;
// ライセンスが割当たっていない場合は-、割当たってる場合はremaining(expiration)の形式で返却
if (licenseStatus === LICENSE_STATUS.NOLICENSE) {
return "-";
}
return `${expiration}(${remaining})`;
};
export const selectSelectedlicenseId = (state: RootState) =>
state.user.apps.selectedlicenseId;
export const selectAllocatableLicenses = (state: RootState) => {
const { allocatableLicenses } = state.user.domain;
// licenseIdはそのまま返却、expiryDateは「nullならundifined」「null以外ならyyyy/mm/dd(現在との差分日数)」を返却
const transformedLicenses = allocatableLicenses.map((license) => ({
licenseId: license.licenseId,
expiryDate: license.expiryDate
? calculateExpiryDate(license.expiryDate)
: undefined,
}));
return transformedLicenses;
};
export const selectInputValidationErrorsForLicenseAcclocation = (
state: RootState
) => {
// 必須項目のチェック(License Acclocation画面用)
// License available選択チェック
// 初期値である0と、「Select a license」選択時のNaNの場合エラーとする
const hasErrorEmptyLicense =
state.user.apps.selectedlicenseId === 0 ||
Number.isNaN(state.user.apps.selectedlicenseId);
return {
hasErrorEmptyLicense,
};
};
// 日付の差分を計算するサブ関数
const calculateExpiryDate = (expiryDate: string) => {
const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000; // 1日のミリ秒数
const currentDate = new Date();
const expirationDate = new Date(expiryDate);
// タイムゾーンオフセットを考慮して、ローカルタイムでの日付を取得
const currentDateLocal = convertLocalToUTCDate(currentDate);
const expirationDateLocal = convertLocalToUTCDate(expirationDate);
// ライセンスは時刻を考慮しないので時分秒を意識しない日付を取得する
const currentDateWithoutTime = new Date(
currentDateLocal.getFullYear(),
currentDateLocal.getMonth(),
currentDateLocal.getDate()
);
const expirationDateWithoutTime = new Date(
expirationDateLocal.getFullYear(),
expirationDateLocal.getMonth(),
expirationDateLocal.getDate()
);
// 差分日数を取得
const timeDifference =
expirationDateWithoutTime.getTime() - currentDateWithoutTime.getTime();
const daysDifference = Math.ceil(timeDifference / MILLISECONDS_IN_A_DAY);
// yyyy/mm/dd形式の年月日を取得
const expirationYear = expirationDateWithoutTime.getFullYear();
const expirationMonth = expirationDateWithoutTime.getMonth() + 1; // getMonth() の結果は0から始まるため、1を足して実際の月に合わせる
const expirationDay = expirationDateWithoutTime.getDate();
const formattedExpirationDate = `${expirationYear}/${expirationMonth}/${expirationDay}`;
return `${formattedExpirationDate} (${daysDifference})`;
};

View File

@ -1,5 +1,5 @@
import { User } from "../../api/api";
import { AddUser, UpdateUser } from "./types";
import { User, AllocatableLicenseInfo } from "../../api/api";
import { AddUser, UpdateUser, LicenseAllocateUser } from "./types";
export interface UsersState {
domain: Domain;
@ -8,12 +8,15 @@ export interface UsersState {
export interface Domain {
users: User[];
allocatableLicenses: AllocatableLicenseInfo[];
}
export interface Apps {
addUser: AddUser;
selectedUser: UpdateUser;
updateUser: UpdateUser;
licenseAllocateUser: LicenseAllocateUser;
selectedlicenseId: number;
hasPasswordMask: boolean;
isLoading: boolean;
}

View File

@ -50,6 +50,16 @@ export interface UpdateUser {
notification: boolean;
}
export interface LicenseAllocateUser {
id: number;
name: string;
email: string;
authorId: string;
licenseStatus: LicenseStatusType | string;
expiration: string;
remaining: number | string;
}
export type RoleType = typeof USER_ROLES[keyof typeof USER_ROLES];
// 受け取った値がUSER_ROLESの型であるかどうかを判定する

View File

@ -1,11 +1,16 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { USER_ROLES } from "components/auth/constants";
import { UsersState } from "./state";
import { addUserAsync, listUsersAsync, updateUserAsync } from "./operations";
import { RoleType } from "./types";
import {
addUserAsync,
listUsersAsync,
updateUserAsync,
getAllocatableLicensesAsync,
} from "./operations";
import { RoleType, UserView } from "./types";
const initialState: UsersState = {
domain: { users: [] },
domain: { users: [], allocatableLicenses: [] },
apps: {
updateUser: {
id: 0,
@ -45,6 +50,16 @@ const initialState: UsersState = {
prompt: false,
encryptionPassword: "",
},
licenseAllocateUser: {
id: 0,
name: "",
email: "",
authorId: "",
licenseStatus: "",
expiration: "",
remaining: "",
},
selectedlicenseId: 0,
hasPasswordMask: false,
isLoading: false,
},
@ -90,6 +105,9 @@ export const userSlice = createSlice({
) => {
const { encryption } = action.payload;
state.apps.addUser.encryption = encryption;
if (!encryption) {
state.apps.addUser.encryptionPassword = undefined;
}
},
changePrompt: (state, action: PayloadAction<{ prompt: boolean }>) => {
const { prompt } = action.payload;
@ -170,14 +188,16 @@ export const userSlice = createSlice({
if (initEncryption && encryption && !password) {
state.apps.hasPasswordMask = true;
}
if (!encryption) {
state.apps.updateUser.encryptionPassword = undefined;
}
},
changeUpdateEncryptionPassword: (
state,
action: PayloadAction<{ encryptionPassword: string }>
) => {
const { encryptionPassword } = action.payload;
state.apps.updateUser.encryptionPassword =
encryptionPassword === "" ? undefined : encryptionPassword;
state.apps.updateUser.encryptionPassword = encryptionPassword;
},
changeUpdatePrompt: (state, action: PayloadAction<{ prompt: boolean }>) => {
const { prompt } = action.payload;
@ -214,6 +234,30 @@ export const userSlice = createSlice({
cleanupUpdateUser: (state) => {
state.apps.updateUser = initialState.apps.updateUser;
},
changeLicenseAllocateUser: (
state,
action: PayloadAction<{ selectedUser: UserView }>
) => {
const { selectedUser } = action.payload;
state.apps.licenseAllocateUser.id = selectedUser.id;
state.apps.licenseAllocateUser.name = selectedUser.name;
state.apps.licenseAllocateUser.email = selectedUser.email;
state.apps.licenseAllocateUser.authorId = selectedUser.authorId;
state.apps.licenseAllocateUser.licenseStatus = selectedUser.licenseStatus;
state.apps.licenseAllocateUser.expiration = selectedUser.expiration;
state.apps.licenseAllocateUser.remaining = selectedUser.remaining;
},
changeSelectedlicenseId: (
state,
action: PayloadAction<{ selectedlicenseId: number }>
) => {
const { selectedlicenseId } = action.payload;
state.apps.selectedlicenseId = selectedlicenseId;
},
cleanupLicenseAllocateInfo: (state) => {
state.apps.licenseAllocateUser = initialState.apps.licenseAllocateUser;
state.apps.selectedlicenseId = initialState.apps.selectedlicenseId;
},
},
extraReducers: (builder) => {
builder.addCase(listUsersAsync.pending, (state) => {
@ -244,6 +288,16 @@ export const userSlice = createSlice({
builder.addCase(updateUserAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(getAllocatableLicensesAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(getAllocatableLicensesAsync.fulfilled, (state, action) => {
state.domain.allocatableLicenses = action.payload.allocatableLicenses;
state.apps.isLoading = false;
});
builder.addCase(getAllocatableLicensesAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});
@ -270,6 +324,9 @@ export const {
changePrompt,
changeEncryptionPassword,
changeHasPasswordMask,
changeLicenseAllocateUser,
changeSelectedlicenseId,
cleanupLicenseAllocateInfo,
} = userSlice.actions;
export default userSlice.reducer;

View File

@ -0,0 +1,4 @@
export * from "./typistGroupSlice";
export * from "./state";
export * from "./selectors";
export * from "./operations";

View File

@ -0,0 +1,43 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import type { RootState } from "app/store";
import { openSnackbar } from "features/ui/uiSlice";
import { getTranslationID } from "translation";
import { AccountsApi, GetTypistGroupsResponse } from "../../../api/api";
import { Configuration } from "../../../api/configuration";
import { ErrorObject, createErrorObject } from "../../../common/errors";
export const listTypistGroupsAsync = createAsyncThunk<
GetTypistGroupsResponse,
void,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/listTypistGroupsAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, accessToken } = state.auth;
const config = new Configuration(configuration);
const accountsApi = new AccountsApi(config);
try {
const typistGroup = await accountsApi.getTypistGroups({
headers: { authorization: `Bearer ${accessToken}` },
});
return typistGroup.data;
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -0,0 +1,7 @@
import { RootState } from "app/store";
export const selectTypistGroups = (state: RootState) =>
state.typistGroup.domain.typistGroups;
export const selectIsLoading = (state: RootState) =>
state.typistGroup.apps.isLoading;

View File

@ -0,0 +1,14 @@
import { TypistGroup } from "../../../api/api";
export interface TypistGroupState {
apps: Apps;
domain: Domain;
}
export interface Apps {
isLoading: boolean;
}
export interface Domain {
typistGroups: TypistGroup[];
}

View File

@ -0,0 +1,32 @@
import { createSlice } from "@reduxjs/toolkit";
import { TypistGroupState } from "./state";
import { listTypistGroupsAsync } from "./operations";
const initialState: TypistGroupState = {
apps: {
isLoading: false,
},
domain: {
typistGroups: [],
},
};
export const typistGroupSlice = createSlice({
name: "typistGroup",
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(listTypistGroupsAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(listTypistGroupsAsync.fulfilled, (state, action) => {
state.domain.typistGroups = action.payload.typistGroups;
state.apps.isLoading = false;
});
builder.addCase(listTypistGroupsAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});
export default typistGroupSlice.reducer;

View File

@ -17,6 +17,7 @@ import {
selectOrderHisory,
selectTotal,
selectTotalPage,
issueLicenseAsync,
selectOffset,
savePageInfo,
selectCompanyName,
@ -50,6 +51,40 @@ export const LicenseOrderHistory: React.FC<LicenseOrderHistoryProps> = (
onReturn();
}, [isLoading, onReturn]);
// issue,issueCancel,orderCancelボタンの処理終了時の情報更新用
const UpdateHistoriesList = () => {
dispatch(
getLicenseOrderHistoriesAsync({
limit: LIMIT_ORDER_HISORY_NUM,
offset,
})
);
};
// issueボタンを押下時の処理
const issueLicense = useCallback(
async (poNumber: string) => {
// ダイアログ確認
// eslint-disable-next-line no-alert
if (window.confirm(t(getTranslationID("common.message.dialogConfirm")))) {
// 注文APIの呼び出し
if (selectedRow) {
const { meta } = await dispatch(
issueLicenseAsync({
orderedAccountId: selectedRow.accountId,
poNumber,
})
);
if (meta.requestStatus === "fulfilled") {
UpdateHistoriesList();
}
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[dispatch]
);
// ページネーションのボタンクリック時のアクション
const movePage = (targetOffset: number) => {
dispatch(
@ -130,7 +165,6 @@ export const LicenseOrderHistory: React.FC<LicenseOrderHistoryProps> = (
{t(getTranslationID("orderHistoriesPage.label.status"))}
</th>
<th className={styles.noLine} />
<th className={styles.noLine} />
</tr>
{!isLoading &&
licenseOrderHistory.length !== 0 &&
@ -168,63 +202,68 @@ export const LicenseOrderHistory: React.FC<LicenseOrderHistoryProps> = (
})()}
</td>
<td>
{selectedRow === undefined &&
x.status === STATUS.ISSUE_REQESTING && (
<ul
className={`${styles.menuAction} ${styles.inTable}`}
>
<li>
<a
href=""
className={`${styles.menuLink} ${styles.isActive}`}
>
{t(
getTranslationID(
"orderHistoriesPage.label.orderCancel"
)
)}
</a>
</li>
</ul>
)}
{selectedRow !== undefined &&
x.status === STATUS.ISSUE_REQESTING && (
<ul
className={`${styles.menuAction} ${styles.inTable}`}
>
<li>
<a
href=""
className={`${styles.colorLink} ${styles.isActive}`}
>
{t(
getTranslationID(
"orderHistoriesPage.label.issue"
)
)}
</a>
</li>
</ul>
)}
{selectedRow !== undefined &&
x.status === STATUS.ISSUED && (
<ul
className={`${styles.menuAction} ${styles.inTable}`}
>
<li>
<a
href=""
className={`${styles.menuLink} ${styles.isActive}`}
>
{t(
getTranslationID(
"orderHistoriesPage.label.issueCancel"
)
)}
</a>
</li>
</ul>
)}
{!selectedRow && (
<ul
className={`${styles.menuAction} ${styles.inTable}`}
>
<li>
<a
className={`${styles.menuLink} ${
x.status === STATUS.ISSUE_REQESTING
? styles.isActive
: ""
}`}
>
{t(
getTranslationID(
"orderHistoriesPage.label.orderCancel"
)
)}
</a>
</li>
</ul>
)}
{selectedRow && (
<ul
className={`${styles.menuAction} ${styles.inTable}`}
>
<li>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<a
className={`${styles.colorLink} ${
x.status === STATUS.ISSUE_REQESTING
? styles.isActive
: ""
}`}
onClick={(event) => {
event.preventDefault();
issueLicense(x.poNumber);
}}
>
{t(
getTranslationID(
"orderHistoriesPage.label.issue"
)
)}
</a>
</li>
<li>
<a
className={`${styles.menuLink} ${
x.status === STATUS.ISSUED
? styles.isActive
: ""
}`}
>
{t(
getTranslationID(
"orderHistoriesPage.label.issueCancel"
)
)}
</a>
</li>
</ul>
)}
</td>
</tr>
))}

View File

@ -13,6 +13,7 @@ import {
selectAdminName,
selectEmail,
selectPassword,
selectSelectedDealer,
} from "../../features/signup/selectors";
import { signupAsync } from "../../features/signup/operations";
@ -25,13 +26,14 @@ const SignupConfirm: React.FC = (): JSX.Element => {
const adminName = useSelector(selectAdminName);
const adminMail = useSelector(selectEmail);
const adminPassword = useSelector(selectPassword);
const dealer = useSelector(selectSelectedDealer);
const onSubmit = useCallback(() => {
dispatch(
signupAsync({
companyName,
country,
dealerAccountId: 0,
dealerAccountId: dealer?.id ?? undefined,
adminName,
adminMail,
adminPassword,
@ -39,7 +41,15 @@ const SignupConfirm: React.FC = (): JSX.Element => {
token: "",
})
);
}, [dispatch, companyName, country, adminName, adminMail, adminPassword]);
}, [
dispatch,
companyName,
country,
dealer,
adminName,
adminMail,
adminPassword,
]);
return (
<main className={styles.main}>
@ -71,7 +81,9 @@ const SignupConfirm: React.FC = (): JSX.Element => {
<dt className={styles.marginBtm1}>
{t(getTranslationID("signupConfirmPage.label.dealer"))}
</dt>
<dd />
<dd>
<p className={styles.formConfirm}>{dealer?.name}&nbsp;</p>
</dd>
<dt className={styles.formTitle}>
{t(getTranslationID("signupConfirmPage.text.adminInfoTitle"))}

View File

@ -1,30 +1,38 @@
import { AppDispatch } from "app/store";
import {
selectAdminName,
selectAllDealers,
selectCompany,
selectCountry,
selectDealer,
selectEmail,
selectInputValidationErrors,
selectSameCountryDealers,
} from "features/signup/selectors";
import {
changeAdminName,
changeCompany,
changeCountry,
changeDealer,
changeEmail,
changePageState,
changePassword,
} from "features/signup/signupSlice";
import React, { useCallback, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { getTranslationID } from "translation";
import styles from "styles/app.module.scss";
import { getDealersAsync } from "features/signup/operations";
import { LANGUAGE_LIST } from "features/top/constants";
import { openSnackbar } from "features/ui";
import { COUNTRY_LIST } from "./constants";
const SignupInput: React.FC = (): JSX.Element => {
const dispatch: AppDispatch = useDispatch();
const [t] = useTranslation();
const [t, i18n] = useTranslation();
const { search } = useLocation();
const navigate = useNavigate();
const [isPasswordHide, setIsPasswordHide] = useState<boolean>(true);
const [isOpenPolicy, setIsOpenPolicy] = useState<boolean>(false);
@ -39,6 +47,7 @@ const SignupInput: React.FC = (): JSX.Element => {
hasErrorIncorrectEmail,
hasErrorIncorrectPassword,
} = useSelector(selectInputValidationErrors);
const onSubmit = useCallback(() => {
if (
hasErrorEmptyAdminName ||
@ -63,11 +72,65 @@ const SignupInput: React.FC = (): JSX.Element => {
hasErrorIncorrectPassword,
]);
const allDealers = useSelector(selectAllDealers);
const dealers = useSelector(selectSameCountryDealers);
const company = useSelector(selectCompany);
const country = useSelector(selectCountry);
const dealer = useSelector(selectDealer);
const adminName = useSelector(selectAdminName);
const email = useSelector(selectEmail);
// 入力画面の初期化時の処理
useEffect(() => {
dispatch(getDealersAsync());
}, [dispatch]);
useEffect(() => {
// 外部のWebサイトからの遷移時にURLのパラメータを取得
// 以下のようなURLで遷移してきた場合に、Dealerと言語を変更する
// https://xxx/signup?dealer=1&language=en
// dealer={account_id第四階層のアカウントID} でDealerを指定
// language={language(en/de/fr/es)} で言語を指定
const query = new URLSearchParams(search);
const paramDealer = query.get("dealer");
const language = query.get("language");
// URLで言語が指定されていたら言語を変更
if (language && LANGUAGE_LIST.map((x) => x.value).includes(language)) {
i18n.changeLanguage(language);
// 既にcookieに選択言語があれば削除
document.cookie = "language=; max-age=0";
// cookieの期限は1年
document.cookie = `language=${language}; max-age=31536000`;
}
// Dealerが取得できていない場合は何もしない
if (allDealers.length === 0) {
return;
}
// URLでDealerが指定されていたら、そのDealerを選択国も選択したDealerの国に変更
const urlDealer = allDealers.find(
(x) => x.id === parseInt(paramDealer ?? "", 10)
);
if (urlDealer) {
dispatch(changeCountry({ country: urlDealer.country }));
dispatch(changeDealer({ dealer: urlDealer.id }));
} else if (paramDealer) {
// URLでDealerが指定されていたが、存在しない場合はメッセージを表示
dispatch(
openSnackbar({
level: "error",
message: t(
getTranslationID("signupPage.message.dealerNotFoundError")
),
})
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [i18n, dispatch, search, allDealers]);
return (
<div className={styles.wrap}>
<header />
@ -145,9 +208,29 @@ const SignupInput: React.FC = (): JSX.Element => {
</dd>
<dt> {t(getTranslationID("signupPage.label.dealer"))}</dt>
<dd>
<select className={styles.formInput}>
<option>Select dealer</option>
<option value="Tokyo">Tokyo</option>
<select
className={styles.formInput}
onChange={(event) => {
dispatch(
changeDealer({
dealer: parseInt(event.target.value, 10),
})
);
}}
value={dealers.find((x) => x.id === dealer)?.id ?? NaN}
>
{[
{
id: NaN,
country: "",
name: "Select dealer",
},
...dealers,
].map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
<span className={styles.formComment}>
{t(getTranslationID("signupPage.text.dealerExplanation"))}

View File

@ -0,0 +1,118 @@
import React, { useEffect } from "react";
import Header from "components/header";
import Footer from "components/footer";
import styles from "styles/app.module.scss";
import { UpdateTokenTimer } from "components/auth/updateTokenTimer";
import progress_activit from "assets/images/progress_activit.svg";
import undo from "assets/images/undo.svg";
import group_add from "assets/images/group_add.svg";
import { useDispatch, useSelector } from "react-redux";
import {
selectTypistGroups,
selectIsLoading,
listTypistGroupsAsync,
} from "features/workflow/typistGroup";
import { AppDispatch } from "app/store";
import { useTranslation } from "react-i18next";
import { getTranslationID } from "translation";
const TypistGroupSettingPage: React.FC = (): JSX.Element => {
const dispatch: AppDispatch = useDispatch();
const [t] = useTranslation();
const isLoading = useSelector(selectIsLoading);
const typistGroup = useSelector(selectTypistGroups);
useEffect(() => {
dispatch(listTypistGroupsAsync());
}, [dispatch]);
return (
<div className={styles.wrap}>
<Header userName="XXXXXX" />
<UpdateTokenTimer />
<main className={styles.main}>
<div>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>
{t(getTranslationID("workflowPage.label.title"))}
</h1>
<p className={styles.pageTx}>{` ${t(
getTranslationID("typistGroupSetting.label.title")
)}`}</p>
</div>
<section className={styles.workflow}>
<div>
<ul className={styles.menuAction}>
<li>
<a
href="/workflow"
className={`${styles.menuLink} ${styles.isActive}`}
>
<img src={undo} alt="" className={styles.menuIcon} />
{t(getTranslationID("typistGroupSetting.label.return"))}
</a>
</li>
<li>
<a className={`${styles.menuLink} ${styles.isActive}`}>
<img src={group_add} alt="" className={styles.menuIcon} />
{t(getTranslationID("typistGroupSetting.label.addGroup"))}
</a>
</li>
</ul>
<table className={`${styles.table} ${styles.group}`}>
<tr className={styles.tableHeader}>
<th className={styles.noLine}>
{t(getTranslationID("typistGroupSetting.label.groupName"))}
</th>
<th>{/** empty th */}</th>
</tr>
{!isLoading && typistGroup.length === 0 ? (
<p style={{ margin: "10px", textAlign: "center" }}>
{t(getTranslationID("common.message.listEmpty"))}
</p>
) : (
typistGroup.map((group) => (
<tr key={group.id}>
<td>{group.name}</td>
<td>
<ul
className={`${styles.menuAction} ${styles.inTable}`}
>
<li>
<a
className={`${styles.menuLink} ${styles.isActive}`}
>
{t(
getTranslationID(
"typistGroupSetting.label.edit"
)
)}
</a>
</li>
</ul>
</td>
</tr>
))
)}
</table>
{isLoading && (
<img
src={progress_activit}
className={styles.icLoading}
alt="Loading"
/>
)}
</div>
</section>
</div>
</main>
<Footer />
</div>
);
};
export default TypistGroupSettingPage;

View File

@ -0,0 +1,281 @@
import { AppDispatch } from "app/store";
import React, { useCallback, useEffect, useState } from "react";
import styles from "styles/app.module.scss";
import { useDispatch, useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import {
selectLicenseAllocateUserId,
selectLicenseAllocateUserEmail,
selectLicenseAllocateUserName,
selectLicenseAllocateUserAuthorId,
selectLicenseAllocateUserStatus,
selectLicenseAllocateUserExpirationDate,
selectAllocatableLicenses,
getAllocatableLicensesAsync,
changeSelectedlicenseId,
selectSelectedlicenseId,
selectIsLoading,
listUsersAsync,
allocateLicenseAsync,
cleanupLicenseAllocateInfo,
selectInputValidationErrorsForLicenseAcclocation,
LICENSE_ALLOCATE_STATUS,
} from "features/user";
import { getTranslationID } from "translation";
import close from "../../assets/images/close.svg";
import progress_activit from "../../assets/images/progress_activit.svg";
interface AllocateLicensePopupProps {
isOpen: boolean;
onClose: () => void;
}
export const AllocateLicensePopup: React.FC<AllocateLicensePopupProps> = (
props
) => {
const { isOpen, onClose } = props;
const dispatch: AppDispatch = useDispatch();
const { t } = useTranslation();
const { hasErrorEmptyLicense } = useSelector(
selectInputValidationErrorsForLicenseAcclocation
);
const id = useSelector(selectLicenseAllocateUserId);
const email = useSelector(selectLicenseAllocateUserEmail);
const name = useSelector(selectLicenseAllocateUserName);
const authorId = useSelector(selectLicenseAllocateUserAuthorId);
const status = useSelector(selectLicenseAllocateUserStatus);
const expirationDate = useSelector(selectLicenseAllocateUserExpirationDate);
const allocatableLicenses = useSelector(selectAllocatableLicenses);
const selectedlicenseId = useSelector(selectSelectedlicenseId);
const [isPushAllocateButton, setIsPushAllocateButton] =
useState<boolean>(false);
const isLoading = useSelector(selectIsLoading);
useEffect(() => {
if (isOpen) {
// 画面起動時、有効なライセンスを取得する
dispatch(getAllocatableLicensesAsync());
}
}, [isOpen, dispatch]);
// ポップアップを閉じる処理
const closePopup = useCallback(() => {
if (isLoading) {
return;
}
setIsPushAllocateButton(false);
dispatch(cleanupLicenseAllocateInfo());
onClose();
}, [isLoading, onClose, dispatch]);
const onAllocateLicense = useCallback(async () => {
setIsPushAllocateButton(true);
// 入力不正時は何もしない
if (hasErrorEmptyLicense) {
return;
}
const { meta } = await dispatch(
allocateLicenseAsync({ userId: id, newLicenseId: selectedlicenseId })
);
setIsPushAllocateButton(false);
if (meta.requestStatus === "fulfilled") {
closePopup();
dispatch(listUsersAsync());
}
}, [dispatch, closePopup, id, selectedlicenseId, hasErrorEmptyLicense]);
return (
<div className={`${styles.modal} ${isOpen ? styles.isShow : ""}`}>
<div className={styles.modalBox}>
<p className={styles.modalTitle}>
{t(getTranslationID("allocateLicensePopupPage.label.title"))}
<button type="button" onClick={closePopup}>
<img src={close} className={styles.modalTitleIcon} alt="close" />
</button>
</p>
<form action="" name="" method="" className={styles.form}>
<dl className={`${styles.formList} ${styles.hasbg}`}>
<dt className={styles.formTitle}>
{t(
getTranslationID(
"allocateLicensePopupPage.label.personalInformation"
)
)}
</dt>
<dt>
{t(getTranslationID("allocateLicensePopupPage.label.email"))}
</dt>
<dd>
<input
type="text"
size={40}
name=""
value={email}
className={styles.formInput}
readOnly
/>
</dd>
<dt>
{t(getTranslationID("allocateLicensePopupPage.label.name"))}
</dt>
<dd>
<input
type="text"
size={40}
name=""
value={name}
className={styles.formInput}
readOnly
/>
</dd>
<dt>
{t(getTranslationID("allocateLicensePopupPage.label.authorID"))}
</dt>
<dd>
<input
type="text"
size={40}
name=""
value={authorId}
className={styles.formInput}
readOnly
/>
</dd>
<dt>
{t(getTranslationID("allocateLicensePopupPage.label.status"))}
</dt>
<dd>
<input
type="text"
size={40}
name=""
value={(() => {
switch (status) {
case LICENSE_ALLOCATE_STATUS.ALLOCATED:
return t(
getTranslationID(
"allocateLicensePopupPage.label.allocated"
)
);
case LICENSE_ALLOCATE_STATUS.NOTALLOCATED:
return t(
getTranslationID(
"allocateLicensePopupPage.label.notAllocated"
)
);
default:
return status;
}
})()}
className={styles.formInput}
readOnly
/>
</dd>
<dt>
{t(
getTranslationID(
"allocateLicensePopupPage.label.expirationDate"
)
)}
</dt>
<dd>
<input
type="text"
size={40}
name=""
value={expirationDate}
className={styles.formInput}
readOnly
/>
</dd>
<dt className={styles.formTitle}>
{t(
getTranslationID(
"allocateLicensePopupPage.label.licenseInformation"
)
)}
</dt>
<dt>
{t(
getTranslationID(
"allocateLicensePopupPage.label.licenseAvailable"
)
)}
</dt>
<dd>
<select
name=""
className={`${styles.formInput} ${
isPushAllocateButton && hasErrorEmptyLicense && styles.isError
}`}
onChange={(event) => {
dispatch(
changeSelectedlicenseId({
selectedlicenseId: parseInt(event.target.value, 10),
})
);
}}
value={selectedlicenseId ?? ""}
>
<option value="">
{t(
getTranslationID(
"allocateLicensePopupPage.label.dropDownHeading"
)
)}
</option>
{allocatableLicenses.map((x) => (
<option key={x.licenseId} value={x.licenseId}>
{/* one yearの場合はそれに相当する内容、それ以外の場合は値をそのまま表示 */}
{x.expiryDate
? x.expiryDate
: t(
getTranslationID(
"allocateLicensePopupPage.label.oneYear"
)
)}
</option>
))}
</select>
{isPushAllocateButton && hasErrorEmptyLicense && (
<span className={styles.formError}>
{t(
getTranslationID(
"allocateLicensePopupPage.message.inputEmptyError"
)
)}
</span>
)}
</dd>
<dd className={`${styles.full} ${styles.alignCenter}`}>
<input
type="button"
name="submit"
value={t(
getTranslationID(
"allocateLicensePopupPage.label.allocateLicense"
)
)}
className={`${styles.formSubmit} ${styles.marginBtm1} ${styles.isActive}`}
onClick={onAllocateLicense}
/>
<img
style={{ display: isLoading ? "inline" : "none" }}
src={progress_activit}
className={styles.icLoading}
alt="Loading"
/>
</dd>
</dl>
</form>
</div>
</div>
);
};

View File

@ -12,17 +12,21 @@ import {
} from "features/user";
import { useTranslation } from "react-i18next";
import { getTranslationID } from "translation";
import { isLicenseStatusType } from "features/user/types";
import { isLicenseStatusType, UserView } from "features/user/types";
import { LICENSE_STATUS } from "features/user/constants";
import { isApproveTier } from "features/auth/utils";
import { TIERS } from "components/auth/constants";
import { changeUpdateUser } from "features/user/userSlice";
import {
changeUpdateUser,
changeLicenseAllocateUser,
} from "features/user/userSlice";
import personAdd from "../../assets/images/person_add.svg";
import checkFill from "../../assets/images/check_fill.svg";
import checkOutline from "../../assets/images/check_outline.svg";
import progress_activit from "../../assets/images/progress_activit.svg";
import { UserAddPopup } from "./popup";
import { UserUpdatePopup } from "./updatePopup";
import { AllocateLicensePopup } from "./allocateLicensePopup";
const UserListPage: React.FC = (): JSX.Element => {
const dispatch: AppDispatch = useDispatch();
@ -30,6 +34,8 @@ const UserListPage: React.FC = (): JSX.Element => {
const [isPopupOpen, setIsPopupOpen] = useState(false);
const [isUpdatePopupOpen, setIsUpdatePopupOpen] = useState(false);
const [isAllocateLicensePopupOpen, setIsAllocateLicensePopupOpen] =
useState(false);
const onOpen = useCallback(() => {
setIsPopupOpen(true);
@ -43,6 +49,14 @@ const UserListPage: React.FC = (): JSX.Element => {
[setIsUpdatePopupOpen, dispatch]
);
const onAllocateLicensePopupOpen = useCallback(
(selectedUser: UserView) => {
setIsAllocateLicensePopupOpen(true);
dispatch(changeLicenseAllocateUser({ selectedUser }));
},
[setIsAllocateLicensePopupOpen, dispatch]
);
useEffect(() => {
// ユーザ一覧取得処理を呼び出す
dispatch(listUsersAsync());
@ -59,14 +73,18 @@ const UserListPage: React.FC = (): JSX.Element => {
isOpen={isUpdatePopupOpen}
onClose={() => {
setIsUpdatePopupOpen(false);
dispatch(listUsersAsync());
}}
/>
<UserAddPopup
isOpen={isPopupOpen}
onClose={() => {
setIsPopupOpen(false);
dispatch(listUsersAsync());
}}
/>
<AllocateLicensePopup
isOpen={isAllocateLicensePopupOpen}
onClose={() => {
setIsAllocateLicensePopupOpen(false);
}}
/>
<div className={styles.wrap}>
@ -164,7 +182,12 @@ const UserListPage: React.FC = (): JSX.Element => {
{isTier5 && (
<>
<li>
<a href="">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a
onClick={() => {
onAllocateLicensePopupOpen(user);
}}
>
{t(
getTranslationID(
"userListPage.label.licenseAllocation"

View File

@ -20,6 +20,7 @@ import {
changePrompt,
changeEncryption,
changeEncryptionPassword,
listUsersAsync,
} from "features/user";
import { USER_ROLES } from "components/auth/constants";
import close from "../../assets/images/close.svg";
@ -75,6 +76,7 @@ export const UserAddPopup: React.FC<UserAddPopupProps> = (props) => {
if (meta.requestStatus === "fulfilled") {
closePopup();
dispatch(listUsersAsync());
}
}, [
hasErrorEmptyName,
@ -146,9 +148,9 @@ export const UserAddPopup: React.FC<UserAddPopupProps> = (props) => {
</dd>
<dt>{t(getTranslationID("userListPage.label.role"))}</dt>
<dd>
<label htmlFor={USER_ROLES.AUTHOR}>
<label htmlFor={`add_${USER_ROLES.AUTHOR}`}>
<input
id={USER_ROLES.AUTHOR}
id={`add_${USER_ROLES.AUTHOR}`}
type="radio"
name="role"
className={styles.formRadio}
@ -159,9 +161,9 @@ export const UserAddPopup: React.FC<UserAddPopupProps> = (props) => {
/>
{t(getTranslationID("userListPage.label.author"))}
</label>
<label htmlFor={USER_ROLES.TYPIST}>
<label htmlFor={`add_${USER_ROLES.TYPIST}`}>
<input
id={USER_ROLES.TYPIST}
id={`add_${USER_ROLES.TYPIST}`}
type="radio"
name="role"
className={styles.formRadio}
@ -172,9 +174,9 @@ export const UserAddPopup: React.FC<UserAddPopupProps> = (props) => {
/>
{t(getTranslationID("userListPage.label.transcriptionist"))}
</label>
<label htmlFor={USER_ROLES.NONE}>
<label htmlFor={`add_${USER_ROLES.NONE}`}>
<input
id={USER_ROLES.NONE}
id={`add_${USER_ROLES.NONE}`}
type="radio"
name="role"
className={styles.formRadio}
@ -303,9 +305,10 @@ export const UserAddPopup: React.FC<UserAddPopupProps> = (props) => {
<dt>{t(getTranslationID("userListPage.label.setting"))}</dt>
<dd className={styles.last}>
<p>
<label htmlFor="Auto renew">
<label htmlFor="add_AutoRenew">
<input
type="checkbox"
id="add_AutoRenew"
className={styles.formCheck}
checked={addUser.autoRenew}
onChange={(e) => {
@ -318,9 +321,10 @@ export const UserAddPopup: React.FC<UserAddPopupProps> = (props) => {
</label>
</p>
<p>
<label htmlFor="License Alert">
<label htmlFor="add_LicenseAlert">
<input
type="checkbox"
id="add_LicenseAlert"
checked={addUser.licenseAlert}
className={styles.formCheck}
onChange={(e) => {
@ -333,9 +337,10 @@ export const UserAddPopup: React.FC<UserAddPopupProps> = (props) => {
</label>
</p>
<p>
<label htmlFor="Notification">
<label htmlFor="add_Notification">
<input
type="checkbox"
id="add_Notification"
checked={addUser.notification}
className={styles.formCheck}
onChange={(e) => {

View File

@ -20,6 +20,7 @@ import {
selectUpdateUser,
selectUpdateValidationErrors,
updateUserAsync,
listUsersAsync,
} from "features/user";
import { getTranslationID } from "translation";
import close from "../../assets/images/close.svg";
@ -79,6 +80,7 @@ export const UserUpdatePopup: React.FC<UserUpdatePopupProps> = (props) => {
if (meta.requestStatus === "fulfilled") {
closePopup();
dispatch(listUsersAsync());
}
}, [
dispatch,
@ -124,9 +126,9 @@ export const UserUpdatePopup: React.FC<UserUpdatePopupProps> = (props) => {
</dd>
<dt>{t(getTranslationID("userListPage.label.role"))}</dt>
<dd>
<label htmlFor={USER_ROLES.AUTHOR}>
<label htmlFor={`edit_${USER_ROLES.AUTHOR}`}>
<input
id={USER_ROLES.AUTHOR}
id={`edit_${USER_ROLES.AUTHOR}`}
type="radio"
name="role"
className={styles.formRadio}
@ -138,9 +140,9 @@ export const UserUpdatePopup: React.FC<UserUpdatePopupProps> = (props) => {
/>
{t(getTranslationID("userListPage.label.author"))}
</label>
<label htmlFor={USER_ROLES.TYPIST}>
<label htmlFor={`edit_${USER_ROLES.TYPIST}`}>
<input
id={USER_ROLES.TYPIST}
id={`edit_${USER_ROLES.TYPIST}`}
type="radio"
name="role"
className={styles.formRadio}
@ -152,9 +154,9 @@ export const UserUpdatePopup: React.FC<UserUpdatePopupProps> = (props) => {
/>
{t(getTranslationID("userListPage.label.transcriptionist"))}
</label>
<label htmlFor={USER_ROLES.NONE}>
<label htmlFor={`edit_${USER_ROLES.NONE}`}>
<input
id={USER_ROLES.NONE}
id={`edit_${USER_ROLES.NONE}`}
type="radio"
name="role"
className={styles.formRadio}
@ -315,9 +317,10 @@ export const UserUpdatePopup: React.FC<UserUpdatePopupProps> = (props) => {
<dt>{t(getTranslationID("userListPage.label.setting"))}</dt>
<dd className="last">
<p>
<label htmlFor="Auto renew">
<label htmlFor="edit_AutoRenew">
<input
type="checkbox"
id="edit_AutoRenew"
className={styles.formCheck}
checked={user.autoRenew}
onChange={(e) => {
@ -332,9 +335,10 @@ export const UserUpdatePopup: React.FC<UserUpdatePopupProps> = (props) => {
</label>
</p>
<p>
<label htmlFor="License Alert">
<label htmlFor="edit_LicenseAlert">
<input
type="checkbox"
id="edit_LicenseAlert"
className={styles.formCheck}
checked={user.licenseAlert}
onChange={(e) => {
@ -349,9 +353,10 @@ export const UserUpdatePopup: React.FC<UserUpdatePopupProps> = (props) => {
</label>
</p>
<p>
<label htmlFor="Notification">
<label htmlFor="edit_Notification">
<input
type="checkbox"
id="edit_Notification"
className={styles.formCheck}
checked={user.notification}
onChange={(e) => {

View File

@ -0,0 +1,24 @@
import React from "react";
import Header from "components/header";
import Footer from "components/footer";
import styles from "styles/app.module.scss";
import { UpdateTokenTimer } from "components/auth/updateTokenTimer";
const WorkflowPage: React.FC = (): JSX.Element => (
<div className={styles.wrap}>
<Header userName="XXXXXX" />
<UpdateTokenTimer />
<main className={styles.main}>
<div className="">
<span>
<a style={{ margin: 20 }} href="/workflow/typist-group">
Transcriptionist Group Setting
</a>
</span>
</div>
</main>
<Footer />
</div>
);
export default WorkflowPage;

View File

@ -935,13 +935,75 @@ h3 + .brCrumb .tlIcon {
.modal .formInput {
padding: 0.5rem 0.8rem;
}
.modal .formInput[type="file"] {
border: none;
background: inherit;
}
.modal .formInput[type="file"].isHide {
display: none;
}
.modal .form label {
padding: 0.5rem 0 0.2rem;
}
.modal .form label.formFileButton {
padding: 0.5rem 0.8rem;
border: 1px #999999 solid;
background: #ffffff;
font-size: 16px;
line-height: 1.4;
letter-spacing: 0.01rem;
font-weight: normal;
cursor: pointer;
border-radius: 0.3rem;
position: relative;
-moz-transition: all 0.3s ease-out;
-ms-transition: all 0.3s ease-out;
-webkit-transition: all 0.3s ease-out;
transition: all 0.3s ease-out;
}
.modal .form label.formFileButton:hover {
background: #f0f0f0;
}
.modal .formFileName {
display: inline-block;
width: 350px;
padding: 0.6rem 0.8rem;
border: 1px #e6e6e6 solid;
background: #e6e6e6;
box-sizing: border-box;
font-size: 16px;
line-height: 1.4;
letter-spacing: 0;
font-weight: normal;
text-align: left;
}
.modal .form .icLoading {
bottom: 1.5rem;
left: calc(50% - 25px);
}
.modal .form .tableWrap {
max-height: 60vh;
overflow-y: scroll;
margin-bottom: 1rem;
}
.modal .form .table {
width: 95%;
margin: 0 auto;
}
.modal .form .table .tableHeader th {
position: -webkit-sticky;
position: sticky;
top: 0;
padding: 0.4rem 0.5rem;
background: #282828;
}
.modal .form .table td {
padding: 0.6rem 0.4rem;
}
.modal .form .table .formInput {
width: inherit;
padding: 0.2rem 0.5rem;
}
.modal .encryptionPass {
display: none;
}
@ -2199,12 +2261,38 @@ tr.isSelected .menuInTable li a {
.workflow .table.workflow td.txWsline {
white-space: pre;
}
.workflow .table.group {
.workflow .table.group,
.workflow .table.template {
width: 600px;
}
.workflow .table.group td:last-child {
.workflow .table.group td:last-child,
.workflow .table.template td:last-child {
text-align: right;
}
.workflow .table.worktype {
width: 1000px;
}
.workflow .table.worktype td:last-child {
text-align: right;
}
.workflow .table .menuLink {
min-width: 3rem;
text-align: center;
}
.workflow .menuAction.worktype {
width: 1000px;
}
.workflow .menuAction.worktype .selectMenu {
padding-top: 0.5rem;
float: right;
font-size: 0.9rem;
}
.workflow .menuAction.worktype .formInput {
width: inherit;
margin-left: 0.5rem;
padding: 0.2rem 0.8rem;
font-size: 0.9rem;
}
.formList dd.formChange {
display: flex;

View File

@ -60,6 +60,12 @@ declare const classNames: {
readonly modalTitleIcon: "modalTitleIcon";
readonly last: "last";
readonly slideSet: "slideSet";
readonly isHide: "isHide";
readonly formFileButton: "formFileButton";
readonly formFileName: "formFileName";
readonly tableWrap: "tableWrap";
readonly table: "table";
readonly tableHeader: "tableHeader";
readonly encryptionPass: "encryptionPass";
readonly pageHeader: "pageHeader";
readonly pageTitle: "pageTitle";
@ -75,8 +81,6 @@ declare const classNames: {
readonly snackbarMessage: "snackbarMessage";
readonly snackbarIcon: "snackbarIcon";
readonly snackbarIconClose: "snackbarIconClose";
readonly table: "table";
readonly tableHeader: "tableHeader";
readonly hasSort: "hasSort";
readonly isActiveAz: "isActiveAz";
readonly isActiveZa: "isActiveZa";
@ -100,8 +104,8 @@ declare const classNames: {
readonly menuAction: "menuAction";
readonly inTable: "inTable";
readonly menuLink: "menuLink";
readonly colorLink: "colorLink";
readonly menuIcon: "menuIcon";
readonly colorLink: "colorLink";
readonly isDisable: "isDisable";
readonly icCheckCircle: "icCheckCircle";
readonly icInTable: "icInTable";
@ -112,7 +116,6 @@ declare const classNames: {
readonly displayOptions: "displayOptions";
readonly tableFilter: "tableFilter";
readonly tableFilter2: "tableFilter2";
readonly tableWrap: "tableWrap";
readonly txWsline: "txWsline";
readonly hidePri: "hidePri";
readonly opPri: "opPri";
@ -178,6 +181,9 @@ declare const classNames: {
readonly changeTitle: "changeTitle";
readonly role4: "role4";
readonly group: "group";
readonly template: "template";
readonly worktype: "worktype";
readonly selectMenu: "selectMenu";
readonly alignCenter: "alignCenter";
readonly alignLeft: "alignLeft";
readonly alignRight: "alignRight";
@ -203,6 +209,5 @@ declare const classNames: {
readonly txNormal: "txNormal";
readonly txIcon: "txIcon";
readonly txWswrap: "txWswrap";
readonly widthMid: "widthMid";
};
export = classNames;

View File

@ -41,7 +41,8 @@
"message": {
"inputEmptyError": "(de)この項目の入力は必須です。入力してください。",
"passwordIncorrectError": "(de)入力されたパスワードがルールを満たしていません。下記のルールを満たすパスワードを入力してください。",
"emailIncorrectError": "(de)メールアドレスの形式が不正です。正しいメールアドレスの形式で入力してください。"
"emailIncorrectError": "(de)メールアドレスの形式が不正です。正しいメールアドレスの形式で入力してください。",
"dealerNotFoundError": "(de)指定されたディーラーが見つかりませんでした。直接ディーラーを指定してください。"
},
"text": {
"title": "(de)Create your account",
@ -324,6 +325,46 @@
"issueCancel": "(de)Issue Cancel",
"orderCancel": "(de)Order Cancel",
"histories": "(de)histories"
},
"message": {
"notEnoughOfNumberOfLicense": "(de)ライセンスが不足しているため、発行することができませんでした。ライセンスの注文を行ってください。",
"alreadyIssueLicense": "(de)すでに発行済みの注文です。画面を更新してください。"
}
},
"allocateLicensePopupPage": {
"label": {
"title": "(de)License Allocation",
"personalInformation": "(de)Personal Information",
"email": "(de)Email",
"name": "(de)Name",
"authorID": "(de)Author ID",
"status": "(de)Status",
"allocated": "(de)Allocated",
"notAllocated": "(de)Not Allocated",
"expirationDate": "(de)Expiration date",
"licenseInformation": "(de)License Information",
"licenseAvailable": "(de)License available",
"dropDownHeading": "(de)Select a license.",
"oneYear": "(de)One Year",
"allocateLicense": "(de)OK"
},
"message": {
"inputEmptyError": "(de)この項目の入力は必須です。入力してください。",
"licenseAllocationFailure": "(de)ライセンスの割り当てに失敗しました。他のライセンスを選択して再度割り当てをしてください。"
}
},
"workflowPage": {
"label": {
"title": "(de)Workflow"
}
},
"typistGroupSetting": {
"label": {
"title": "(de)Transctiprionist Group",
"return": "(de)Return",
"addGroup": "(de)Add Group",
"groupName": "(de)Group Name",
"edit": "(de)Edit"
}
}
}
}

View File

@ -41,7 +41,8 @@
"message": {
"inputEmptyError": "この項目の入力は必須です。入力してください。",
"passwordIncorrectError": "入力されたパスワードがルールを満たしていません。下記のルールを満たすパスワードを入力してください。",
"emailIncorrectError": "メールアドレスの形式が不正です。正しいメールアドレスの形式で入力してください。"
"emailIncorrectError": "メールアドレスの形式が不正です。正しいメールアドレスの形式で入力してください。",
"dealerNotFoundError": "指定されたディーラーが見つかりませんでした。直接ディーラーを指定してください。"
},
"text": {
"title": "Create your account",
@ -324,6 +325,46 @@
"issueCancel": "Issue Cancel",
"orderCancel": "Order Cancel",
"histories": "histories"
},
"message": {
"notEnoughOfNumberOfLicense": "ライセンスが不足しているため、発行することができませんでした。ライセンスの注文を行ってください。",
"alreadyIssueLicense": "すでに発行済みの注文です。画面を更新してください。"
}
},
"allocateLicensePopupPage": {
"label": {
"title": "License Allocation",
"personalInformation": "Personal Information",
"email": "Email",
"name": "Name",
"authorID": "Author ID",
"status": "Status",
"allocated": "Allocated",
"notAllocated": "Not Allocated",
"expirationDate": "Expiration date",
"licenseInformation": "License Information",
"licenseAvailable": "License available",
"dropDownHeading": "Select a license.",
"oneYear": "One Year",
"allocateLicense": "OK"
},
"message": {
"inputEmptyError": "この項目の入力は必須です。入力してください。",
"licenseAllocationFailure": "ライセンスの割り当てに失敗しました。他のライセンスを選択して再度割り当てをしてください。"
}
},
"workflowPage": {
"label": {
"title": "Workflow"
}
},
"typistGroupSetting": {
"label": {
"title": "Transctiprionist Group",
"return": "Return",
"addGroup": "Add Group",
"groupName": "Group Name",
"edit": "Edit"
}
}
}
}

View File

@ -41,7 +41,8 @@
"message": {
"inputEmptyError": "(es)この項目の入力は必須です。入力してください。",
"passwordIncorrectError": "(es)入力されたパスワードがルールを満たしていません。下記のルールを満たすパスワードを入力してください。",
"emailIncorrectError": "(es)メールアドレスの形式が不正です。正しいメールアドレスの形式で入力してください。"
"emailIncorrectError": "(es)メールアドレスの形式が不正です。正しいメールアドレスの形式で入力してください。",
"dealerNotFoundError": "(es)指定されたディーラーが見つかりませんでした。直接ディーラーを指定してください。"
},
"text": {
"title": "(es)Create your account",
@ -324,6 +325,46 @@
"issueCancel": "(es)Issue Cancel",
"orderCancel": "(es)Order Cancel",
"histories": "(es)histories"
},
"message": {
"notEnoughOfNumberOfLicense": "(es)ライセンスが不足しているため、発行することができませんでした。ライセンスの注文を行ってください。",
"alreadyIssueLicense": "(es)すでに発行済みの注文です。画面を更新してください。"
}
},
"allocateLicensePopupPage": {
"label": {
"title": "(es)License Allocation",
"personalInformation": "(es)Personal Information",
"email": "(es)Email",
"name": "(es)Name",
"authorID": "(es)Author ID",
"status": "(es)Status",
"allocated": "(es)Allocated",
"notAllocated": "(es)Not Allocated",
"expirationDate": "(es)Expiration date",
"licenseInformation": "(es)License Information",
"licenseAvailable": "(es)License available",
"dropDownHeading": "(es)Select a license.",
"oneYear": "(es)One Year",
"allocateLicense": "(es)OK"
},
"message": {
"inputEmptyError": "(es)この項目の入力は必須です。入力してください。",
"licenseAllocationFailure": "(es)ライセンスの割り当てに失敗しました。他のライセンスを選択して再度割り当てをしてください。"
}
},
"workflowPage": {
"label": {
"title": "(es)Workflow"
}
},
"typistGroupSetting": {
"label": {
"title": "(es)Transctiprionist Group",
"return": "(es)Return",
"addGroup": "(es)Add Group",
"groupName": "(es)Group Name",
"edit": "(es)Edit"
}
}
}
}

View File

@ -41,7 +41,8 @@
"message": {
"inputEmptyError": "(fr)この項目の入力は必須です。入力してください。",
"passwordIncorrectError": "(fr)入力されたパスワードがルールを満たしていません。下記のルールを満たすパスワードを入力してください。",
"emailIncorrectError": "(fr)メールアドレスの形式が不正です。正しいメールアドレスの形式で入力してください。"
"emailIncorrectError": "(fr)メールアドレスの形式が不正です。正しいメールアドレスの形式で入力してください。",
"dealerNotFoundError": "(fr)指定されたディーラーが見つかりませんでした。直接ディーラーを指定してください。"
},
"text": {
"title": "(fr)Create your account",
@ -324,6 +325,46 @@
"issueCancel": "(fr)Issue Cancel",
"orderCancel": "(fr)Order Cancel",
"histories": "(fr)histories"
},
"message": {
"notEnoughOfNumberOfLicense": "(fr)ライセンスが不足しているため、発行することができませんでした。ライセンスの注文を行ってください。",
"alreadyIssueLicense": "(fr)すでに発行済みの注文です。画面を更新してください。"
}
},
"allocateLicensePopupPage": {
"label": {
"title": "(fr)License Allocation",
"personalInformation": "(fr)Personal Information",
"email": "(fr)Email",
"name": "(fr)Name",
"authorID": "(fr)Author ID",
"status": "(fr)Status",
"allocated": "(fr)Allocated",
"notAllocated": "(fr)Not Allocated",
"expirationDate": "(fr)Expiration date",
"licenseInformation": "(fr)License Information",
"licenseAvailable": "(fr)License available",
"dropDownHeading": "(fr)Select a license.",
"oneYear": "(fr)One Year",
"allocateLicense": "(fr)OK"
},
"message": {
"inputEmptyError": "(fr)この項目の入力は必須です。入力してください。",
"licenseAllocationFailure": "(fr)ライセンスの割り当てに失敗しました。他のライセンスを選択して再度割り当てをしてください。"
}
},
"workflowPage": {
"label": {
"title": "(fr)Workflow"
}
},
"typistGroupSetting": {
"label": {
"title": "(fr)Transctiprionist Group",
"return": "(fr)Return",
"addGroup": "(fr)Add Group",
"groupName": "(fr)Group Name",
"edit": "(fr)Edit"
}
}
}
}

View File

@ -0,0 +1,16 @@
-- +migrate Up
CREATE TABLE IF NOT EXISTS `lisence_allocation_history` (
`user_id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY COMMENT 'ユーザーID',
`license_id` BIGINT UNSIGNED NOT NULL COMMENT 'ライセンスID',
`allocate_type` VARCHAR(255) NOT NULL COMMENT '割り当て種別(割当解除/割当)',
`executed_at` TIMESTAMP NOT NULL COMMENT '実施日時',
`switch_from_type` VARCHAR(255) NOT NULL COMMENT '切り替え元種別(特になし/カード/トライアル)',
`deleted_at` TIMESTAMP COMMENT '削除時刻',
`created_by` VARCHAR(255) COMMENT '作成者',
`created_at` TIMESTAMP DEFAULT now() COMMENT '作成時刻',
`updated_by` VARCHAR(255) COMMENT '更新者',
`updated_at` TIMESTAMP DEFAULT now() COMMENT '更新時刻'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;
-- +migrate Down
DROP TABLE `lisence_allocation_history`;

View File

@ -0,0 +1,19 @@
-- +migrate Up
DROP TABLE IF EXISTS `lisence_allocation_history`;
CREATE TABLE IF NOT EXISTS `license_allocation_history` (
`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY COMMENT '割り当て履歴ID',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT 'ユーザーID',
`license_id` BIGINT UNSIGNED NOT NULL COMMENT 'ライセンスID',
`is_allocated` BOOLEAN NOT NULL DEFAULT 0 COMMENT '割り当て済みか',
`executed_at` TIMESTAMP NOT NULL COMMENT '実施日時',
`switch_from_type` VARCHAR(255) NOT NULL COMMENT '切り替え元種別(特になし/カード/トライアル)',
`deleted_at` TIMESTAMP COMMENT '削除時刻',
`created_by` VARCHAR(255) COMMENT '作成者',
`created_at` TIMESTAMP DEFAULT now() COMMENT '作成時刻',
`updated_by` VARCHAR(255) COMMENT '更新者',
`updated_at` TIMESTAMP DEFAULT now() COMMENT '更新時刻'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;
-- +migrate Down
DROP TABLE `license_allocation_history`;

View File

@ -298,6 +298,60 @@
},
"tags": ["accounts"],
"security": [{ "bearer": [] }]
},
"post": {
"operationId": "createTypistGroup",
"summary": "",
"description": "ログインしているユーザーのアカウント配下にタイピストグループを追加します",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateTypistGroupRequest"
}
}
}
},
"responses": {
"200": {
"description": "成功時のレスポンス",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateTypistGroupResponse"
}
}
}
},
"400": {
"description": "グループ名が空の場合/ユーザーが存在しない場合",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"401": {
"description": "認証エラー",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"500": {
"description": "想定外のサーバーエラー",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
}
},
"tags": ["accounts"],
"security": [{ "bearer": [] }]
}
},
"/accounts/partner": {
@ -882,6 +936,118 @@
"security": [{ "bearer": [] }]
}
},
"/users/license/allocate": {
"post": {
"operationId": "allocateLicense",
"summary": "",
"description": "ライセンスを割り当てます",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AllocateLicenseRequest"
}
}
}
},
"responses": {
"200": {
"description": "成功時のレスポンス",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AllocateLicenseResponse"
}
}
}
},
"400": {
"description": "割り当て失敗時",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"401": {
"description": "認証エラー",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"500": {
"description": "想定外のサーバーエラー",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
}
},
"tags": ["users"],
"security": [{ "bearer": [] }]
}
},
"/users/license/deallocate": {
"post": {
"operationId": "deallocateLicense",
"summary": "",
"description": "ライセンス割り当てを解除します",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeallocateLicenseRequest"
}
}
}
},
"responses": {
"200": {
"description": "成功時のレスポンス",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeallocateLicenseResponse"
}
}
}
},
"400": {
"description": "すでにライセンスが割り当て解除されている時",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"401": {
"description": "認証エラー",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"500": {
"description": "想定外のサーバーエラー",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
}
},
"tags": ["users"],
"security": [{ "bearer": [] }]
}
},
"/files/audio/upload-finished": {
"post": {
"operationId": "uploadFinished",
@ -1816,6 +1982,44 @@
"security": [{ "bearer": [] }]
}
},
"/licenses/allocatable": {
"get": {
"operationId": "getAllocatableLicenses",
"summary": "",
"description": "割り当て可能なライセンスを取得します",
"parameters": [],
"responses": {
"200": {
"description": "成功時のレスポンス",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetAllocatableLicensesResponse"
}
}
}
},
"401": {
"description": "認証エラー",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"500": {
"description": "想定外のサーバーエラー",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
}
},
"tags": ["licenses"],
"security": [{ "bearer": [] }]
}
},
"/notification/register": {
"post": {
"operationId": "register",
@ -1923,7 +2127,7 @@
"minLength": 2,
"maxLength": 2
},
"dealerAccountId": { "type": "number", "nullable": true },
"dealerAccountId": { "type": "number" },
"adminName": { "type": "string" },
"adminMail": { "type": "string" },
"adminPassword": { "type": "string" },
@ -1936,7 +2140,6 @@
"required": [
"companyName",
"country",
"dealerAccountId",
"adminName",
"adminMail",
"adminPassword",
@ -2028,6 +2231,23 @@
},
"required": ["typistGroups"]
},
"CreateTypistGroupRequest": {
"type": "object",
"properties": {
"typistGroupName": {
"type": "string",
"minLength": 1,
"maxLength": 50
},
"typistIds": {
"minItems": 1,
"type": "array",
"items": { "type": "string" }
}
},
"required": ["typistGroupName", "typistIds"]
},
"CreateTypistGroupResponse": { "type": "object", "properties": {} },
"CreatePartnerAccountRequest": {
"type": "object",
"properties": {
@ -2373,6 +2593,26 @@
"required": ["id", "role", "autoRenew", "licenseAlart", "notification"]
},
"PostUpdateUserResponse": { "type": "object", "properties": {} },
"AllocateLicenseRequest": {
"type": "object",
"properties": {
"userId": { "type": "number", "description": "ユーザーID" },
"newLicenseId": {
"type": "number",
"description": "割り当てるライセンスのID"
}
},
"required": ["userId", "newLicenseId"]
},
"AllocateLicenseResponse": { "type": "object", "properties": {} },
"DeallocateLicenseRequest": {
"type": "object",
"properties": {
"userId": { "type": "number", "description": "ユーザーID" }
},
"required": ["userId"]
},
"DeallocateLicenseResponse": { "type": "object", "properties": {} },
"AudioOptionItem": {
"type": "object",
"properties": {
@ -2671,6 +2911,24 @@
"required": ["cardLicenseKey"]
},
"ActivateCardLicensesResponse": { "type": "object", "properties": {} },
"AllocatableLicenseInfo": {
"type": "object",
"properties": {
"licenseId": { "type": "number" },
"expiryDate": { "format": "date-time", "type": "string" }
},
"required": ["licenseId", "expiryDate"]
},
"GetAllocatableLicensesResponse": {
"type": "object",
"properties": {
"allocatableLicenses": {
"type": "array",
"items": { "$ref": "#/components/schemas/AllocatableLicenseInfo" }
}
},
"required": ["allocatableLicenses"]
},
"RegisterRequest": {
"type": "object",
"properties": {

View File

@ -42,4 +42,8 @@ export const ErrorCodes = [
'E010701', // Blobファイル不在エラー
'E010801', // ライセンス不在エラー
'E010802', // ライセンス取り込み済みエラー
'E010803', // ライセンス発行済みエラー
'E010804', // ライセンス不足エラー
'E010805', // ライセンス有効期限切れエラー
'E010806', // ライセンス割り当て不可エラー
] as const;

View File

@ -31,4 +31,8 @@ export const errors: Errors = {
E010701: 'File not found in Blob Storage Error.',
E010801: 'License not exist Error',
E010802: 'License already activated Error',
E010803: 'License already issued Error',
E010804: 'License shortage Error',
E010805: 'License is expired Error',
E010806: 'License is unavailable Error',
};

View File

@ -1,7 +1,7 @@
import { Context } from './types';
export const makeContext = (externaiId: string): Context => {
export const makeContext = (externalId: string): Context => {
return {
trackingId: externaiId,
trackingId: externalId,
};
};

View File

@ -0,0 +1,218 @@
import { Context } from '../log';
import {
AdB2cService,
ConflictError,
} from '../../gateways/adb2c/adb2c.service';
import { SendGridService } from '../../gateways/sendgrid/sendgrid.service';
import { User, newUser } from '../../repositories/users/entity/user.entity';
import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service';
import { AccountsRepositoryService } from '../../repositories/accounts/accounts.repository.service';
import { Account } from '../../repositories/accounts/entity/account.entity';
// ### ユニットテスト用コード以外では絶対に使用してはいけないダーティな手段を使用しているが、他の箇所では使用しないこと ###
/**
* adB2cServiceのモックを作成してTServiceが依存するサービス(adB2cService)
* serviceに指定するオブジェクトは`adB2cService: AdB2cService`
* @param service TService
* @param overrides adB2cServiceの各種メソッドのモックが返す値
*/
export const overrideAdB2cService = <TService>(
service: TService,
overrides: {
createUser?: (
context: Context,
email: string,
password: string,
username: string,
) => Promise<{ sub: string } | ConflictError>;
deleteUser?: (externalId: string, context: Context) => Promise<void>;
},
): void => {
// テストコードでのみ許される強引な方法でprivateメンバ変数の参照を取得
const obj = (service as any).adB2cService as AdB2cService;
if (overrides.createUser) {
Object.defineProperty(obj, obj.createUser.name, {
value: overrides.createUser,
writable: true,
});
}
if (overrides.deleteUser) {
Object.defineProperty(obj, obj.deleteUser.name, {
value: overrides.deleteUser,
writable: true,
});
}
};
/**
* sendgridServiceのモックを作成してTServiceが依存するサービス(sendgridService)
* serviceに指定するオブジェクトは`sendgridService: SendgridService`
* @param service TService
* @param overrides sendgridServiceの各種メソッドのモックが返す値
*/
export const overrideSendgridService = <TService>(
service: TService,
overrides: {
createMailContentFromEmailConfirm?: (
context: Context,
accountId: number,
userId: number,
email: string,
) => Promise<{ subject: string; text: string; html: string }>;
createMailContentFromEmailConfirmForNormalUser?: (
accountId: number,
userId: number,
email: string,
) => Promise<{ subject: string; text: string; html: string }>;
sendMail?: (
context: Context,
to: string,
from: string,
subject: string,
text: string,
html: string,
) => Promise<void>;
},
): void => {
// テストコードでのみ許される強引な方法でprivateメンバ変数の参照を取得
const obj = (service as any).sendgridService as SendGridService;
if (overrides.sendMail) {
Object.defineProperty(obj, obj.sendMail.name, {
value: overrides.sendMail,
writable: true,
});
} else {
// [重要]
// sendMailだけは外部に対する送信を行ってしまう & 失敗によりメールアドレス自体の信頼度が変動してしまうため、
// overrideした場合には"必ず"偽物の呼び出しになるようにしておく
Object.defineProperty(obj, obj.sendMail.name, {
value: async () => {
return;
},
writable: true,
});
}
if (overrides.createMailContentFromEmailConfirm) {
Object.defineProperty(obj, obj.createMailContentFromEmailConfirm.name, {
value: overrides.createMailContentFromEmailConfirm,
writable: true,
});
}
if (overrides.createMailContentFromEmailConfirmForNormalUser) {
Object.defineProperty(
obj,
obj.createMailContentFromEmailConfirmForNormalUser.name,
{
value: overrides.createMailContentFromEmailConfirmForNormalUser,
writable: true,
},
);
}
};
/**
* usersRepositoryのモックを作成してTServiceが依存するサービス(usersRepositoryService)
* serviceに指定するオブジェクトは`usersRepository: UsersRepositoryService`
* @param service TService
* @param overrides usersRepositoryの各種メソッドのモックが返す値
*/
export const overrideUsersRepositoryService = <TService>(
service: TService,
overrides: {
createNormalUser?: (user: newUser) => Promise<User>;
deleteNormalUser?: (userId: number) => Promise<void>;
},
): void => {
// テストコードでのみ許される強引な方法でprivateメンバ変数の参照を取得
const obj = (service as any).usersRepository as UsersRepositoryService;
if (overrides.createNormalUser) {
Object.defineProperty(obj, obj.createNormalUser.name, {
value: overrides.createNormalUser,
writable: true,
});
}
if (overrides.deleteNormalUser) {
Object.defineProperty(obj, obj.deleteNormalUser.name, {
value: overrides.deleteNormalUser,
writable: true,
});
}
};
/**
* blobStorageServiceのモックを作成してTServiceが依存するサービス(blobStorageService)
* serviceに指定するオブジェクトは`blobstorageService: blobStorageService`
* @param service TService
* @param overrides blobStorageServiceの各種メソッドのモックが返す値
*/
export const overrideBlobstorageService = <TService>(
service: TService,
overrides: {
createContainer?: (
context: Context,
accountId: number,
country: string,
) => Promise<void>;
deleteContainer?: (
context: Context,
accountId: number,
country: string,
) => Promise<void>;
},
): void => {
// テストコードでのみ許される強引な方法でprivateメンバ変数の参照を取得
const obj = (service as any).blobStorageService as BlobstorageService;
if (overrides.createContainer) {
Object.defineProperty(obj, obj.createContainer.name, {
value: overrides.createContainer,
writable: true,
});
}
if (overrides.deleteContainer) {
Object.defineProperty(obj, obj.deleteContainer.name, {
value: overrides.deleteContainer,
writable: true,
});
}
};
/**
* accountsRepositoryのモックを作成してTServiceが依存するサービス(AccountsRepositoryService)
* serviceに指定するオブジェクトは`accountsRepository: AccountsRepositoryService`
* @param service TService
* @param overrides accountsRepositoryの各種メソッドのモックが返す値
*/
export const overrideAccountsRepositoryService = <TService>(
service: TService,
overrides: {
createAccount?: (
companyName: string,
country: string,
dealerAccountId: number | undefined,
tier: number,
adminExternalUserId: string,
adminUserRole: string,
adminUserAcceptedTermsVersion: string,
) => Promise<{ newAccount: Account; adminUser: User }>;
deleteAccount?: (accountId: number, userId: number) => Promise<void>;
},
): void => {
// テストコードでのみ許される強引な方法でprivateメンバ変数の参照を取得
const obj = (service as any).accountRepository as AccountsRepositoryService;
if (overrides.deleteAccount) {
Object.defineProperty(obj, obj.deleteAccount.name, {
value: overrides.deleteAccount,
writable: true,
});
}
if (overrides.createAccount) {
Object.defineProperty(obj, obj.createAccount.name, {
value: overrides.createAccount,
writable: true,
});
}
};

View File

@ -0,0 +1,31 @@
import { registerDecorator, ValidationOptions } from 'class-validator';
export const IsAdminPasswordvalid = (validationOptions?: ValidationOptions) => {
return (object: any, propertyName: string) => {
registerDecorator({
name: 'IsAdminPasswordvalid',
target: object.constructor,
propertyName: propertyName,
constraints: [],
options: validationOptions,
validator: {
validate: (value: string) => {
// 8文字64文字でなければ早期に不合格
const minLength = 8;
const maxLength = 64;
if (value.length < minLength || value.length > maxLength) {
return false;
}
// 英字の大文字、英字の小文字、アラビア数字、記号(@#$%^&*\-_+=[]{}|\:',.?/`~"();!から2種類以上組み合わせ
const charaTypePattern =
/^((?=.*[a-z])(?=.*[A-Z])|(?=.*[a-z])(?=.*[\d])|(?=.*[a-z])(?=.*[@#$%^&*\\\-_+=\[\]{}|:',.?\/`~"();!])|(?=.*[A-Z])(?=.*[\d])|(?=.*[A-Z])(?=.*[@#$%^&*\\\-_+=\[\]{}|:',.?\/`~"();!])|(?=.*[\d])(?=.*[@#$%^&*\\\-_+=\[\]{}|:',.?\/`~"();!]))[a-zA-Z\d@#$%^&*\\\-_+=\[\]{}|:',.?\/`~"();!]/;
return new RegExp(charaTypePattern).test(value);
},
defaultMessage: () => {
return 'Admin password rule not satisfied';
},
},
});
};
};

View File

@ -119,6 +119,15 @@ export const LICENSE_ALLOCATED_STATUS = {
REUSABLE: 'Reusable',
DELETED: 'Deleted',
} as const;
/**
*
* @const {string[]}
*/
export const SWITCH_FROM_TYPE = {
NONE: 'NONE',
CARD: 'CARD',
TRIAL: 'TRIAL',
} as const;
/**
*
@ -126,6 +135,12 @@ export const LICENSE_ALLOCATED_STATUS = {
*/
export const LICENSE_EXPIRATION_THRESHOLD_DAYS = 14;
/**
*
* @const {number}
*/
export const LICENSE_EXPIRATION_DAYS = 365;
/**
*
* @const {number}
@ -198,3 +213,15 @@ export const USER_LICENSE_STATUS = {
ALERT: 'Alert',
RENEW: 'Renew',
};
/**
*
* @const {number}
*/
export const TRIAL_LICENSE_EXPIRATION_DAYS = 30;
/**
*
* @const {number}
*/
export const TRIAL_LICENSE_ISSUE_NUM = 100;

View File

@ -33,6 +33,8 @@ import {
IssueLicenseRequest,
IssueLicenseResponse,
GetDealersResponse,
CreateTypistGroupResponse,
CreateTypistGroupRequest,
} from './types/types';
import { USER_ROLES, ADMIN_ROLES, TIERS } from '../../constants';
import { AuthGuard } from '../../common/guards/auth/authguards';
@ -40,7 +42,8 @@ import { RoleGuard } from '../../common/guards/role/roleguards';
import { retrieveAuthorizationToken } from '../../common/http/helper';
import { AccessToken } from '../../common/token';
import jwt from 'jsonwebtoken';
import { Context } from '../../common/log';
import { makeContext } from '../../common/log';
import { v4 as uuidv4 } from 'uuid';
@ApiTags('accounts')
@Controller('accounts')
@ -80,7 +83,10 @@ export class AccountsController {
} = body;
const role = USER_ROLES.NONE;
const context = makeContext(uuidv4());
await this.accountService.createAccount(
context,
companyName,
country,
dealerAccountId,
@ -239,6 +245,46 @@ export class AccountsController {
return { typistGroups };
}
@ApiResponse({
status: HttpStatus.OK,
type: CreateTypistGroupResponse,
description: '成功時のレスポンス',
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'グループ名が空の場合/ユーザーが存在しない場合',
type: ErrorResponse,
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: '認証エラー',
type: ErrorResponse,
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: '想定外のサーバーエラー',
type: ErrorResponse,
})
@ApiOperation({
operationId: 'createTypistGroup',
description:
'ログインしているユーザーのアカウント配下にタイピストグループを追加します',
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN] }))
@Post('typist-groups')
async createTypistGroup(
@Req() req: Request,
@Body() body: CreateTypistGroupRequest,
): Promise<CreateTypistGroupResponse> {
// アクセストークン取得
const accessToken = retrieveAuthorizationToken(req);
const payload = jwt.decode(accessToken, { json: true }) as AccessToken;
return {};
}
@Post('partner')
@ApiResponse({
status: HttpStatus.OK,
@ -277,7 +323,10 @@ export class AccountsController {
const accessToken = retrieveAuthorizationToken(req);
const payload = jwt.decode(accessToken, { json: true }) as AccessToken;
const context = makeContext(payload.userId);
await this.accountService.createPartnerAccount(
context,
companyName,
country,
email,
@ -401,12 +450,17 @@ export class AccountsController {
console.log(body);
const { orderedAccountId, poNumber } = body;
/*await this.licensesService.issueLicense(
orderedAccountId
const token = retrieveAuthorizationToken(req);
const accessToken = jwt.decode(token, { json: true }) as AccessToken;
const context = makeContext(accessToken.userId);
await this.accountService.issueLicense(
context,
orderedAccountId,
accessToken.userId,
accessToken.tier,
poNumber,
);
*/
return {};
}

View File

@ -7,6 +7,7 @@ import { AccountsController } from './accounts.controller';
import { AccountsService } from './accounts.service';
import { AdB2cModule } from '../../gateways/adb2c/adb2c.module';
import { UserGroupsRepositoryModule } from '../../repositories/user_groups/user_groups.repository.module';
import { BlobstorageModule } from '../../gateways/blobstorage/blobstorage.module';
@Module({
imports: [
@ -16,6 +17,7 @@ import { UserGroupsRepositoryModule } from '../../repositories/user_groups/user_
UserGroupsRepositoryModule,
SendGridModule,
AdB2cModule,
BlobstorageModule,
],
controllers: [AccountsController],
providers: [AccountsService],

File diff suppressed because it is too large Load Diff

View File

@ -10,11 +10,7 @@ import {
} from '../../gateways/adb2c/adb2c.service';
import { Account } from '../../repositories/accounts/entity/account.entity';
import { User } from '../../repositories/users/entity/user.entity';
import {
LICENSE_EXPIRATION_THRESHOLD_DAYS,
TIERS,
USER_ROLES,
} from '../../constants';
import { TIERS, USER_ROLES } from '../../constants';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import {
TypistGroup,
@ -26,7 +22,10 @@ import {
Dealer,
GetMyAccountResponse,
} from './types/types';
import { DateWithZeroTime } from '../licenses/types/types';
import {
DateWithZeroTime,
ExpirationThresholdDate,
} from '../licenses/types/types';
import { GetLicenseSummaryResponse, Typist } from './types/types';
import { AccessToken } from '../../common/token';
import { UserNotFoundError } from '../../repositories/users/errors/types';
@ -34,6 +33,14 @@ import { UserGroupsRepositoryService } from '../../repositories/user_groups/user
import { makePassword } from '../../common/password';
import { LicensesRepositoryService } from '../../repositories/licenses/licenses.repository.service';
import { AccountNotFoundError } from '../../repositories/accounts/errors/types';
import { Context } from '../../common/log';
import {
LicensesShortageError,
AlreadyIssuedError,
OrderNotFoundError,
} from '../../repositories/licenses/errors/types';
import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service';
@Injectable()
export class AccountsService {
constructor(
@ -43,6 +50,7 @@ export class AccountsService {
private readonly userGroupsRepository: UserGroupsRepositoryService,
private readonly adB2cService: AdB2cService,
private readonly sendgridService: SendGridService,
private readonly blobStorageService: BlobstorageService,
private readonly configService: ConfigService,
) {}
private readonly logger = new Logger(AccountsService.name);
@ -58,13 +66,9 @@ export class AccountsService {
try {
const currentDate = new DateWithZeroTime();
const expiringSoonDate = new Date(currentDate.getTime());
expiringSoonDate.setDate(
currentDate.getDate() + LICENSE_EXPIRATION_THRESHOLD_DAYS,
const expiringSoonDate = new ExpirationThresholdDate(
currentDate.getTime(),
);
// システム上有効期限日付の23時59分59秒999ミリ秒までライセンスは有効なため、各値最大値をセット
expiringSoonDate.setHours(23, 59, 59, 999);
const { licenseSummary, isStorageAvailable } =
await this.accountRepository.getLicenseSummaryInfo(
@ -118,100 +122,221 @@ export class AccountsService {
* @returns account
*/
async createAccount(
context: Context,
companyName: string,
country: string,
dealerAccountId: number | null,
dealerAccountId: number | undefined,
email: string,
password: string,
username: string,
role: string,
acceptedTermsVersion: string,
): Promise<{ accountId: number; userId: number; externalUserId: string }> {
let externalUser: { sub: string } | ConflictError;
this.logger.log(
`[IN] [${context.trackingId}] ${this.createAccount.name} | params: { ` +
`country: ${country}, ` +
`dealerAccountId: ${dealerAccountId}, ` +
`role: ${role}, ` +
`acceptedTermsVersion: ${acceptedTermsVersion} };`,
);
try {
// idpにユーザーを作成
externalUser = await this.adB2cService.createUser(
email,
password,
username,
);
} catch (e) {
console.log(e);
console.log('create externalUser failed');
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
// メールアドレス重複エラー
if (isConflictError(externalUser)) {
throw new HttpException(
makeErrorResponse('E010301'),
HttpStatus.BAD_REQUEST,
);
}
let account: Account;
let user: User;
try {
// アカウントと管理者をセットで作成
const { newAccount, adminUser } =
await this.accountRepository.createAccount(
companyName,
country,
dealerAccountId,
TIERS.TIER5,
externalUser.sub,
role,
acceptedTermsVersion,
);
account = newAccount;
user = adminUser;
} catch (e) {
console.log('create account failed');
console.log(
`[NOT IMPLEMENT] [RECOVER] delete account: ${externalUser.sub}`,
);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
// メールの送信元を取得
const from = this.configService.get<string>('MAIL_FROM') ?? '';
// メールの内容を構成
const { subject, text, html } =
await this.sendgridService.createMailContentFromEmailConfirm(
account.id,
user.id,
let externalUser: { sub: string } | ConflictError;
try {
// idpにユーザーを作成
externalUser = await this.adB2cService.createUser(
context,
email,
password,
username,
);
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.error('create externalUser failed');
// メールを送信
await this.sendgridService.sendMail(email, from, subject, text, html);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
// メールアドレス重複エラー
if (isConflictError(externalUser)) {
this.logger.error(`email conflict. externalUser: ${externalUser}`);
throw new HttpException(
makeErrorResponse('E010301'),
HttpStatus.BAD_REQUEST,
);
}
let account: Account;
let user: User;
try {
// アカウントと管理者をセットで作成
const { newAccount, adminUser } =
await this.accountRepository.createAccount(
companyName,
country,
dealerAccountId,
TIERS.TIER5,
externalUser.sub,
role,
acceptedTermsVersion,
);
account = newAccount;
user = adminUser;
this.logger.log(
`[${context.trackingId}] adminUser.external_id: ${user.external_id}`,
);
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.error('create account failed');
//リカバリ処理
// idpのユーザーを削除
await this.deleteAdB2cUser(externalUser.sub, context);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
// 新規作成アカウント用のBlobコンテナを作成
try {
await this.blobStorageService.createContainer(
context,
account.id,
country,
);
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.error('create container failed');
//リカバリ処理
// idpのユーザーを削除
await this.deleteAdB2cUser(externalUser.sub, context);
// DBのアカウントを削除
await this.deleteAccount(account.id, user.id, context);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
// メールの送信元を取得
const from = this.configService.get<string>('MAIL_FROM') ?? '';
// メールの内容を構成
const { subject, text, html } =
await this.sendgridService.createMailContentFromEmailConfirm(
context,
account.id,
user.id,
email,
);
// メールを送信
await this.sendgridService.sendMail(
context,
email,
from,
subject,
text,
html,
);
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.error('send E-mail failed');
//リカバリ処理
// idpのユーザーを削除
await this.deleteAdB2cUser(externalUser.sub, context);
// DBのアカウントを削除
await this.deleteAccount(account.id, user.id, context);
// Blobコンテナを削除
await this.deleteBlobContainer(account.id, country, context);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
return {
accountId: account.id,
userId: user.id,
externalUserId: user.external_id,
};
} catch (e) {
console.log('create user failed');
console.log(`[NOT IMPLEMENT] [RECOVER] delete account: ${account.id}`);
console.log(
`[NOT IMPLEMENT] [RECOVER] delete externalUser: ${externalUser.sub}`,
);
console.log(`[NOT IMPLEMENT] [RECOVER] delete user: ${user.id}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
throw e;
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.createAccount.name}`,
);
}
}
return {
accountId: account.id,
userId: user.id,
externalUserId: user.external_id,
};
// AdB2cのユーザーを削除
// TODO「タスク 2452: リトライ処理を入れる箇所を検討し、実装する」の候補
private async deleteAdB2cUser(
externalUserId: string,
context: Context,
): Promise<void> {
try {
await this.adB2cService.deleteUser(externalUserId, context);
this.logger.log(
`[${context.trackingId}] delete externalUser: ${externalUserId}`,
);
} catch (error) {
this.logger.error(`error=${error}`);
this.logger.error(
`[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`,
);
}
}
// DBのアカウントを削除
private async deleteAccount(
accountId: number,
userId: number,
context: Context,
): Promise<void> {
try {
await this.accountRepository.deleteAccount(accountId, userId);
this.logger.log(
`[${context.trackingId}] delete account: ${accountId}, user: ${userId}`,
);
} catch (error) {
this.logger.error(`error=${error}`);
this.logger.error(
`[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete account: ${accountId}, user: ${userId}`,
);
}
}
// Blobコンテナを削除
// TODO「タスク 2452: リトライ処理を入れる箇所を検討し、実装する」の候補
private async deleteBlobContainer(
accountId: number,
country: string,
context: Context,
): Promise<void> {
try {
await this.blobStorageService.deleteContainer(
context,
accountId,
country,
);
this.logger.log(
`[${context.trackingId}] delete container: ${accountId}, country: ${country}`,
);
} catch (error) {
this.logger.error(
`[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete container: ${accountId}, country: ${country}`,
);
}
}
/**
@ -335,97 +460,168 @@ export class AccountsService {
/**
*
* @param companyName
* @param country
* @param email
* @param adminName
* @param userId
* @param tier
* @param companyName
* @param country
* @param email
* @param adminName
* @param creatorUserId ID
* @param creatorAccountTier
*/
async createPartnerAccount(
context: Context,
companyName: string,
country: string,
email: string,
adminName: string,
userId: string,
tier: number,
): Promise<void> {
this.logger.log(`[IN] ${this.createPartnerAccount.name}`);
let myAccountId: number;
creatorUserId: string,
creatorAccountTier: number,
): Promise<{ accountId: number }> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.createPartnerAccount.name} | params: { creatorUserId: ${creatorUserId}, creatorAccountTier: ${creatorAccountTier} };`,
);
try {
// アクセストークンからユーザーIDを取得する
myAccountId = (await this.usersRepository.findUserByExternalId(userId))
.account_id;
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof UserNotFoundError) {
throw new HttpException(
makeErrorResponse('E010204'),
HttpStatus.BAD_REQUEST,
let myAccountId: number;
try {
// アクセストークンからユーザーIDを取得する
myAccountId = (
await this.usersRepository.findUserByExternalId(creatorUserId)
).account_id;
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof UserNotFoundError) {
throw new HttpException(
makeErrorResponse('E010204'),
HttpStatus.BAD_REQUEST,
);
} else {
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
const ramdomPassword = makePassword();
let externalUser: { sub: string } | ConflictError;
try {
// 管理者ユーザを作成し、AzureADB2C IDを取得する
externalUser = await this.adB2cService.createUser(
context,
email,
ramdomPassword,
adminName,
);
} else {
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
// メールアドレスが重複していた場合はエラーを返す
if (isConflictError(externalUser)) {
throw new HttpException(
makeErrorResponse('E010301'),
HttpStatus.BAD_REQUEST,
);
}
const ramdomPassword = makePassword();
let account: Account;
let user: User;
try {
// アカウントと管理者をセットで作成
const { newAccount, adminUser } =
await this.accountRepository.createAccount(
companyName,
country,
myAccountId,
creatorAccountTier + 1,
externalUser.sub,
USER_ROLES.NONE,
null,
);
account = newAccount;
user = adminUser;
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.error('create partner account failed');
//リカバリ処理
// idpのユーザーを削除
await this.deleteAdB2cUser(externalUser.sub, context);
let externalUser: { sub: string } | ConflictError;
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
// 管理者ユーザを作成し、AzureADB2C IDを取得する
externalUser = await this.adB2cService.createUser(
email,
ramdomPassword,
adminName,
);
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
// メールアドレスが重複していた場合はエラーを返す
if (isConflictError(externalUser)) {
throw new HttpException(
makeErrorResponse('E010301'),
HttpStatus.BAD_REQUEST,
);
}
try {
// アカウントと管理者をセットで作成
const { newAccount, adminUser } =
await this.accountRepository.createAccount(
companyName,
try {
// 新規作成アカウント用のBlobコンテナを作成
await this.blobStorageService.createContainer(
context,
account.id,
country,
myAccountId,
tier + 1,
externalUser.sub,
USER_ROLES.NONE,
null,
);
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.error('create partner container failed');
//リカバリ処理
// idpのユーザーを削除
await this.deleteAdB2cUser(externalUser.sub, context);
const from = this.configService.get<string>('MAIL_FROM') || '';
const { subject, text, html } =
await this.sendgridService.createMailContentFromEmailConfirmForNormalUser(
newAccount.id,
adminUser.id,
email,
// DBのアカウントとユーザーを削除
await this.deleteAccount(account.id, user.id, context);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
await this.sendgridService.sendMail(email, from, subject, text, html);
}
try {
const from = this.configService.get<string>('MAIL_FROM') || '';
const { subject, text, html } =
await this.sendgridService.createMailContentFromEmailConfirmForNormalUser(
account.id,
user.id,
email,
);
await this.sendgridService.sendMail(
context,
email,
from,
subject,
text,
html,
);
return { accountId: account.id };
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.error('create partner account send mail failed');
//リカバリ処理
// idpのユーザーを削除
await this.deleteAdB2cUser(externalUser.sub, context);
// DBのアカウントを削除
await this.deleteAccount(account.id, user.id, context);
// Blobコンテナを削除
await this.deleteBlobContainer(account.id, country, context);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.error('create partner account failed');
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
throw e;
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.createPartnerAccount.name}`,
);
}
}
@ -446,11 +642,17 @@ export class AccountsService {
try {
const currentDate = new DateWithZeroTime();
// 第五階層のshortage算出に使用する日付情報
// 「有効期限が現在日付からしきい値以内のライセンス数」を取得するため、しきい値となる日付を作成する
const expiringSoonDate = new ExpirationThresholdDate(
currentDate.getTime(),
);
const getPartnerLicenseResult =
await this.accountRepository.getPartnerLicense(
accountId,
currentDate,
expiringSoonDate,
offset,
limit,
);
@ -471,27 +673,14 @@ export class AccountsService {
},
);
// 第五階層のshortage算出に使用する日付情報
// 「有効期限が現在日付からしきい値以内のライセンス数」を取得するため、しきい値となる日付を作成する
const expiringSoonDate = new Date(currentDate.getTime());
expiringSoonDate.setDate(
currentDate.getDate() + LICENSE_EXPIRATION_THRESHOLD_DAYS,
);
expiringSoonDate.setHours(23, 59, 59, 999);
// 各子アカウントのShortageを算出してreturn用の変数にマージする
const childrenPartnerLicenses: PartnerLicenseInfo[] = [];
for (const childPartnerLicenseFromRepository of getPartnerLicenseResult.childPartnerLicensesFromRepository) {
let childShortage;
if (childPartnerLicenseFromRepository.tier === TIERS.TIER5) {
// 第五階層の場合計算式が異なるため、別途値を取得する
const { expiringSoonLicense, allocatableLicenseWithMargin } =
await this.accountRepository.getLicenseCountForShortage(
childPartnerLicenseFromRepository.accountId,
currentDate,
expiringSoonDate,
);
childShortage = allocatableLicenseWithMargin - expiringSoonLicense;
childShortage =
childPartnerLicenseFromRepository.allocatableLicenseWithMargin -
childPartnerLicenseFromRepository.expiringSoonLicense;
} else {
childShortage =
childPartnerLicenseFromRepository.stockLicense -
@ -587,6 +776,72 @@ export class AccountsService {
}
}
/**
*
* @param context
* @param orderedAccountId
* @param userId
* @param tier
* @param poNumber
*/
async issueLicense(
context: Context,
orderedAccountId: number,
userId: string,
tier: number,
poNumber: string,
): Promise<void> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.issueLicense.name} | params: { ` +
`orderedAccountId: ${orderedAccountId}, ` +
`userId: ${userId}, ` +
`tier: ${tier}, ` +
`poNumber: ${poNumber} };`,
);
try {
// アクセストークンからユーザーIDを取得する
const myAccountId = (
await this.usersRepository.findUserByExternalId(userId)
).account_id;
await this.licensesRepository.issueLicense(
orderedAccountId,
myAccountId,
tier,
poNumber,
);
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
case OrderNotFoundError:
throw new HttpException(
makeErrorResponse('E010801'),
HttpStatus.BAD_REQUEST,
);
case AlreadyIssuedError:
throw new HttpException(
makeErrorResponse('E010803'),
HttpStatus.BAD_REQUEST,
);
case LicensesShortageError:
throw new HttpException(
makeErrorResponse('E010804'),
HttpStatus.BAD_REQUEST,
);
default:
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.issueLicense.name}`,
);
}
}
// dealersのアカウント情報を取得する
async getDealers(): Promise<GetDealersResponse> {
this.logger.log(`[IN] ${this.getDealers.name}`);

View File

@ -14,6 +14,8 @@ import { UserGroup } from '../../../repositories/user_groups/entity/user_group.e
import { UserGroupsRepositoryService } from '../../../repositories/user_groups/user_groups.repository.service';
import { AdB2cUser } from '../../../gateways/adb2c/types/types';
import { LicensesRepositoryService } from '../../../repositories/licenses/licenses.repository.service';
import { Context } from '../../../common/log';
import { BlobstorageService } from '../../../gateways/blobstorage/blobstorage.service';
export type LicensesRepositoryMockValue = {
getLicenseOrderHistoryInfo:
@ -22,6 +24,7 @@ export type LicensesRepositoryMockValue = {
orderHistories: LicenseOrder[];
}
| Error;
issueLicense: undefined | Error;
};
export type UsersRepositoryMockValue = {
findUserById: User | Error;
@ -58,6 +61,15 @@ export type AccountsRepositoryMockValue = {
};
createAccount: { newAccount: Account; adminUser: User } | Error;
};
export type BlobStorageServiceMockValue = {
createContainer: void | Error;
containerExists: boolean | Error;
fileExists: boolean | Error;
publishUploadSas: string | Error;
publishDownloadSas: string | Error;
};
export const makeAccountsServiceMock = async (
accountsRepositoryMockValue: AccountsRepositoryMockValue,
usersRepositoryMockValue: UsersRepositoryMockValue,
@ -65,6 +77,7 @@ export const makeAccountsServiceMock = async (
adB2cMockValue: AdB2cMockValue,
configMockValue: ConfigMockValue,
sendGridMockValue: SendGridMockValue,
blobStorageMockValue: BlobStorageServiceMockValue,
licensesRepositoryMockValue: LicensesRepositoryMockValue,
): Promise<AccountsService> => {
const module: TestingModule = await Test.createTestingModule({
@ -90,6 +103,8 @@ export const makeAccountsServiceMock = async (
return makeConfigMock(configMockValue);
case SendGridService:
return makeSendGridServiceMock(sendGridMockValue);
case BlobstorageService:
return makeBlobStorageServiceMock(blobStorageMockValue);
case LicensesRepositoryService:
return makeLicensesRepositoryMock(licensesRepositoryMockValue);
}
@ -127,10 +142,10 @@ export const makeAccountsRepositoryMock = (
export const makeLicensesRepositoryMock = (
value: LicensesRepositoryMockValue,
) => {
const { getLicenseOrderHistoryInfo } = value;
const { getLicenseOrderHistoryInfo, issueLicense } = value;
return {
findUserById:
getLicenseOrderHistoryInfo:
getLicenseOrderHistoryInfo instanceof Error
? jest
.fn<Promise<void>, []>()
@ -141,6 +156,21 @@ export const makeLicensesRepositoryMock = (
[]
>()
.mockResolvedValue(getLicenseOrderHistoryInfo),
issueLicense:
issueLicense instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(issueLicense)
: jest
.fn<
Promise<{
context: Context;
orderedAccountId: number;
myAccountId: number;
tier: number;
poNumber: string;
}>,
[]
>()
.mockResolvedValue(issueLicense),
};
};
export const makeUsersRepositoryMock = (value: UsersRepositoryMockValue) => {
@ -228,6 +258,42 @@ export const makeSendGridServiceMock = (value: SendGridMockValue) => {
: jest.fn<Promise<void>, []>().mockResolvedValue(sendMail),
};
};
export const makeBlobStorageServiceMock = (
value: BlobStorageServiceMockValue,
) => {
const {
containerExists,
fileExists,
createContainer,
publishUploadSas,
publishDownloadSas,
} = value;
return {
containerExists:
containerExists instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(containerExists)
: jest.fn<Promise<boolean>, []>().mockResolvedValue(containerExists),
fileExists:
fileExists instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(fileExists)
: jest.fn<Promise<boolean>, []>().mockResolvedValue(fileExists),
createContainer:
createContainer instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(createContainer)
: jest.fn<Promise<void>, []>().mockResolvedValue(createContainer),
publishUploadSas:
publishUploadSas instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(publishUploadSas)
: jest.fn<Promise<string>, []>().mockResolvedValue(publishUploadSas),
publishDownloadSas:
publishDownloadSas instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(publishDownloadSas)
: jest.fn<Promise<string>, []>().mockResolvedValue(publishDownloadSas),
};
};
// 個別のテストケースに対応してそれぞれのMockを用意するのは無駄が多いのでテストケース内で個別の値を設定する
export const makeDefaultAccountsRepositoryMockValue =
(): AccountsRepositoryMockValue => {
@ -402,5 +468,17 @@ export const makeDefaultLicensesRepositoryMockValue =
},
],
},
issueLicense: undefined,
};
};
export const makeBlobStorageServiceMockValue =
(): BlobStorageServiceMockValue => {
return {
containerExists: true,
fileExists: true,
publishUploadSas: 'https://blob-storage?sas-token',
publishDownloadSas: 'https://blob-storage?sas-token',
createContainer: undefined,
};
};

View File

@ -1,9 +1,78 @@
import { DataSource } from 'typeorm';
import { User } from '../../../repositories/users/entity/user.entity';
import { Account } from '../../../repositories/accounts/entity/account.entity';
import {
License,
LicenseOrder,
} from '../../../repositories/licenses/entity/license.entity';
import { SortCriteria } from '../../../repositories/sort_criteria/entity/sort_criteria.entity';
// TODO: [PBI 2379] 他のUtilityからコピペしてきたもの。後日整理される前提。
export const createAccountAndAdminUser = async (
datasource: DataSource,
adminExternalId: string,
): Promise<{
accountId: number;
adminId: number;
role: string;
tier: number;
}> => {
const { identifiers: account_idf } = await datasource
.getRepository(Account)
.insert({
tier: 1,
country: 'JP',
delegation_permission: false,
locked: false,
company_name: 'test inc.',
verified: true,
deleted_at: '',
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
const account = account_idf.pop() as Account;
const { identifiers: user_idf } = await datasource
.getRepository(User)
.insert({
account_id: account.id,
external_id: adminExternalId,
role: 'admin none',
accepted_terms_version: '1.0',
email_verified: true,
auto_renew: true,
license_alert: true,
notification: true,
encryption: true,
encryption_password: 'password',
prompt: true,
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
const user = user_idf.pop() as User;
// Accountの管理者を設定する
await datasource.getRepository(Account).update(
{ id: user.account_id },
{
primary_admin_user_id: user.id,
},
);
const accountResult = await getAccount(datasource, account.id);
const userResult = await getUser(datasource, user.id);
return {
accountId: account.id,
adminId: user.id,
role: userResult.role,
tier: accountResult.tier,
};
};
export const createAccount = async (
datasource: DataSource,
@ -29,6 +98,80 @@ export const createAccount = async (
return { accountId: account.id };
};
/**
* ユーティリティ: 指定IDのアカウントを取得する
* @param dataSource
* @param id ID
* @returns
*/
export const getAccount = async (dataSource: DataSource, id: number) => {
return await dataSource.getRepository(Account).findOne({
where: { id: id },
});
};
/**
* ユーティリティ: すべてのアカウントを取得する
* @param dataSource
* @returns 
*/
export const getAccounts = async (
dataSource: DataSource,
): Promise<Account[]> => {
return await dataSource.getRepository(Account).find();
};
/**
* ユーティリティ: 指定ExternalIdのユーザーを取得する
* @param dataSource
* @param externalId ID
* @returns
*/
export const getUserFromExternalID = async (
dataSource: DataSource,
externalId: string,
) => {
return await dataSource.getRepository(User).findOne({
where: { external_id: externalId },
});
};
/**
* ユーティリティ: 指定Idのユーザーを取得する
* @param dataSource
* @param externalId ID
* @returns
*/
export const getUser = async (
datasource: DataSource,
id: number,
): Promise<User> => {
const user = await datasource.getRepository(User).findOne({
where: {
id: id,
},
});
return user;
};
/**
* ユーティリティ: すべてのユーザーを取得する
* @param dataSource
* @returns
*/
export const getUsers = async (dataSource: DataSource): Promise<User[]> => {
return await dataSource.getRepository(User).find();
};
/**
* ユーティリティ: すべてのソート条件を取得する
* @param dataSource
* @returns
*/
export const getSortCriteria = async (dataSource: DataSource) => {
return await dataSource.getRepository(SortCriteria).find();
};
export const createLicense = async (
datasource: DataSource,
accountId: number,
@ -50,6 +193,30 @@ export const createLicense = async (
identifiers.pop() as License;
};
// 有効期限・ステータス付きのライセンスを作成
export const createLicenseSetExpiryDateAndStatus = async (
datasource: DataSource,
accountId: number,
expiryDate: Date,
status: string,
): Promise<void> => {
const { identifiers } = await datasource.getRepository(License).insert({
expiry_date: expiryDate,
account_id: accountId,
type: 'NORMAL',
status: status,
allocated_user_id: null,
order_id: null,
deleted_at: null,
delete_order_id: null,
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
identifiers.pop() as License;
};
export const createLicenseOrder = async (
datasource: DataSource,
fromAccountId: number,
@ -74,3 +241,31 @@ export const createLicenseOrder = async (
});
identifiers.pop() as License;
};
export const createUser = async (
datasource: DataSource,
accountId: number,
external_id: string,
role: string,
author_id?: string | undefined,
): Promise<{ userId: number; externalId: string; authorId: string }> => {
const { identifiers } = await datasource.getRepository(User).insert({
account_id: accountId,
external_id: external_id,
role: role,
accepted_terms_version: '1.0',
author_id: author_id,
email_verified: true,
auto_renew: true,
license_alert: true,
notification: true,
encryption: false,
prompt: false,
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
const user = identifiers.pop() as User;
return { userId: user.id, externalId: external_id, authorId: author_id };
};

View File

@ -1,5 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsInt, IsOptional, Matches, Min } from 'class-validator';
import {
IsEmail,
IsInt,
IsOptional,
Matches,
MaxLength,
Min,
ArrayMinSize,
MinLength,
} from 'class-validator';
import { IsAdminPasswordvalid } from '../../../common/validators/admin.validator';
export class CreateAccountRequest {
@ApiProperty()
@ -10,16 +20,17 @@ export class CreateAccountRequest {
maxLength: 2,
})
country: string;
@ApiProperty({ nullable: true })
@ApiProperty({ required: false })
@IsInt()
@IsOptional()
dealerAccountId?: number;
dealerAccountId?: number | undefined;
@ApiProperty()
adminName: string;
@ApiProperty()
@IsEmail()
adminMail: string;
@ApiProperty()
@IsAdminPasswordvalid()
adminPassword: string;
@ApiProperty({ description: '同意済み利用規約のバージョン' })
acceptedTermsVersion: string;
@ -120,6 +131,18 @@ export class GetTypistGroupsResponse {
typistGroups: TypistGroup[];
}
export class CreateTypistGroupRequest {
@ApiProperty({ minLength: 1, maxLength: 50 })
@MinLength(1)
@MaxLength(50)
typistGroupName: string;
@ApiProperty({ minItems: 1 })
@ArrayMinSize(1)
typistIds: number[];
}
export class CreateTypistGroupResponse {}
export class CreatePartnerAccountRequest {
@ApiProperty()
companyName: string;
@ -189,9 +212,15 @@ export class GetPartnerLicensesResponse {
childrenPartnerLicenses: PartnerLicenseInfo[];
}
// 第五階層のshortage算出用
export class PartnerLicenseInfoForShortage {
expiringSoonLicense?: number;
allocatableLicenseWithMargin?: number;
}
// RepositoryからPartnerLicenseInfoに関する情報を取得する際の型
export type PartnerLicenseInfoForRepository = Omit<
PartnerLicenseInfo,
PartnerLicenseInfo & PartnerLicenseInfoForShortage,
'shortage'
>;

View File

@ -389,7 +389,7 @@ describe('音声ファイルダウンロードURL取得', () => {
it('Typistの場合、自身が担当するタスクでない場合エラー', async () => {
const { accountId } = await createAccount(source);
const { externalId, userId } = await createUser(
const { externalId } = await createUser(
source,
accountId,
'typist-user-external-id',

View File

@ -232,15 +232,11 @@ export class FilesService {
try {
// 国に応じたリージョンのBlobストレージにコンテナが存在するか確認
const isContainerExist = await this.blobStorageService.containerExists(
await this.blobStorageService.containerExists(
context,
accountId,
country,
);
//TODO [Task2241] コンテナが無ければ作成しているが、アカウント登録時に作成するので本処理は削除予定。
if (!isContainerExist) {
await this.blobStorageService.createContainer(accountId, country);
}
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.log(

View File

@ -1,6 +1,7 @@
import {
Body,
Controller,
Get,
HttpStatus,
Post,
Req,
@ -21,6 +22,8 @@ import {
IssueCardLicensesRequest,
ActivateCardLicensesResponse,
ActivateCardLicensesRequest,
GetAllocatableLicensesResponse,
GetAllocatableLicensesRequest,
} from './types/types';
import { Request } from 'express';
import { retrieveAuthorizationToken } from '../../common/http/helper';
@ -29,6 +32,7 @@ import { AuthGuard } from '../../common/guards/auth/authguards';
import { RoleGuard } from '../../common/guards/role/roleguards';
import { ADMIN_ROLES, TIERS } from '../../constants';
import jwt from 'jsonwebtoken';
import { makeContext } from '../../common/log';
@ApiTags('licenses')
@Controller('licenses')
@ -169,4 +173,47 @@ export class LicensesController {
return {};
}
@ApiResponse({
status: HttpStatus.OK,
type: GetAllocatableLicensesResponse,
description: '成功時のレスポンス',
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: '認証エラー',
type: ErrorResponse,
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: '想定外のサーバーエラー',
type: ErrorResponse,
})
@ApiOperation({
operationId: 'getAllocatableLicenses',
description: '割り当て可能なライセンスを取得します',
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], tiers: [TIERS.TIER5] }),
)
@Get('/allocatable')
async getAllocatableLicenses(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@Req() req: Request,
): Promise<GetAllocatableLicensesResponse> {
const token = retrieveAuthorizationToken(req);
const payload = jwt.decode(token, { json: true }) as AccessToken;
const context = makeContext(payload.userId);
const allocatableLicenses =
await this.licensesService.getAllocatableLicenses(
context,
payload.userId,
);
return allocatableLicenses;
}
}

View File

@ -4,6 +4,7 @@ import {
IssueCardLicensesRequest,
IssueCardLicensesResponse,
ActivateCardLicensesRequest,
NewAllocatedLicenseExpirationDate,
} from './types/types';
import {
makeDefaultAccountsRepositoryMockValue,
@ -27,10 +28,16 @@ import {
createCardLicense,
createLicense,
createCardLicenseIssue,
createLicenseAllocationHistory,
selectCardLicensesCount,
selectCardLicense,
selectLicense,
selectLicenseAllocationHistory,
} from './test/utility';
import { UsersService } from '../users/users.service';
import { makeContext } from '../../common/log';
import { IsNotIn } from 'class-validator';
import { LICENSE_ALLOCATED_STATUS, LICENSE_TYPE } from '../../constants';
describe('LicensesService', () => {
it('ライセンス注文が完了する', async () => {
@ -324,11 +331,19 @@ describe('DBテスト', () => {
const cardLicenseKey = 'WZCETXC0Z9PQZ9GKRGGY';
const defaultAccountId = 150;
const licenseId = 50;
const license_id = 50;
const issueId = 100;
await createLicense(source, licenseId, defaultAccountId);
await createCardLicense(source, licenseId, issueId, cardLicenseKey);
await createLicense(
source,
license_id,
null,
defaultAccountId,
LICENSE_TYPE.CARD,
LICENSE_ALLOCATED_STATUS.UNALLOCATED,
null,
);
await createCardLicense(source, license_id, issueId, cardLicenseKey);
await createCardLicenseIssue(source, issueId);
const service = module.get<LicensesService>(LicensesService);
@ -338,10 +353,494 @@ describe('DBテスト', () => {
source,
cardLicenseKey,
);
const dbSelectResultFromLicense = await selectLicense(source, licenseId);
const dbSelectResultFromLicense = await selectLicense(source, license_id);
expect(
dbSelectResultFromCardLicense.cardLicense.activated_at,
).toBeDefined();
expect(dbSelectResultFromLicense.license.account_id).toEqual(accountId);
});
it('取込可能なライセンスのみが取得できる', async () => {
const module = await makeTestingModule(source);
const now = new Date();
const { accountId } = await createAccount(source);
const { externalId } = await createUser(
source,
accountId,
'userId',
'admin',
);
// ライセンスを作成する
// 1件目
await createLicense(
source,
1,
new Date(now.getTime() + 60 * 60 * 1000),
accountId,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.UNALLOCATED,
null,
);
// 2件目、expiry_dateがnull(OneYear)
await createLicense(
source,
2,
null,
accountId,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.UNALLOCATED,
null,
);
// 3件目、1件目と同じ有効期限
await createLicense(
source,
3,
new Date(now.getTime() + 60 * 60 * 1000),
accountId,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.UNALLOCATED,
null,
);
// 4件目、expiry_dateが一番遠いデータ
await createLicense(
source,
4,
new Date(now.getTime() + 60 * 60 * 1000 * 2),
accountId,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.UNALLOCATED,
null,
);
// 5件目、expiry_dateがnull(OneYear)
await createLicense(
source,
5,
null,
accountId,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.UNALLOCATED,
null,
);
// 6件目、ライセンス状態が割当済
await createLicense(
source,
6,
new Date(now.getTime() + 60 * 60 * 1000 * 2),
accountId,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.ALLOCATED,
null,
);
// 7件目、ライセンス状態が削除済
await createLicense(
source,
7,
new Date(now.getTime() + 60 * 60 * 1000 * 2),
accountId,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.DELETED,
null,
);
// 8件目、別アカウントの未割当のライセンス
await createLicense(
source,
8,
new Date(now.getTime() + 60 * 60 * 1000),
accountId + 1,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.UNALLOCATED,
null,
);
// 9件目、有効期限切れのライセンス
await createLicense(
source,
9,
new Date(now.getTime() - 60 * 60 * 1000 * 24),
accountId,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.UNALLOCATED,
null,
);
const service = module.get<LicensesService>(LicensesService);
const context = makeContext('userId');
const response = await service.getAllocatableLicenses(context, externalId);
// 対象外のデータは取得していないことを確認する
expect(response.allocatableLicenses.length).toBe(5);
// ソートして取得されていることを確認する
// expiry_dateがnullを最優先、次に有効期限が遠い順(同じ有効期限の場合はID昇順)
expect(response.allocatableLicenses[0].licenseId).toBe(2);
expect(response.allocatableLicenses[1].licenseId).toBe(5);
expect(response.allocatableLicenses[2].licenseId).toBe(4);
expect(response.allocatableLicenses[3].licenseId).toBe(1);
expect(response.allocatableLicenses[4].licenseId).toBe(3);
});
});
describe('ライセンス割り当て', () => {
let source: DataSource = null;
beforeEach(async () => {
source = new DataSource({
type: 'sqlite',
database: ':memory:',
logging: false,
entities: [__dirname + '/../../**/*.entity{.ts,.js}'],
synchronize: true, // trueにすると自動的にmigrationが行われるため注意
});
return source.initialize();
});
afterEach(async () => {
await source.destroy();
source = null;
});
it('未割当のライセンスに対して、ライセンス割り当てが完了する', async () => {
const module = await makeTestingModule(source);
const { accountId } = await createAccount(source);
const { userId } = await createUser(source, accountId, 'userId', 'admin');
await createLicense(
source,
1,
null,
accountId,
LICENSE_TYPE.CARD,
LICENSE_ALLOCATED_STATUS.UNALLOCATED,
null,
);
await createLicenseAllocationHistory(source, 1, userId, 1, 'NONE');
const service = module.get<UsersService>(UsersService);
const expiry_date = new NewAllocatedLicenseExpirationDate();
await service.allocateLicense(makeContext('trackingId'), userId, 1);
const resultLicense = await selectLicense(source, 1);
expect(resultLicense.license.allocated_user_id).toBe(userId);
expect(resultLicense.license.status).toBe(
LICENSE_ALLOCATED_STATUS.ALLOCATED,
);
expect(resultLicense.license.expiry_date.setMilliseconds(0)).toEqual(
expiry_date.setMilliseconds(0),
);
const licenseAllocationHistory = await selectLicenseAllocationHistory(
source,
userId,
1,
);
expect(licenseAllocationHistory.licenseAllocationHistory.user_id).toBe(
userId,
);
expect(licenseAllocationHistory.licenseAllocationHistory.license_id).toBe(
1,
);
expect(licenseAllocationHistory.licenseAllocationHistory.is_allocated).toBe(
true,
);
});
it('再割り当て可能なライセンスに対して、ライセンス割り当てが完了する', async () => {
const module = await makeTestingModule(source);
const { accountId } = await createAccount(source);
const { userId } = await createUser(source, accountId, 'userId', 'admin');
const date = new Date();
date.setDate(date.getDate() + 30);
await createLicense(
source,
1,
date,
accountId,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.REUSABLE,
null,
);
await createLicenseAllocationHistory(source, 1, userId, 1, 'NONE');
const service = module.get<UsersService>(UsersService);
await service.allocateLicense(makeContext('trackingId'), userId, 1);
const result = await selectLicense(source, 1);
expect(result.license.allocated_user_id).toBe(userId);
expect(result.license.status).toBe(LICENSE_ALLOCATED_STATUS.ALLOCATED);
expect(result.license.expiry_date).toEqual(date);
const licenseAllocationHistory = await selectLicenseAllocationHistory(
source,
userId,
1,
);
expect(licenseAllocationHistory.licenseAllocationHistory.user_id).toBe(
userId,
);
expect(licenseAllocationHistory.licenseAllocationHistory.license_id).toBe(
1,
);
expect(licenseAllocationHistory.licenseAllocationHistory.is_allocated).toBe(
true,
);
});
it('未割当のライセンスに対して、別のライセンスが割り当てられているユーザーの割り当てが完了する', async () => {
const module = await makeTestingModule(source);
const { accountId } = await createAccount(source);
const { userId } = await createUser(source, accountId, 'userId', 'admin');
const date = new Date();
date.setDate(date.getDate() + 30);
await createLicense(
source,
1,
date,
accountId,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.ALLOCATED,
userId,
);
await createLicense(
source,
2,
null,
accountId,
LICENSE_TYPE.CARD,
LICENSE_ALLOCATED_STATUS.UNALLOCATED,
null,
);
await createLicenseAllocationHistory(source, 1, userId, 1, 'NONE');
const service = module.get<UsersService>(UsersService);
const expiry_date = new NewAllocatedLicenseExpirationDate();
await service.allocateLicense(makeContext('trackingId'), userId, 2);
// もともと割り当てられていたライセンスの状態確認
const result1 = await selectLicense(source, 1);
expect(result1.license.allocated_user_id).toBe(null);
expect(result1.license.status).toBe(LICENSE_ALLOCATED_STATUS.REUSABLE);
expect(result1.license.expiry_date).toEqual(date);
const licenseAllocationHistory = await selectLicenseAllocationHistory(
source,
userId,
1,
);
expect(licenseAllocationHistory.licenseAllocationHistory.user_id).toBe(
userId,
);
expect(licenseAllocationHistory.licenseAllocationHistory.license_id).toBe(
1,
);
expect(licenseAllocationHistory.licenseAllocationHistory.is_allocated).toBe(
false,
);
// 新たに割り当てたライセンスの状態確認
const result2 = await selectLicense(source, 2);
expect(result2.license.allocated_user_id).toBe(userId);
expect(result2.license.status).toBe(LICENSE_ALLOCATED_STATUS.ALLOCATED);
expect(result2.license.expiry_date.setMilliseconds(0)).toEqual(
expiry_date.setMilliseconds(0),
);
const newlicenseAllocationHistory = await selectLicenseAllocationHistory(
source,
userId,
2,
);
expect(newlicenseAllocationHistory.licenseAllocationHistory.user_id).toBe(
userId,
);
expect(
newlicenseAllocationHistory.licenseAllocationHistory.license_id,
).toBe(2);
expect(
newlicenseAllocationHistory.licenseAllocationHistory.is_allocated,
).toBe(true);
});
it('割り当て時にライセンス履歴テーブルへの登録が完了する元がNORMALのとき', async () => {
const module = await makeTestingModule(source);
const { accountId } = await createAccount(source);
const { userId } = await createUser(source, accountId, 'userId', 'admin');
const date = new Date();
date.setDate(date.getDate() + 30);
await createLicense(
source,
1,
date,
accountId,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.ALLOCATED,
userId,
);
await createLicense(
source,
2,
null,
accountId,
LICENSE_TYPE.CARD,
LICENSE_ALLOCATED_STATUS.UNALLOCATED,
null,
);
await createLicenseAllocationHistory(source, 1, userId, 1, 'NONE');
const service = module.get<UsersService>(UsersService);
await service.allocateLicense(makeContext('trackingId'), userId, 2);
const licenseAllocationHistory = await selectLicenseAllocationHistory(
source,
userId,
2,
);
expect(
licenseAllocationHistory.licenseAllocationHistory.switch_from_type,
).toBe('NONE');
});
it('割り当て時にライセンス履歴テーブルへの登録が完了する元がCARDのとき', async () => {
const module = await makeTestingModule(source);
const { accountId } = await createAccount(source);
const { userId } = await createUser(source, accountId, 'userId', 'admin');
const date = new Date();
date.setDate(date.getDate() + 30);
await createLicense(
source,
1,
date,
accountId,
LICENSE_TYPE.CARD,
LICENSE_ALLOCATED_STATUS.ALLOCATED,
userId,
);
await createLicense(
source,
2,
null,
accountId,
LICENSE_TYPE.CARD,
LICENSE_ALLOCATED_STATUS.UNALLOCATED,
null,
);
await createLicenseAllocationHistory(source, 1, userId, 1, 'CARD');
const service = module.get<UsersService>(UsersService);
await service.allocateLicense(makeContext('trackingId'), userId, 2);
const licenseAllocationHistory = await selectLicenseAllocationHistory(
source,
userId,
2,
);
expect(
licenseAllocationHistory.licenseAllocationHistory.switch_from_type,
).toBe('CARD');
});
it('割り当て時にライセンス履歴テーブルへの登録が完了する元がTRIALのとき', async () => {
const module = await makeTestingModule(source);
const { accountId } = await createAccount(source);
const { userId } = await createUser(source, accountId, 'userId', 'admin');
const date = new Date();
date.setDate(date.getDate() + 30);
await createLicense(
source,
1,
date,
accountId,
LICENSE_TYPE.TRIAL,
LICENSE_ALLOCATED_STATUS.ALLOCATED,
userId,
);
await createLicense(
source,
2,
null,
accountId,
LICENSE_TYPE.CARD,
LICENSE_ALLOCATED_STATUS.UNALLOCATED,
null,
);
await createLicenseAllocationHistory(source, 1, userId, 1, 'TRIAL');
const service = module.get<UsersService>(UsersService);
await service.allocateLicense(makeContext('trackingId'), userId, 2);
const licenseAllocationHistory = await selectLicenseAllocationHistory(
source,
userId,
2,
);
expect(
licenseAllocationHistory.licenseAllocationHistory.switch_from_type,
).toBe('TRIAL');
});
it('有効期限が切れているライセンスを割り当てようとした場合、エラーになる', async () => {
const module = await makeTestingModule(source);
const { accountId } = await createAccount(source);
const { userId } = await createUser(source, accountId, 'userId', 'admin');
const date = new Date();
date.setDate(date.getDate() - 30);
await createLicense(
source,
1,
date,
accountId,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.REUSABLE,
null,
);
const service = module.get<UsersService>(UsersService);
await expect(
service.allocateLicense(makeContext('trackingId'), userId, 1),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010805'), HttpStatus.BAD_REQUEST),
);
});
it('割り当て不可なライセンスを割り当てようとした場合、エラーになる', async () => {
const module = await makeTestingModule(source);
const { accountId } = await createAccount(source);
const { userId } = await createUser(source, accountId, 'userId', 'admin');
const date = new Date();
date.setDate(date.getDate() + 30);
await createLicense(
source,
1,
null,
accountId,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.ALLOCATED,
null,
);
await createLicense(
source,
2,
null,
accountId,
LICENSE_TYPE.NORMAL,
LICENSE_ALLOCATED_STATUS.DELETED,
null,
);
const service = module.get<UsersService>(UsersService);
await expect(
service.allocateLicense(makeContext('trackingId'), userId, 1),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010806'), HttpStatus.BAD_REQUEST),
);
await expect(
service.allocateLicense(makeContext('trackingId'), userId, 2),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010806'), HttpStatus.BAD_REQUEST),
);
});
});

View File

@ -11,7 +11,11 @@ import {
} from '../../repositories/licenses/errors/types';
import { LicensesRepositoryService } from '../../repositories/licenses/licenses.repository.service';
import { UserNotFoundError } from '../../repositories/users/errors/types';
import { IssueCardLicensesResponse } from './types/types';
import {
GetAllocatableLicensesResponse,
IssueCardLicensesResponse,
} from './types/types';
import { Context } from '../../common/log';
@Injectable()
export class LicensesService {
@ -211,4 +215,44 @@ export class LicensesService {
this.logger.log(`[OUT] ${this.activateCardLicenseKey.name}`);
return;
}
/**
* get allocatable lisences
* @param context
* @param userId
* @@returns AllocatableLicenseInfo[]
*/
async getAllocatableLicenses(
context: Context,
userId: string,
): Promise<GetAllocatableLicensesResponse> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.getAllocatableLicenses.name} | params: { ` +
`userId: ${userId}, `,
);
// ユーザIDからアカウントIDを取得する
try {
const myAccountId = (
await this.usersRepository.findUserByExternalId(userId)
).account_id;
// 割り当て可能なライセンスを取得する
const allocatableLicenses =
await this.licensesRepository.getAllocatableLicenses(myAccountId);
return {
allocatableLicenses,
};
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.error('get allocatable lisences failed');
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.getAllocatableLicenses.name}`,
);
}
}
}

View File

@ -5,6 +5,7 @@ import {
License,
CardLicense,
CardLicenseIssue,
LicenseAllocationHistory,
} from '../../../repositories/licenses/entity/license.entity';
export const createAccount = async (
@ -58,15 +59,19 @@ export const createUser = async (
export const createLicense = async (
datasource: DataSource,
licenseId: number,
expiry_date: Date,
accountId: number,
type: string,
status: string,
allocated_user_id: number,
): Promise<void> => {
const { identifiers } = await datasource.getRepository(License).insert({
id: licenseId,
expiry_date: null,
expiry_date: expiry_date,
account_id: accountId,
type: 'card',
status: 'Unallocated',
allocated_user_id: null,
type: type,
status: status,
allocated_user_id: allocated_user_id,
order_id: null,
deleted_at: null,
delete_order_id: null,
@ -114,6 +119,31 @@ export const createCardLicenseIssue = async (
identifiers.pop() as CardLicenseIssue;
};
export const createLicenseAllocationHistory = async (
datasource: DataSource,
historyId: number,
userId: number,
licenseId: number,
type: string,
): Promise<void> => {
const { identifiers } = await datasource
.getRepository(LicenseAllocationHistory)
.insert({
id: historyId,
user_id: userId,
license_id: licenseId,
is_allocated: true,
executed_at: new Date(),
switch_from_type: type,
deleted_at: null,
created_by: null,
created_at: new Date(),
updated_by: null,
updated_at: new Date(),
});
identifiers.pop() as LicenseAllocationHistory;
};
export const selectCardLicensesCount = async (
datasource: DataSource,
): Promise<{ count: number }> => {
@ -144,3 +174,22 @@ export const selectLicense = async (
});
return { license };
};
export const selectLicenseAllocationHistory = async (
datasource: DataSource,
userId: number,
licence_id: number,
): Promise<{ licenseAllocationHistory: LicenseAllocationHistory }> => {
const licenseAllocationHistory = await datasource
.getRepository(LicenseAllocationHistory)
.findOne({
where: {
user_id: userId,
license_id: licence_id,
},
order: {
executed_at: 'DESC',
},
});
return { licenseAllocationHistory };
};

View File

@ -1,5 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, Matches, Max, Min, Length } from 'class-validator';
import {
LICENSE_EXPIRATION_DAYS,
LICENSE_EXPIRATION_THRESHOLD_DAYS,
TRIAL_LICENSE_EXPIRATION_DAYS,
} from '../../../constants';
export class CreateOrdersRequest {
@ApiProperty()
@ -37,6 +42,19 @@ export class ActivateCardLicensesRequest {
export class ActivateCardLicensesResponse {}
export class GetAllocatableLicensesRequest {}
export class AllocatableLicenseInfo {
@ApiProperty()
licenseId: number;
@ApiProperty()
expiryDate: Date;
}
export class GetAllocatableLicensesResponse {
@ApiProperty({ type: [AllocatableLicenseInfo] })
allocatableLicenses: AllocatableLicenseInfo[];
}
// ライセンス算出用に、その日の始まりの時刻0:00:00.000)の日付を取得する
export class DateWithZeroTime extends Date {
constructor(...args: any[]) {
@ -48,3 +66,44 @@ export class DateWithZeroTime extends Date {
this.setHours(0, 0, 0, 0); // 時分秒を"0:00:00.000"に固定
}
}
// ライセンスの算出用に、閾値となる時刻23:59:59.999)の日付を取得する
export class ExpirationThresholdDate extends Date {
constructor(...args: any[]) {
if (args.length === 0) {
super(); // 引数がない場合、現在の日付で初期化
} else {
super(...(args as [string])); // 引数がある場合、引数をそのままDateクラスのコンストラクタに渡す
}
this.setDate(this.getDate() + LICENSE_EXPIRATION_THRESHOLD_DAYS);
this.setHours(23, 59, 59, 999); // 時分秒を"23:59:59.999"に固定
}
}
// 新規トライアルライセンス発行時の有効期限算出用に、30日後の日付を取得する
export class NewTrialLicenseExpirationDate extends Date {
constructor(...args: any[]) {
if (args.length === 0) {
super(); // 引数がない場合、現在の日付で初期化
} else {
super(...(args as [string])); // 引数がある場合、引数をそのままDateクラスのコンストラクタに渡す
}
this.setDate(this.getDate() + TRIAL_LICENSE_EXPIRATION_DAYS);
this.setHours(23, 59, 59); // 時分秒を"23:59:59"に固定
this.setMilliseconds(0);
}
}
// 新規ライセンス割り当て時の有効期限算出用に、365日後の日付を取得する
export class NewAllocatedLicenseExpirationDate extends Date {
constructor(...args: any[]) {
if (args.length === 0) {
super(); // 引数がない場合、現在の日付で初期化
} else {
super(...(args as [string])); // 引数がある場合、引数をそのままDateクラスのコンストラクタに渡す
}
this.setDate(this.getDate() + LICENSE_EXPIRATION_DAYS);
this.setHours(23, 59, 59); // 時分秒を"23:59:59"に固定
this.setMilliseconds(0);
}
}

View File

@ -8,6 +8,7 @@ import {
import { SendGridService } from '../../../gateways/sendgrid/sendgrid.service';
import { User } from '../../../repositories/users/entity/user.entity';
import { UsersRepositoryService } from '../../../repositories/users/users.repository.service';
import { LicensesRepositoryService } from '../../../repositories/licenses/licenses.repository.service';
import { UsersService } from '../users.service';
import { SortCriteria } from '../../../repositories/sort_criteria/entity/sort_criteria.entity';
import { SortCriteriaRepositoryService } from '../../../repositories/sort_criteria/sort_criteria.repository.service';
@ -31,6 +32,10 @@ export type UsersRepositoryMockValue = {
existsAuthorId: boolean | Error;
};
export type LicensesRepositoryMockValue = {
// empty
};
export type AdB2cMockValue = {
getMetaData: B2cMetadata | Error;
getSignKeySets: JwkSignKey[] | Error;
@ -58,6 +63,7 @@ export type ConfigMockValue = {
export const makeUsersServiceMock = async (
usersRepositoryMockValue: UsersRepositoryMockValue,
licensesRepositoryMockValue: LicensesRepositoryMockValue,
adB2cMockValue: AdB2cMockValue,
sendGridMockValue: SendGridMockValue,
configMockValue: ConfigMockValue,
@ -75,6 +81,8 @@ export const makeUsersServiceMock = async (
switch (token) {
case UsersRepositoryService:
return makeUsersRepositoryMock(usersRepositoryMockValue);
case LicensesRepositoryService:
return makeLicensesRepositoryMock();
case AdB2cService:
return makeAdB2cServiceMock(adB2cMockValue);
case SendGridService:
@ -239,6 +247,12 @@ export const makeUsersRepositoryMock = (value: UsersRepositoryMockValue) => {
};
};
export const makeLicensesRepositoryMock = (): LicensesRepositoryMockValue => {
return {
// empty
};
};
export const makeSendGridMock = (value: SendGridMockValue) => {
const { sendMail, createMailContentFromEmailConfirmForNormalUser } = value;
@ -283,7 +297,7 @@ export const makeDefaultSendGridlValue = (): SendGridMockValue => {
export const makeDefaultConfigValue = (): ConfigMockValue => {
return {
get: `test@example.co.jp`,
get: `test@example.com`,
};
};

View File

@ -38,6 +38,72 @@ import { AdB2cMockValue, makeAdB2cServiceMock } from './users.service.mock';
import { AdB2cService } from '../../../gateways/adb2c/adb2c.service';
import { LICENSE_ALLOCATED_STATUS, LICENSE_TYPE } from '../../../constants';
export const createAccountAndAdminUser = async (
datasource: DataSource,
adminExternalId: string,
): Promise<{
accountId: number;
adminId: number;
role: string;
tier: number;
}> => {
const { identifiers: account_idf } = await datasource
.getRepository(Account)
.insert({
tier: 1,
country: 'JP',
delegation_permission: false,
locked: false,
company_name: 'test inc.',
verified: true,
deleted_at: '',
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
const account = account_idf.pop() as Account;
const { identifiers: user_idf } = await datasource
.getRepository(User)
.insert({
account_id: account.id,
external_id: adminExternalId,
role: 'admin none',
accepted_terms_version: '1.0',
email_verified: true,
auto_renew: true,
license_alert: true,
notification: true,
encryption: true,
encryption_password: 'password',
prompt: true,
created_by: 'test_runner',
created_at: new Date(),
updated_by: 'updater',
updated_at: new Date(),
});
const user = user_idf.pop() as User;
// Accountの管理者を設定する
await datasource.getRepository(Account).update(
{ id: user.account_id },
{
primary_admin_user_id: user.id,
},
);
const accountResult = await getAccount(datasource, account.id);
const userResult = await getUser(datasource, user.id);
return {
accountId: account.id,
adminId: user.id,
role: userResult.role,
tier: accountResult.tier,
};
};
export const createAccount = async (
datasource: DataSource,
): Promise<{ accountId: number }> => {
@ -68,6 +134,7 @@ export const createUser = async (
encryption?: boolean | undefined,
encryption_password?: string | undefined,
prompt?: boolean | undefined,
email_verified?: boolean | undefined,
): Promise<{ userId: number; externalId: string }> => {
const { identifiers } = await datasource.getRepository(User).insert({
account_id: accountId,
@ -75,7 +142,7 @@ export const createUser = async (
role: role,
accepted_terms_version: '1.0',
author_id: author_id,
email_verified: true,
email_verified: email_verified ?? true,
auto_renew: auto_renew,
license_alert: true,
notification: true,
@ -91,6 +158,18 @@ export const createUser = async (
return { userId: user.id, externalId: external_id };
};
/**
* ユーティリティ: 指定IDのアカウントを取得する
* @param dataSource
* @param id ID
* @returns
*/
export const getAccount = async (dataSource: DataSource, id: number) => {
return await dataSource.getRepository(Account).findOne({
where: { id: id },
});
};
export const getUser = async (
datasource: DataSource,
id: number,
@ -103,6 +182,45 @@ export const getUser = async (
return user;
};
export const getLicenses = async (
datasource: DataSource,
account_id: number,
): Promise<License[]> => {
const licenses = await datasource.getRepository(License).find({
where: {
account_id: account_id,
},
});
return licenses;
};
/**
* ユーティリティ: 指定外部IDを持つユーザーを取得する
* @param dataSource
* @param externalId ID
* @returns
*/
export const getUserByExternalId = async (
datasource: DataSource,
externalId: string,
): Promise<User> => {
const user = await datasource.getRepository(User).findOne({
where: {
external_id: externalId,
},
});
return user;
};
/**
* ユーティリティ: すべてのユーザーを取得する
* @param dataSource
* @returns
*/
export const getUsers = async (dataSource: DataSource): Promise<User[]> => {
return await dataSource.getRepository(User).find();
};
/**
*
* @param datasource

View File

@ -239,3 +239,19 @@ export class PostUpdateUserRequest {
}
export class PostUpdateUserResponse {}
export class AllocateLicenseRequest {
@ApiProperty({ description: 'ユーザーID' })
userId: number;
@ApiProperty({ description: '割り当てるライセンスのID' })
newLicenseId: number;
}
export class AllocateLicenseResponse {}
export class DeallocateLicenseRequest {
@ApiProperty({ description: 'ユーザーID' })
userId: number;
}
export class DeallocateLicenseResponse {}

View File

@ -33,6 +33,10 @@ import {
GetSortCriteriaResponse,
PostUpdateUserRequest,
PostUpdateUserResponse,
AllocateLicenseResponse,
AllocateLicenseRequest,
DeallocateLicenseResponse,
DeallocateLicenseRequest,
} from './types/types';
import { UsersService } from './users.service';
import jwt from 'jsonwebtoken';
@ -41,10 +45,11 @@ import {
isSortDirection,
isTaskListSortableAttribute,
} from '../../common/types/sort';
import { ADMIN_ROLES } from '../../constants';
import { ADMIN_ROLES, TIERS } from '../../constants';
import { RoleGuard } from '../../common/guards/role/roleguards';
import { makeContext } from '../../common/log';
import { UserRoles } from '../../common/types/role';
import { v4 as uuidv4 } from 'uuid';
@ApiTags('users')
@Controller('users')
@ -93,8 +98,8 @@ export class UsersController {
async confirmUserAndInitPassword(
@Body() body: ConfirmRequest,
): Promise<ConfirmResponse> {
console.log(body);
await this.usersService.confirmUserAndInitPassword(body.token);
const context = makeContext(uuidv4());
await this.usersService.confirmUserAndInitPassword(context, body.token);
return {};
}
@ -171,8 +176,11 @@ export class UsersController {
const accessToken = retrieveAuthorizationToken(req);
const payload = jwt.decode(accessToken, { json: true }) as AccessToken;
const context = makeContext(payload.userId);
//ユーザ作成処理
await this.usersService.createUser(
context,
payload,
name,
role as UserRoles,
@ -372,4 +380,94 @@ export class UsersController {
);
return {};
}
@ApiResponse({
status: HttpStatus.OK,
type: AllocateLicenseResponse,
description: '成功時のレスポンス',
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: '割り当て失敗時',
type: ErrorResponse,
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: '認証エラー',
type: ErrorResponse,
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: '想定外のサーバーエラー',
type: ErrorResponse,
})
@ApiOperation({
operationId: 'allocateLicense',
description: 'ライセンスを割り当てます',
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], tiers: [TIERS.TIER5] }),
)
@Post('/license/allocate')
async allocateLicense(
@Body() body: AllocateLicenseRequest,
@Req() req: Request,
): Promise<AllocateLicenseResponse> {
const accessToken = retrieveAuthorizationToken(req);
const { userId } = jwt.decode(accessToken, { json: true }) as AccessToken;
const context = makeContext(userId);
await this.usersService.allocateLicense(
context,
body.userId,
body.newLicenseId,
);
return {};
}
@ApiResponse({
status: HttpStatus.OK,
type: DeallocateLicenseResponse,
description: '成功時のレスポンス',
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'すでにライセンスが割り当て解除されている時',
type: ErrorResponse,
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: '認証エラー',
type: ErrorResponse,
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: '想定外のサーバーエラー',
type: ErrorResponse,
})
@ApiOperation({
operationId: 'deallocateLicense',
description: 'ライセンス割り当てを解除します',
})
@ApiBearerAuth()
@UseGuards(AuthGuard)
@UseGuards(
RoleGuard.requireds({ roles: [ADMIN_ROLES.ADMIN], tiers: [TIERS.TIER5] }),
)
@Post('/license/deallocate')
async deallocateLicense(
@Body() body: DeallocateLicenseRequest,
@Req() req: Request,
): Promise<DeallocateLicenseResponse> {
//API実装時に詳細をかいていく
//const accessToken = retrieveAuthorizationToken(req);
//const { userId } = jwt.decode(accessToken, { json: true }) as AccessToken;
//const context = makeContext(userId);
//await this.usersService.deallocateLicense(context, body.userId);
return {};
}
}

View File

@ -4,12 +4,14 @@ import { AdB2cModule } from '../../gateways/adb2c/adb2c.module';
import { SendGridModule } from '../../gateways/sendgrid/sendgrid.module';
import { SortCriteriaRepositoryModule } from '../../repositories/sort_criteria/sort_criteria.repository.module';
import { UsersRepositoryModule } from '../../repositories/users/users.repository.module';
import { LicensesRepositoryModule } from '../../repositories/licenses/licenses.repository.module';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [
UsersRepositoryModule,
LicensesRepositoryModule,
SortCriteriaRepositoryModule,
AdB2cModule,
SendGridModule,

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,7 @@ import {
newUser,
} from '../../repositories/users/entity/user.entity';
import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
import { LicensesRepositoryService } from '../../repositories/licenses/licenses.repository.service';
import { GetRelationsResponse, User } from './types/types';
import {
AuthorIdAlreadyExistsError,
@ -39,11 +40,16 @@ import {
import { DateWithZeroTime } from '../licenses/types/types';
import { Context } from '../../common/log';
import { UserRoles } from '../../common/types/role';
import {
LicenseExpiredError,
LicenseUnavailableError,
} from '../../repositories/licenses/errors/types';
@Injectable()
export class UsersService {
constructor(
private readonly usersRepository: UsersRepositoryService,
private readonly licensesRepository: LicensesRepositoryService,
private readonly sortCriteriaRepository: SortCriteriaRepositoryService,
private readonly adB2cService: AdB2cService,
private readonly configService: ConfigService,
@ -74,7 +80,9 @@ export class UsersService {
try {
// トランザクションで取得と更新をまとめる
const userId = decodedToken.userId;
await this.usersRepository.updateUserVerified(userId);
await this.usersRepository.updateUserVerifiedAndCreateTrialLicense(
userId,
);
} catch (e) {
this.logger.error(e);
if (e instanceof Error) {
@ -115,6 +123,7 @@ export class UsersService {
* @returns void
*/
async createUser(
context: Context,
accessToken: AccessToken,
name: string,
role: UserRoles,
@ -127,7 +136,7 @@ export class UsersService {
encryptionPassword?: string | undefined,
prompt?: boolean | undefined,
): Promise<void> {
this.logger.log(`[IN] ${this.createUser.name}`);
this.logger.log(`[IN] [${context.trackingId}] ${this.createUser.name}`);
//DBよりアクセス者の所属するアカウントIDを取得する
let adminUser: EntityUser;
@ -176,6 +185,7 @@ export class UsersService {
try {
// idpにユーザーを作成
externalUser = await this.adB2cService.createUser(
context,
email,
ramdomPassword,
name,
@ -219,6 +229,10 @@ export class UsersService {
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.error('create user failed');
//リカバリー処理
//Azure AD B2Cに登録したユーザー情報を削除する
await this.deleteB2cUser(externalUser.sub, context);
switch (e.code) {
case 'ER_DUP_ENTRY':
//AuthorID重複エラー
@ -248,11 +262,22 @@ export class UsersService {
);
//SendGridAPIを呼び出してメールを送信する
await this.sendgridService.sendMail(email, from, subject, text, html);
await this.sendgridService.sendMail(
context,
email,
from,
subject,
text,
html,
);
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.error('create user failed');
this.logger.error(`[NOT IMPLEMENT] [RECOVER] delete user: ${newUser.id}`);
//リカバリー処理
//Azure AD B2Cに登録したユーザー情報を削除する
await this.deleteB2cUser(externalUser.sub, context);
// DBからユーザーを削除する
await this.deleteUser(newUser.id, context);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
@ -262,6 +287,35 @@ export class UsersService {
return;
}
// Azure AD B2Cに登録したユーザー情報を削除する
// TODO 「タスク 2452: リトライ処理を入れる箇所を検討し、実装する」の候補
private async deleteB2cUser(externalUserId: string, context: Context) {
try {
await this.adB2cService.deleteUser(externalUserId, context);
this.logger.log(
`[${context.trackingId}] delete externalUser: ${externalUserId}`,
);
} catch (error) {
this.logger.error(`error=${error}`);
this.logger.error(
`[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`,
);
}
}
// DBに登録したユーザー情報を削除する
private async deleteUser(userId: number, context: Context) {
try {
await this.usersRepository.deleteNormalUser(userId);
this.logger.log(`[${context.trackingId}] delete user: ${userId}`);
} catch (error) {
this.logger.error(`error=${error}`);
this.logger.error(
`[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete user: ${userId}`,
);
}
}
// roleを受け取って、roleに応じたnewUserを作成して返却する
private createNewUserInfo(
role: UserRoles,
@ -313,8 +367,13 @@ export class UsersService {
* confirm User And Init Password
* @param token
*/
async confirmUserAndInitPassword(token: string): Promise<void> {
this.logger.log(`[IN] ${this.confirmUserAndInitPassword.name}`);
async confirmUserAndInitPassword(
context: Context,
token: string,
): Promise<void> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.confirmUserAndInitPassword.name}`,
);
const pubKey = getPublicKey(this.configService);
@ -351,7 +410,14 @@ export class UsersService {
const html = `<p>OMDS TOP PAGE URL.<p><a href="${domains}">${domains}"</a><br>temporary password: ${ramdomPassword}`;
// メールを送信
await this.sendgridService.sendMail(email, from, subject, text, html);
await this.sendgridService.sendMail(
context,
email,
from,
subject,
text,
html,
);
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
@ -805,4 +871,51 @@ export class UsersService {
this.logger.log(`[OUT] [${context.trackingId}] ${this.updateUser.name}`);
}
}
/**
*
* @param context
* @param userId
* @param newLicenseId
*/
async allocateLicense(
context: Context,
userId: number,
newLicenseId: number,
): Promise<void> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.allocateLicense.name} | params: { ` +
`userId: ${userId}, ` +
`newLicenseId: ${newLicenseId}, `,
);
try {
await this.licensesRepository.allocateLicense(userId, newLicenseId);
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
case LicenseExpiredError:
throw new HttpException(
makeErrorResponse('E010805'),
HttpStatus.BAD_REQUEST,
);
case LicenseUnavailableError:
throw new HttpException(
makeErrorResponse('E010806'),
HttpStatus.BAD_REQUEST,
);
default:
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.allocateLicense.name}`,
);
}
}
}

View File

@ -56,11 +56,12 @@ export class AdB2cService {
* @returns user
*/
async createUser(
context: Context,
email: string,
password: string,
username: string,
): Promise<{ sub: string } | ConflictError> {
this.logger.log(`[IN] ${this.createUser.name}`);
this.logger.log(`[IN] [${context.trackingId}] ${this.createUser.name}`);
try {
// ユーザをADB2Cに登録
const newUser = await this.graphClient.api('users/').post({
@ -93,7 +94,7 @@ export class AdB2cService {
throw e;
} finally {
this.logger.log(`[OUT] ${this.createUser.name}`);
this.logger.log(`[OUT] [${context.trackingId}] ${this.createUser.name}`);
}
}
@ -232,6 +233,26 @@ export class AdB2cService {
this.logger.log(`[OUT] [${context.trackingId}] ${this.getUsers.name}`);
}
}
/**
* Azure AD B2Cからユーザ情報を削除する
* @param externalId ID
* @param context
*/
async deleteUser(externalId: string, context: Context): Promise<void> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.deleteUser.name} | params: { externalId: ${externalId} };`,
);
try {
// https://learn.microsoft.com/en-us/graph/api/user-delete?view=graph-rest-1.0&tabs=javascript#example
await this.graphClient.api(`users/${externalId}`).delete();
} catch (e) {
this.logger.error(`error=${e}`);
throw e;
} finally {
this.logger.log(`[OUT] [${context.trackingId}] ${this.deleteUser.name}`);
}
}
}
// TODO [Task2002] 文字列の配列を15要素ずつ区切る(この処理も別タスクで削除予定)

View File

@ -51,13 +51,22 @@ export class BlobstorageService {
this.sharedKeyCredentialEU,
);
}
/**
* Creates container
* @param companyName
* @param context
* @param accountId
* @param country
* @returns container
*/
async createContainer(accountId: number, country: string): Promise<void> {
this.logger.log(`[IN] ${this.createContainer.name}`);
async createContainer(
context: Context,
accountId: number,
country: string,
): Promise<void> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.createContainer.name}`,
);
// 国に応じたリージョンでコンテナ名を指定してClientを取得
const containerClient = this.getContainerClient(accountId, country);
@ -69,9 +78,54 @@ export class BlobstorageService {
this.logger.error(`error=${e}`);
throw e;
} finally {
this.logger.log(`[OUT] ${this.createContainer.name}`);
this.logger.log(
`[OUT] [${context.trackingId}] ${this.createContainer.name}`,
);
}
}
/**
*
* @param context
* @param accountId
* @param country
*/
async deleteContainer(
context: Context,
accountId: number,
country: string,
): Promise<void> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.deleteContainer.name}`,
);
try {
// 国に応じたリージョンでコンテナ名を指定してClientを取得
const containerClient = this.getContainerClient(accountId, country);
const { succeeded, errorCode, date } =
await containerClient.deleteIfExists();
this.logger.log(
`succeeded: ${succeeded}, errorCode: ${errorCode}, date: ${date}`,
);
// 失敗時、コンテナが存在しない場合以外はエラーとして例外をスローする
// コンテナ不在の場合のエラーコードは「ContainerNotFound」以下を参照
// https://learn.microsoft.com/ja-jp/rest/api/storageservices/blob-service-error-codes
if (!succeeded && errorCode !== 'ContainerNotFound') {
throw new Error(
`delete blob container failed. succeeded: ${succeeded}, errorCode: ${errorCode}, date: ${date}`,
);
}
} catch (e) {
this.logger.error(`error=${e}`);
throw e;
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.deleteContainer.name}`,
);
}
}
/**
* Containers exists
* @param country

View File

@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { sign } from '../../common/jwt';
import sendgrid from '@sendgrid/mail';
import { getPrivateKey } from '../../common/jwt/jwt';
import { Context } from '../../common/log';
@Injectable()
export class SendGridService {
@ -20,10 +21,15 @@ export class SendGridService {
* @returns
*/
async createMailContentFromEmailConfirm(
context: Context,
accountId: number,
userId: number,
email: string,
): Promise<{ subject: string; text: string; html: string }> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.createMailContentFromEmailConfirm.name}`,
);
const lifetime =
this.configService.get<number>('EMAIL_CONFIRM_LIFETIME') ?? 0;
const privateKey = getPrivateKey(this.configService);
@ -39,6 +45,9 @@ export class SendGridService {
const domains = this.configService.get<string>('APP_DOMAIN');
const path = 'mail-confirm/';
this.logger.log(
`[OUT] [${context.trackingId}] ${this.createMailContentFromEmailConfirm.name}`,
);
return {
subject: 'Verify your new account',
text: `The verification URL. ${domains}${path}?verify=${token}`,
@ -85,17 +94,23 @@ export class SendGridService {
/**
*
* @param accountId ID
* @param userId ID
* @returns user confirm token
* @param context
* @param to
* @param from
* @param subject
* @param text
* @param html
* @returns mail
*/
async sendMail(
context: Context,
to: string,
from: string,
subject: string,
text: string,
html: string,
): Promise<void> {
this.logger.log(`[IN] [${context.trackingId}] ${this.sendMail.name}`);
try {
const res = await sendgrid
.send({
@ -114,8 +129,10 @@ export class SendGridService {
`status code: ${res.statusCode} body: ${JSON.stringify(res.body)}`,
);
} catch (e) {
console.log(JSON.stringify(e));
this.logger.error(e);
throw e;
} finally {
this.logger.log(`[OUT] [${context.trackingId}] ${this.sendMail.name}`);
}
}
}

View File

@ -79,7 +79,7 @@ export class AccountsRepositoryService {
}
/**
*
*
* @param companyName
* @param country
* @param dealerAccountId
@ -92,7 +92,7 @@ export class AccountsRepositoryService {
async createAccount(
companyName: string,
country: string,
dealerAccountId: number | null,
dealerAccountId: number | undefined,
tier: number,
adminExternalUserId: string,
adminUserRole: string,
@ -150,6 +150,27 @@ export class AccountsRepositoryService {
});
}
/**
*
* @param accountId
* @returns delete
*/
async deleteAccount(accountId: number, userId: number): Promise<void> {
await this.dataSource.transaction(async (entityManager) => {
const accountsRepo = entityManager.getRepository(Account);
const usersRepo = entityManager.getRepository(User);
const sortCriteriaRepo = entityManager.getRepository(SortCriteria);
// ソート条件を削除
await sortCriteriaRepo.delete({
user_id: userId,
});
// プライマリ管理者を削除
await usersRepo.delete({ id: userId });
// アカウントを削除
await accountsRepo.delete({ id: accountId });
});
}
/**
* IDからアカウント情報を取得する
* @param id
@ -429,6 +450,7 @@ export class AccountsRepositoryService {
async getPartnerLicense(
id: number,
currentDate: Date,
expiringSoonDate: Date,
offset: number,
limit: number,
): Promise<{
@ -486,6 +508,24 @@ export class AccountsRepositoryService {
entityManager,
);
// 第五の不足数を算出するためのライセンス数情報を取得する
let expiringSoonLicense: number;
let allocatableLicenseWithMargin: number;
if (childAccount.tier === TIERS.TIER5) {
expiringSoonLicense = await this.getExpiringSoonLicense(
entityManager,
childAccount.id,
currentDate,
expiringSoonDate,
);
allocatableLicenseWithMargin =
await this.getAllocatableLicenseWithMargin(
entityManager,
childAccount.id,
expiringSoonDate,
);
}
// 戻り値用の値を設定
const childPartnerLicenseFromRepository: PartnerLicenseInfoForRepository =
{
@ -495,6 +535,8 @@ export class AccountsRepositoryService {
stockLicense: childLicenseOrderStatus.stockLicense,
issuedRequested: childLicenseOrderStatus.issuedRequested,
issueRequesting: childLicenseOrderStatus.issueRequesting,
expiringSoonLicense: expiringSoonLicense,
allocatableLicenseWithMargin: allocatableLicenseWithMargin,
};
childPartnerLicensesFromRepository.push(
@ -517,42 +559,6 @@ export class AccountsRepositoryService {
});
}
/**
*
* @param id
* @param currentDate
* @param expiringSoonDate
* @returns expiringSoonLicense
* @returns expiringSoonDate
*/
async getLicenseCountForShortage(
id: number,
currentDate: Date,
expiringSoonDate: Date,
): Promise<{
expiringSoonLicense: number;
allocatableLicenseWithMargin: number;
}> {
return await this.dataSource.transaction(async (entityManager) => {
const expiringSoonLicense = await this.getExpiringSoonLicense(
entityManager,
id,
currentDate,
expiringSoonDate,
);
const allocatableLicenseWithMargin =
await this.getAllocatableLicenseWithMargin(
entityManager,
id,
expiringSoonDate,
);
return {
expiringSoonLicense: expiringSoonLicense,
allocatableLicenseWithMargin: allocatableLicenseWithMargin,
};
});
}
/**
* Dealer(Tier4)
* @returns dealer accounts

View File

@ -22,16 +22,16 @@ export class Account {
@Column()
country: string;
@Column()
@Column({ default: false })
delegation_permission: boolean;
@Column()
@Column({ default: false })
locked: boolean;
@Column()
company_name: string;
@Column()
@Column({ default: false })
verified: boolean;
@Column({ nullable: true })
@ -43,16 +43,16 @@ export class Account {
@Column({ nullable: true })
deleted_at?: Date;
@Column()
created_by: string;
@Column({ nullable: true })
created_by?: string;
@CreateDateColumn()
@CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定
created_at: Date;
@Column()
updated_by: string;
@Column({ nullable: true })
updated_by?: string;
@UpdateDateColumn()
@UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at: Date;
@OneToMany(() => User, (user) => user.id)

View File

@ -6,6 +6,7 @@ import {
UpdateDateColumn,
OneToOne,
JoinColumn,
ManyToOne,
} from 'typeorm';
import { User } from '../../users/entity/user.entity';
@ -164,3 +165,43 @@ export class CardLicense {
@UpdateDateColumn({})
updated_at: Date;
}
@Entity({ name: 'license_allocation_history' })
export class LicenseAllocationHistory {
@PrimaryGeneratedColumn()
id: number;
@Column()
user_id: number;
@Column()
license_id: number;
@Column()
is_allocated: boolean;
@Column()
executed_at: Date;
@Column()
switch_from_type: string;
@Column({ nullable: true })
deleted_at: Date;
@Column({ nullable: true })
created_by: string;
@CreateDateColumn()
created_at: Date;
@Column({ nullable: true })
updated_by: string;
@UpdateDateColumn()
updated_at: Date;
@ManyToOne(() => License, (licenses) => licenses.id)
@JoinColumn({ name: 'license_id' })
license?: License;
}

View File

@ -6,3 +6,15 @@ export class LicenseNotExistError extends Error {}
// 取り込むライセンスが既に取り込み済みのエラー
export class LicenseKeyAlreadyActivatedError extends Error {}
// 注文不在エラー
export class OrderNotFoundError extends Error {}
// 注文発行済エラー
export class AlreadyIssuedError extends Error {}
// ライセンス不足エラー
export class LicensesShortageError extends Error {}
// ライセンス有効期限切れエラー
export class LicenseExpiredError extends Error {}
// ライセンス割り当て不可エラー
export class LicenseUnavailableError extends Error {}

View File

@ -5,6 +5,7 @@ import {
CardLicenseIssue,
License,
LicenseOrder,
LicenseAllocationHistory,
} from './entity/license.entity';
import { LicensesRepositoryService } from './licenses.repository.service';
@ -15,6 +16,7 @@ import { LicensesRepositoryService } from './licenses.repository.service';
License,
CardLicense,
CardLicenseIssue,
LicenseAllocationHistory,
]),
],
providers: [LicensesRepositoryService],

View File

@ -1,10 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, In } from 'typeorm';
import { DataSource, In, IsNull, MoreThanOrEqual } from 'typeorm';
import {
LicenseOrder,
License,
CardLicenseIssue,
CardLicense,
LicenseAllocationHistory,
} from './entity/license.entity';
import {
CARD_LICENSE_LENGTH,
@ -12,12 +13,24 @@ import {
LICENSE_STATUS_ISSUE_REQUESTING,
LICENSE_STATUS_ISSUED,
LICENSE_TYPE,
SWITCH_FROM_TYPE,
TIERS,
} from '../../constants';
import {
PoNumberAlreadyExistError,
LicenseNotExistError,
LicenseKeyAlreadyActivatedError,
LicensesShortageError,
AlreadyIssuedError,
OrderNotFoundError,
LicenseExpiredError,
LicenseUnavailableError,
} from './errors/types';
import {
AllocatableLicenseInfo,
DateWithZeroTime,
} from '../../features/licenses/types/types';
import { NewAllocatedLicenseExpirationDate } from '../../features/licenses/types/types';
@Injectable()
export class LicensesRepositoryService {
@ -90,6 +103,7 @@ export class LicensesRepositoryService {
entityManager.getRepository(CardLicenseIssue);
const licenses = [];
//TODO タスク 2409: カードライセンスのレコード作成がbulkinsertになっていない
// ライセンステーブルを作成するBULK INSERT)
for (let i = 0; i < count; i++) {
const license = new License();
@ -146,6 +160,7 @@ export class LicensesRepositoryService {
}
const cardLicenses = [];
//TODO タスク 2409: カードライセンスのレコード作成がbulkinsertになっていない
// カードライセンステーブルを作成するBULK INSERT)
for (let i = 0; i < count; i++) {
const cardLicense = new CardLicense();
@ -296,4 +311,234 @@ export class LicensesRepositoryService {
};
});
}
/**
*
* @context Context
* @param orderedAccountId
* @param myAccountId
* @param tier
* @param poNumber
*/
async issueLicense(
orderedAccountId: number,
myAccountId: number,
tier: number,
poNumber: string,
): Promise<void> {
const nowDate = new Date();
await this.dataSource.transaction(async (entityManager) => {
const licenseOrderRepo = entityManager.getRepository(LicenseOrder);
const licenseRepo = entityManager.getRepository(License);
const issuingOrder = await licenseOrderRepo.findOne({
where: {
from_account_id: orderedAccountId,
po_number: poNumber,
},
});
// 注文が存在しない場合、エラー
if (!issuingOrder) {
throw new OrderNotFoundError(`No order found for PONumber:${poNumber}`);
}
// 既に発行済みの注文の場合、エラー
if (issuingOrder.status !== LICENSE_STATUS_ISSUE_REQUESTING) {
throw new AlreadyIssuedError(
`An order for PONumber:${poNumber} has already been issued.`,
);
}
// ライセンステーブルのレコードを作成する
const newLicenses = Array.from({ length: issuingOrder.quantity }, () => {
const license = new License();
license.account_id = orderedAccountId;
license.status = LICENSE_ALLOCATED_STATUS.UNALLOCATED;
license.type = LICENSE_TYPE.NORMAL;
license.order_id = issuingOrder.id;
return license;
});
// ライセンス注文テーブルを更新(注文元)
await licenseOrderRepo.update(
{ id: issuingOrder.id },
{
issued_at: nowDate,
status: LICENSE_STATUS_ISSUED,
},
);
// ライセンステーブルを登録(注文元)
await licenseRepo.save(newLicenses);
// 第一階層の場合はストックライセンスの概念が存在しないため、ストックライセンス変更処理は行わない
if (tier !== TIERS.TIER1) {
const licensesToUpdate = await licenseRepo.find({
where: {
account_id: myAccountId,
status: LICENSE_ALLOCATED_STATUS.UNALLOCATED,
type: LICENSE_TYPE.NORMAL,
},
order: {
id: 'ASC',
},
take: newLicenses.length,
});
// 登録したライセンスに対して自身のライセンスが不足していた場合、エラー
if (newLicenses.length > licensesToUpdate.length) {
throw new LicensesShortageError(
`Shortage Licenses.Number of licenses attempted to be issued is ${newLicenses.length}.`,
);
}
for (const licenseToUpdate of licensesToUpdate) {
licenseToUpdate.status = LICENSE_ALLOCATED_STATUS.DELETED;
licenseToUpdate.deleted_at = nowDate;
licenseToUpdate.delete_order_id = issuingOrder.id;
}
// 自身のライセンスを削除(論理削除)する
await licenseRepo.save(licensesToUpdate);
}
});
}
/**
*
* @context Context
* @param accountId
* @param tier
* @return AllocatableLicenseInfo[]
*/
async getAllocatableLicenses(
myAccountId: number,
): Promise<AllocatableLicenseInfo[]> {
const nowDate = new DateWithZeroTime();
const licenseRepo = this.dataSource.getRepository(License);
const queryBuilder = licenseRepo
.createQueryBuilder('license')
.where('license.account_id = :accountId', { accountId: myAccountId })
.andWhere('license.status IN (:...statuses)', {
statuses: [
LICENSE_ALLOCATED_STATUS.UNALLOCATED,
LICENSE_ALLOCATED_STATUS.REUSABLE,
],
})
.andWhere(
'(license.expiry_date >= :nowDate OR license.expiry_date IS NULL)',
{ nowDate },
)
.orderBy('license.expiry_date IS NULL', 'DESC')
.addOrderBy('license.expiry_date', 'DESC')
.addOrderBy('license.id', 'ASC');
const allocatableLicenses = await queryBuilder.getMany();
return allocatableLicenses.map((license) => ({
licenseId: license.id,
expiryDate: license.expiry_date,
}));
}
/**
*
* @param userId
* @param newLicenseId
*/
async allocateLicense(userId: number, newLicenseId: number): Promise<void> {
await this.dataSource.transaction(async (entityManager) => {
const licenseRepo = entityManager.getRepository(License);
const licenseAllocationHistoryRepo = entityManager.getRepository(
LicenseAllocationHistory,
);
// 割り当て対象のライセンス情報を取得
const targetLicense = await licenseRepo.findOne({
where: {
id: newLicenseId,
},
});
// 期限切れの場合はエラー
if (targetLicense.expiry_date) {
const currentDay = new Date();
currentDay.setHours(23, 59, 59, 999);
if (targetLicense.expiry_date < currentDay) {
throw new LicenseExpiredError(
`License is expired. expiration date: ${targetLicense.expiry_date} current Date: ${currentDay}`,
);
}
}
// ライセンス状態が「未割当」「再利用可能」以外の場合はエラー
if (
targetLicense.status === LICENSE_ALLOCATED_STATUS.ALLOCATED ||
targetLicense.status === LICENSE_ALLOCATED_STATUS.DELETED
) {
throw new LicenseUnavailableError(
`License is unavailable. License status: ${targetLicense.status}`,
);
}
// 対象ユーザーのライセンス割り当て状態を取得
const allocatedLicense = await licenseRepo.findOne({
where: {
allocated_user_id: userId,
},
});
// 既にライセンスが割り当てられているなら、割り当てを解除
if (allocatedLicense) {
allocatedLicense.status = LICENSE_ALLOCATED_STATUS.REUSABLE;
allocatedLicense.allocated_user_id = null;
await licenseRepo.save(allocatedLicense);
// ライセンス割り当て履歴テーブルへ登録
const deallocationHistory = new LicenseAllocationHistory();
deallocationHistory.user_id = userId;
deallocationHistory.license_id = allocatedLicense.id;
deallocationHistory.is_allocated = false;
deallocationHistory.executed_at = new Date();
deallocationHistory.switch_from_type = SWITCH_FROM_TYPE.NONE;
await licenseAllocationHistoryRepo.save(deallocationHistory);
}
// ライセンス割り当てを実施
targetLicense.status = LICENSE_ALLOCATED_STATUS.ALLOCATED;
targetLicense.allocated_user_id = userId;
// 有効期限が未設定なら365日後に設定
if (!targetLicense.expiry_date) {
targetLicense.expiry_date = new NewAllocatedLicenseExpirationDate();
}
await licenseRepo.save(targetLicense);
// 直近割り当てたライセンス種別を取得
const oldLicenseType = await licenseAllocationHistoryRepo.findOne({
relations: {
license: true,
},
where: { user_id: userId, is_allocated: true },
order: { executed_at: 'DESC' },
});
let switchFromType = '';
if (oldLicenseType) {
switch (oldLicenseType.license.type) {
case LICENSE_TYPE.CARD:
switchFromType = SWITCH_FROM_TYPE.CARD;
break;
case LICENSE_TYPE.TRIAL:
switchFromType = SWITCH_FROM_TYPE.TRIAL;
break;
default:
switchFromType = SWITCH_FROM_TYPE.NONE;
break;
}
} else {
switchFromType = SWITCH_FROM_TYPE.NONE;
}
// ライセンス割り当て履歴テーブルへ登録
const allocationHistory = new LicenseAllocationHistory();
allocationHistory.user_id = userId;
allocationHistory.license_id = targetLicense.id;
allocationHistory.is_allocated = true;
allocationHistory.executed_at = new Date();
// TODO switchFromTypeの値については「PBI1234: 第一階層として、ライセンス数推移情報をCSV出力したい」で正式対応
allocationHistory.switch_from_type = switchFromType;
await licenseAllocationHistoryRepo.save(allocationHistory);
});
}
}

View File

@ -33,40 +33,40 @@ export class User {
@Column({ nullable: true })
accepted_terms_version?: string;
@Column()
@Column({ default: false })
email_verified: boolean;
@Column()
@Column({ default: true })
auto_renew: boolean;
@Column()
@Column({ default: true })
license_alert: boolean;
@Column()
@Column({ default: true })
notification: boolean;
@Column({ nullable: true })
@Column({ default: false })
encryption?: boolean;
@Column({ nullable: true })
encryption_password?: string;
@Column({ nullable: true })
@Column({ default: false })
prompt?: boolean;
@Column({ nullable: true })
deleted_at?: Date;
@Column()
@Column({ nullable: true })
created_by: string;
@CreateDateColumn()
@CreateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定
created_at: Date;
@Column()
updated_by: string;
@Column({ nullable: true })
updated_by?: string;
@UpdateDateColumn()
@UpdateDateColumn({ default: () => "datetime('now', 'localtime')" }) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at: Date;
@ManyToOne(() => Account, (account) => account.user)

View File

@ -13,37 +13,19 @@ import {
InvalidRoleChangeError,
EncryptionPasswordNeedError,
} from './errors/types';
import { USER_ROLES } from '../../constants';
import {
LICENSE_ALLOCATED_STATUS,
LICENSE_TYPE,
TRIAL_LICENSE_ISSUE_NUM,
USER_ROLES,
} from '../../constants';
import { License } from '../licenses/entity/license.entity';
import { NewTrialLicenseExpirationDate } from '../../features/licenses/types/types';
@Injectable()
export class UsersRepositoryService {
constructor(private dataSource: DataSource) {}
async create(
accountId: number,
externalUserId: string,
role: string,
acceptedTermsVersion: string,
): Promise<User> {
const user = new User();
{
user.account_id = accountId;
user.external_id = externalUserId;
user.role = role;
user.accepted_terms_version = acceptedTermsVersion;
}
const createdEntity = await this.dataSource.transaction(
async (entityManager) => {
const repo = entityManager.getRepository(User);
const newUser = repo.create(user);
const persisted = await repo.save(newUser);
return persisted;
},
);
return createdEntity;
}
/**
*
* @param user
@ -285,6 +267,68 @@ export class UsersRepositoryService {
});
}
/**
* Emailを認証済みにして
* @param id
* @returns user verified and create trial license
*/
async updateUserVerifiedAndCreateTrialLicense(id: number): Promise<void> {
await this.dataSource.transaction(async (entityManager) => {
const userRepo = entityManager.getRepository(User);
const targetUser = await userRepo.findOne({
relations: {
account: true,
},
where: {
id: id,
},
});
// 運用上ユーザがいないことはあり得ないが、プログラム上発生しうるのでエラーとして処理
if (!targetUser) {
throw new UserNotFoundError();
}
if (targetUser.email_verified) {
throw new EmailAlreadyVerifiedError();
}
targetUser.email_verified = true;
await userRepo.update({ id: targetUser.id }, targetUser);
// トライアルライセンス100件を作成する
const licenseRepo = entityManager.getRepository(License);
const licenses: License[] = [];
// トライアルライセンスの有効期限は今日を起算日として30日後の日付が変わるまで
const expiryDate = new NewTrialLicenseExpirationDate();
for (let i = 0; i < TRIAL_LICENSE_ISSUE_NUM; i++) {
const license = new License();
license.expiry_date = expiryDate;
license.account_id = targetUser.account.id;
license.type = LICENSE_TYPE.TRIAL;
license.status = LICENSE_ALLOCATED_STATUS.UNALLOCATED;
licenses.push(license);
}
await licenseRepo
.createQueryBuilder()
.insert()
.into(License)
.values(
licenses.map((value) => ({
expiry_date: value.expiry_date,
account_id: value.account_id,
type: value.type,
status: value.status,
})),
)
.execute();
});
}
/**
* IDを持つユーザーを探す
* @param externalId
@ -337,4 +381,22 @@ export class UsersRepositoryService {
return typists;
});
}
/**
* UserID指定のユーザーとソート条件を同時に削除する
* @param userId
* @returns delete
*/
async deleteNormalUser(userId: number): Promise<void> {
await this.dataSource.transaction(async (entityManager) => {
const usersRepo = entityManager.getRepository(User);
const sortCriteriaRepo = entityManager.getRepository(SortCriteria);
// ソート条件を削除
await sortCriteriaRepo.delete({
user_id: userId,
});
// プライマリ管理者を削除
await usersRepo.delete({ id: userId });
});
}
}