Merged PR 754: データ登録ツール作成+動作確認

## 概要
[Task3571: データ登録ツール作成+動作確認](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3571)

- 移行データの登録ツールを作成しました
  - 入力用jsonファイルの読み込み
  - アカウント・ユーザの登録
    - 既存サービスを移植・微修正し呼び出し
    - rate_limit用のsleep実施
  - ワークタイプ・ライセンス・カードライセンスの登録
- 実行についてはpostmanでの実行を考えており、clientは作成しておりません

## レビューポイント
- 既存サービスからの流用が多いですが、メインの処理はfeatures/registerになるため、こちらをメインに見ていただければと思います。

## UIの変更
- 無し

## 動作確認状況
- ローカルで動作確認

## 補足
- 相談、参考資料などがあれば
This commit is contained in:
masaaki 2024-02-21 01:41:21 +00:00
parent 5adf7ed12e
commit 12d168d14c
50 changed files with 3785 additions and 18 deletions

View File

@ -0,0 +1,36 @@
STAGE=local
NO_COLOR=TRUE
CORS=TRUE
PORT=8280
# 開発環境ではADB2Cが別テナントになる都合上、環境変数を分けている
TENANT_NAME=adb2codmsdev
SIGNIN_FLOW_NAME=b2c_1_signin_dev
ADB2C_TENANT_ID=xxxxxxxx
ADB2C_CLIENT_ID=xxxxxxxx
ADB2C_CLIENT_SECRET=xxxxxxxx
ADB2C_ORIGIN=https://zzzzzzzzzz
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA5IZZNgDew9eGmuFTezwdHYLSaJvUPPIKYoiOeVLD1paWNI51\n7Vkaoh0ngprcKOdv6T1N07V4igK7mOim2zY3yCTR6wcWR3PfFJrl9vh5SOo79koZ\noJb27YiM4jtxfx2dezzp0T2GoNR5rRolPUbWFJXnDe0DVXYXpJLb4LAlF2XAyYX0\nSYKUVUsJnzm5k4xbXtnwPwVbpm0EdswBE6qSfiL9zWk9dvHoKzSnfSDzDFoFcEoV\nchawzYXf/MM1YR4wo5XyzECc6Q5Ah4z522//mBNNaDHv83Yuw3mGShT73iJ0JQdk\nTturshv2Ecma38r6ftrIwNYXw4VVatJM8+GOOQIDAQABAoIBADrwp7u097+dK/tw\nWD61n3DIGAqg/lmFt8X4IH8MKLSE/FKr16CS1bqwOEuIM3ZdUtDeXd9Xs7IsyEPE\n5ZwuXK7DSF0M4+Mj8Ip49Q0Aww9aUoLQU9HGfgN/r4599GTrt31clZXA/6Mlighq\ncOZgCcEfdItz8OMu5SQuOIW4CKkCuaWnPOP26UqZocaXNZfpZH0iFLATMMH/TT8x\nay9ToHTQYE17ijdQ/EOLSwoeDV1CU1CIE3P4YfLJjvpKptly5dTevriHEzBi70Jx\n/KEPUn9Jj2gZafrUxRVhmMbm1zkeYxL3gsqRuTzRjEeeILuZhSJyCkQZyUNARxsg\nQY4DZfECgYEA+YLKUtmYTx60FS6DJ4s31TAsXY8kwhq/lB9E3GBZKDd0DPayXEeK\n4UWRQDTT6MI6fedW69FOZJ5sFLp8HQpcssb4Weq9PCpDhNTx8MCbdH3Um5QR3vfW\naKq/1XM8MDUnx5XcNYd87Aw3azvJAvOPr69as8IPnj6sKaRR9uQjbYUCgYEA6nfV\n5j0qmn0EJXZJblk4mvvjLLoWSs17j9YlrZJlJxXMDFRYtgnelv73xMxOMvcGoxn5\nifs7dpaM2x5EmA6jVU5sYaB/beZGEPWqPYGyjIwXPvUGAAv8Gbnvpp+xlSco/Dum\nIq0w+43ry5/xWh6CjfrvKV0J2bDOiJwPEdu/8iUCgYEAnBBSvL+dpN9vhFAzeOh7\nY71eAqcmNsLEUcG9MJqTKbSFwhYMOewF0iHRWHeylEPokhfBJn8kqYrtz4lVWFTC\n5o/Nh3BsLNXCpbMMIapXkeWiti1HgE9ErPMgSkJpwz18RDpYIqM8X+jEQS6D7HSr\nyxfDg+w+GJza0rEVE3hfMIECgYBw+KZ2VfhmEWBjEHhXE+QjQMR3s320MwebCUqE\nNCpKx8TWF/naVC0MwfLtvqbbBY0MHyLN6d//xpA9r3rLbRojqzKrY2KiuDYAS+3n\nzssRzxoQOozWju+8EYu30/ADdqfXyIHG6X3VZs87AGiQzGyJLmP3oR1y5y7MQa09\nJI16hQKBgHK5uwJhGa281Oo5/FwQ3uYLymbNwSGrsOJXiEu2XwJEXwVi2ELOKh4/\n03pBk3Kva3fIwEK+vCzDNnxShIQqBE76/2I1K1whOfoUehhYvKHGaXl2j70Zz9Ks\nrkGW1cx7p+yDqATDrwHBHTHFh5bUTTn8dN40n0e0W/llurpbBkJM\n-----END RSA PRIVATE KEY-----\n"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5IZZNgDew9eGmuFTezwd\nHYLSaJvUPPIKYoiOeVLD1paWNI517Vkaoh0ngprcKOdv6T1N07V4igK7mOim2zY3\nyCTR6wcWR3PfFJrl9vh5SOo79koZoJb27YiM4jtxfx2dezzp0T2GoNR5rRolPUbW\nFJXnDe0DVXYXpJLb4LAlF2XAyYX0SYKUVUsJnzm5k4xbXtnwPwVbpm0EdswBE6qS\nfiL9zWk9dvHoKzSnfSDzDFoFcEoVchawzYXf/MM1YR4wo5XyzECc6Q5Ah4z522//\nmBNNaDHv83Yuw3mGShT73iJ0JQdkTturshv2Ecma38r6ftrIwNYXw4VVatJM8+GO\nOQIDAQAB\n-----END PUBLIC KEY-----\n"
SENDGRID_API_KEY=xxxxxxxxxxxxxxxx
MAIL_FROM=xxxxx@xxxxx.xxxx
NOTIFICATION_HUB_NAME=ntf-odms-dev
NOTIFICATION_HUB_CONNECT_STRING=XXXXXXXXXXXXXXXXXX
APP_DOMAIN=http://localhost:8081/
STORAGE_TOKEN_EXPIRE_TIME=2
STORAGE_ACCOUNT_NAME_US=saodmsusdev
STORAGE_ACCOUNT_NAME_AU=saodmsaudev
STORAGE_ACCOUNT_NAME_EU=saodmseudev
STORAGE_ACCOUNT_KEY_US=XXXXXXXXXXXXXXXXXXXXXXX
STORAGE_ACCOUNT_KEY_AU=XXXXXXXXXXXXXXXXXXXXXXX
STORAGE_ACCOUNT_KEY_EU=XXXXXXXXXXXXXXXXXXXXXXX
STORAGE_ACCOUNT_ENDPOINT_US=https://AAAAAAAAAAAAA
STORAGE_ACCOUNT_ENDPOINT_AU=https://AAAAAAAAAAAAA
STORAGE_ACCOUNT_ENDPOINT_EU=https://AAAAAAAAAAAAA
ACCESS_TOKEN_LIFETIME_WEB=7200
REFRESH_TOKEN_LIFETIME_WEB=86400
REFRESH_TOKEN_LIFETIME_DEFAULT=2592000
EMAIL_CONFIRM_LIFETIME=86400
REDIS_HOST=redis-cache
REDIS_PORT=6379
REDIS_PASSWORD=omdsredispass
ADB2C_CACHE_TTL=86400

File diff suppressed because it is too large Load Diff

View File

@ -31,6 +31,7 @@
"@nestjs/core": "^9.3.9",
"@nestjs/platform-express": "^9.4.1",
"@nestjs/serve-static": "^3.0.1",
"@nestjs/typeorm": "^9.0.1",
"@nestjs/swagger": "^6.2.1",
"@nestjs/testing": "^9.3.9",
"@nestjs/typeorm": "^10.0.2",
@ -44,7 +45,9 @@
"mysql2": "^3.9.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0",
"swagger-cli": "^4.0.4"
"swagger-cli": "^4.0.4",
"typeorm": "^0.3.10",
"mysql2": "^2.3.3"
},
"devDependencies": {
"@types/express": "^4.17.17",

View File

@ -1,15 +1,29 @@
import { MiddlewareConsumer, Module } from "@nestjs/common";
import { ServeStaticModule } from "@nestjs/serve-static";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { join } from "path";
import { LoggerMiddleware } from "./common/loggerMiddleware";
import { TypeOrmModule } from "@nestjs/typeorm";
import { DeleteModule } from "./features/delete/delete.module";
import { AdB2cModule } from "./gateways/adb2c/adb2c.module";
import { BlobstorageModule } from "./gateways/blobstorage/blobstorage.module";
import { RegisterController } from "./features/register/register.controller";
import { RegisterService } from "./features/register/register.service";
import { RegisterModule } from "./features/register/register.module";
import { AccountsRepositoryModule } from "./repositories/accounts/accounts.repository.module";
import { UsersRepositoryModule } from "./repositories/users/users.repository.module";
import { SortCriteriaRepositoryModule } from "./repositories/sort_criteria/sort_criteria.repository.module";
import { LicensesRepositoryModule } from "./repositories/licenses/licenses.repository.module";
import { WorktypesRepositoryModule } from "./repositories/worktypes/worktypes.repository.module";
import { AccountsController } from "./features/accounts/accounts.controller";
import { AccountsService } from "./features/accounts/accounts.service";
import { AccountsModule } from "./features/accounts/accounts.module";
import { UsersController } from "./features/users/users.controller";
import { UsersService } from "./features/users/users.service";
import { UsersModule } from "./features/users/users.module";
import { DeleteModule } from "./features/delete/delete.module";
import { DeleteRepositoryModule } from "./repositories/delete/delete.repository.module";
import { DeleteController } from "./features/delete/delete.controller";
import { DeleteService } from "./features/delete/delete.service";
import { BlobstorageModule } from "./gateways/blobstorage/blobstorage.module";
@Module({
imports: [
@ -20,6 +34,18 @@ import { BlobstorageModule } from "./gateways/blobstorage/blobstorage.module";
envFilePath: [".env.local", ".env"],
isGlobal: true,
}),
AdB2cModule,
AccountsModule,
UsersModule,
RegisterModule,
AccountsRepositoryModule,
UsersRepositoryModule,
SortCriteriaRepositoryModule,
LicensesRepositoryModule,
WorktypesRepositoryModule,
BlobstorageModule,
DeleteModule,
DeleteRepositoryModule,
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
@ -34,13 +60,9 @@ import { BlobstorageModule } from "./gateways/blobstorage/blobstorage.module";
}),
inject: [ConfigService],
}),
DeleteModule,
AdB2cModule,
BlobstorageModule,
DeleteRepositoryModule,
],
controllers: [DeleteController],
providers: [DeleteService],
controllers: [RegisterController, AccountsController, UsersController, DeleteController],
providers: [RegisterService, AccountsService, UsersService, DeleteService],
})
export class AppModule {
configure(consumer: MiddlewareConsumer) {

View File

@ -0,0 +1,70 @@
/*
E+6
- 1~2...
- 3~4DB...
- 5~6
ex)
E00XXXX : システムエラーDB接続失敗など
E01XXXX : 業務エラー
EXX00XX : 内部エラー
EXX01XX : トークンエラー
EXX02XX : DBエラーDB関連
EXX03XX : ADB2CエラーDB関連
*/
export const ErrorCodes = [
'E009999', // 汎用エラー
'E000101', // トークン形式不正エラー
'E000102', // トークン有効期限切れエラー
'E000103', // トークン非アクティブエラー
'E000104', // トークン署名エラー
'E000105', // トークン発行元エラー
'E000106', // トークンアルゴリズムエラー
'E000107', // トークン不足エラー
'E000108', // トークン権限エラー
'E000301', // ADB2Cへのリクエスト上限超過エラー
'E000401', // IPアドレス未設定エラー
'E000501', // リクエストID未設定エラー
'E010001', // パラメータ形式不正エラー
'E010201', // 未認証ユーザエラー
'E010202', // 認証済ユーザエラー
'E010203', // 管理ユーザ権限エラー
'E010204', // ユーザ不在エラー
'E010205', // DBのRoleが想定外の値エラー
'E010206', // DBのTierが想定外の値エラー
'E010207', // ユーザーのRole変更不可エラー
'E010208', // ユーザーの暗号化パスワード不足エラー
'E010209', // ユーザーの同意済み利用規約バージョンが最新でないエラー
'E010301', // メールアドレス登録済みエラー
'E010302', // authorId重複エラー
'E010401', // PONumber重複エラー
'E010501', // アカウント不在エラー
'E010502', // アカウント情報変更不可エラー
'E010503', // 代行操作不許可エラー
'E010504', // アカウントロックエラー
'E010601', // タスク変更不可エラー(タスクが変更できる状態でない、またはタスクが存在しない)
'E010602', // タスク変更権限不足エラー
'E010603', // タスク不在エラー
'E010701', // Blobファイル不在エラー
'E010801', // ライセンス不在エラー
'E010802', // ライセンス取り込み済みエラー
'E010803', // ライセンス発行済みエラー
'E010804', // ライセンス不足エラー
'E010805', // ライセンス有効期限切れエラー
'E010806', // ライセンス割り当て不可エラー
'E010807', // ライセンス割り当て解除済みエラー
'E010808', // ライセンス注文キャンセル不可エラー
'E010809', // ライセンス発行キャンセル不可エラー(ステータスが変えられている場合)
'E010810', // ライセンス発行キャンセル不可エラー(発行から一定期間経過した場合)
'E010811', // ライセンス発行キャンセル不可エラー(発行したライセンスが割り当てされている場合)
'E010812', // ライセンス未割当エラー
'E010908', // タイピストグループ不在エラー
'E010909', // タイピストグループ名重複エラー
'E011001', // ワークタイプ重複エラー
'E011002', // ワークタイプ登録上限超過エラー
'E011003', // ワークタイプ不在エラー
'E011004', // ワークタイプ使用中エラー
'E012001', // テンプレートファイル不在エラー
'E013001', // ワークフローのAuthorIDとWorktypeIDのペア重複エラー
'E013002', // ワークフロー不在エラー
] as const;

View File

@ -0,0 +1,10 @@
import { errors } from './message';
import { ErrorCodeType, ErrorResponse } from './types/types';
export const makeErrorResponse = (errorcode: ErrorCodeType): ErrorResponse => {
const msg = errors[errorcode];
return {
code: errorcode,
message: msg,
};
};

View File

@ -0,0 +1,59 @@
import { Errors } from './types/types';
// エラーコードとメッセージ対応表
export const errors: Errors = {
E009999: 'Internal Server Error.',
E000101: 'Token invalid format Error.',
E000102: 'Token expired Error.',
E000103: 'Token not before Error',
E000104: 'Token invalid signature Error.',
E000105: 'Token invalid issuer Error.',
E000106: 'Token invalid algorithm Error.',
E000107: 'Token is not exist Error.',
E000108: 'Token authority failed Error.',
E000301: 'ADB2C request limit exceeded Error',
E000401: 'IP address not found Error.',
E000501: 'Request ID not found Error.',
E010001: 'Param invalid format Error.',
E010201: 'Email not verified user Error.',
E010202: 'Email already verified user Error.',
E010203: 'Administrator Permissions Error.',
E010204: 'User not Found Error.',
E010205: 'Role from DB is unexpected value Error.',
E010206: 'Tier from DB is unexpected value Error.',
E010207: 'User role change not allowed Error.',
E010208: 'User encryption password not found Error.',
E010209: 'Accepted term not latest Error.',
E010301: 'This email user already created Error',
E010302: 'This AuthorId already used Error',
E010401: 'This PoNumber already used Error',
E010501: 'Account not Found Error.',
E010502: 'Account information cannot be changed Error.',
E010503: 'Delegation not allowed Error.',
E010504: 'Account is locked Error.',
E010601: 'Task is not Editable Error',
E010602: 'No task edit permissions Error',
E010603: 'Task not found Error.',
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',
E010807: 'License is already deallocated Error',
E010808: 'Order cancel failed Error',
E010809: 'Already license order status changed Error',
E010810: 'Cancellation period expired error',
E010811: 'Already license allocated Error',
E010812: 'License not allocated Error',
E010908: 'Typist Group not exist Error',
E010909: 'Typist Group name already exist Error',
E011001: 'This WorkTypeID already used Error',
E011002: 'WorkTypeID create limit exceeded Error',
E011003: 'WorkTypeID not found Error',
E011004: 'WorkTypeID is in use Error',
E012001: 'Template file not found Error',
E013001: 'AuthorId and WorktypeId pair already exists Error',
E013002: 'Workflow not found Error',
};

View File

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { ErrorCodes } from '../code';
export class ErrorResponse {
@ApiProperty()
message: string;
@ApiProperty()
code: string;
}
export type ErrorCodeType = (typeof ErrorCodes)[number];
export type Errors = {
[P in ErrorCodeType]: string;
};

View File

@ -0,0 +1,32 @@
import { Request } from 'express';
import { Context } from './types';
export const makeContext = (
externalId: string,
requestId: string,
delegationId?: string,
): Context => {
return new Context(externalId, requestId, delegationId);
};
// リクエストヘッダーからrequestIdを取得する
export const retrieveRequestId = (req: Request): string | undefined => {
return req.header('x-request-id');
};
/**
* IPアドレスを取得します
* @param {Request}
* @return {string | undefined}
*/
export const retrieveIp = (req: Request): string | undefined => {
// ローカル環境では直近の送信元IPを取得する
if (process.env.STAGE === 'local') {
return req.ip;
}
const ip = req.header('x-forwarded-for');
if (typeof ip === 'string') {
return ip;
}
return undefined;
};

View File

@ -0,0 +1,4 @@
import { Context } from './types';
import { makeContext, retrieveRequestId, retrieveIp } from './context';
export { Context, makeContext, retrieveRequestId, retrieveIp };

View File

@ -0,0 +1,34 @@
export class Context {
/**
* APIの操作ユーザーを追跡するためのID
*/
trackingId: string;
/**
* APIの操作ユーザーのIPアドレス
*/
ip: string;
/**
* ID
*/
requestId: string;
/**
* APIの代行操作ユーザーを追跡するためのID
*/
delegationId?: string | undefined;
constructor(externalId: string, requestId: string, delegationId?: string) {
this.trackingId = externalId;
this.delegationId = delegationId;
this.requestId = requestId;
}
/**
*
*/
getTrackingId(): string {
if (this.delegationId) {
return `${this.requestId}_${this.trackingId} by ${this.delegationId}`;
} else {
return `${this.requestId}_${this.trackingId}`;
}
}
}

View File

@ -0,0 +1,3 @@
import { makePassword } from './password';
export { makePassword };

View File

@ -0,0 +1,35 @@
export const makePassword = (): string => {
// パスワードの文字数を決定
const passLength = 8;
// パスワードに使用可能な文字を決定(今回はアルファベットの大文字と小文字 数字 symbolsの記号
const lowerCase = 'abcdefghijklmnopqrstuvwxyz';
const upperCase = lowerCase.toLocaleUpperCase();
const numbers = '0123456789';
const symbols = '@#$%^&*\\-_+=[]{}|:\',.?/`~"();!';
const chars = lowerCase + upperCase + numbers + symbols;
// 英字の大文字、英字の小文字、アラビア数字、記号(@#$%^&*\-_+=[]{}|\:',.?/`~"();!から2種類以上組み合わせ
const charaTypePattern =
/^((?=.*[a-z])(?=.*[A-Z])|(?=.*[a-z])(?=.*[\d])|(?=.*[a-z])(?=.*[@#$%^&*\\\-_+=\[\]{}|:',.?\/`~"();!])|(?=.*[A-Z])(?=.*[\d])|(?=.*[A-Z])(?=.*[@#$%^&*\\\-_+=\[\]{}|:',.?\/`~"();!])|(?=.*[\d])(?=.*[@#$%^&*\\\-_+=\[\]{}|:',.?\/`~"();!]))[a-zA-Z\d@#$%^&*\\\-_+=\[\]{}|:',.?\/`~"();!]/;
// autoGeneratedPasswordが以上の条件を満たせばvalidがtrueになる
let valid = false;
let autoGeneratedPassword: string = '';
while (!valid) {
// パスワードをランダムに決定
while (autoGeneratedPassword.length < passLength) {
// 上で決定したcharsの中からランダムに1文字ずつ追加
const index = Math.floor(Math.random() * chars.length);
autoGeneratedPassword += chars[index];
}
// パスワードが上で決定した条件をすべて満たしているかチェック
// 条件を満たすまでループ
valid =
autoGeneratedPassword.length == passLength &&
charaTypePattern.test(autoGeneratedPassword);
}
return autoGeneratedPassword;
};

View File

@ -0,0 +1,143 @@
import {
ObjectLiteral,
Repository,
EntityTarget,
UpdateResult,
DeleteResult,
UpdateQueryBuilder,
Brackets,
FindOptionsWhere,
} from 'typeorm';
import { Context } from '../log';
/**
* VS Code上で型解析エラーが発生するためtypeorm内の型定義と同一の型定義をここに記述する
*/
type QueryDeepPartialEntity<T> = _QueryDeepPartialEntity<
ObjectLiteral extends T ? unknown : T
>;
type _QueryDeepPartialEntity<T> = {
[P in keyof T]?:
| (T[P] extends Array<infer U>
? Array<_QueryDeepPartialEntity<U>>
: T[P] extends ReadonlyArray<infer U>
? ReadonlyArray<_QueryDeepPartialEntity<U>>
: _QueryDeepPartialEntity<T[P]>)
| (() => string);
};
interface InsertEntityOptions {
id: number;
}
const insertEntity = async <T extends InsertEntityOptions & ObjectLiteral>(
entity: EntityTarget<T>,
repository: Repository<T>,
value: QueryDeepPartialEntity<T>,
isCommentOut: boolean,
context: Context,
): Promise<T> => {
let query = repository.createQueryBuilder().insert().into(entity);
if (isCommentOut) {
query = query.comment(
`${context.getTrackingId()}_${new Date().toUTCString()}`,
);
}
const result = await query.values(value).execute();
// result.identifiers[0].idがnumber型でない場合はエラー
if (typeof result.identifiers[0].id !== 'number') {
throw new Error('Failed to insert entity');
}
const where: FindOptionsWhere<T> = { id: result.identifiers[0].id } as T;
// 結果をもとにセレクトする
const inserted = await repository.findOne({
where,
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
if (!inserted) {
throw new Error('Failed to insert entity');
}
return inserted;
};
const insertEntities = async <T extends InsertEntityOptions & ObjectLiteral>(
entity: EntityTarget<T>,
repository: Repository<T>,
values: QueryDeepPartialEntity<T>[],
isCommentOut: boolean,
context: Context,
): Promise<T[]> => {
let query = repository.createQueryBuilder().insert().into(entity);
if (isCommentOut) {
query = query.comment(
`${context.getTrackingId()}_${new Date().toUTCString()}`,
);
}
const result = await query.values(values).execute();
// 挿入するレコードが0で、結果も0であれば、からの配列を返す
if (values.length === 0 && result.identifiers.length === 0) {
return [];
}
// 挿入するレコード数と挿入されたレコード数が一致しない場合はエラー
if (result.identifiers.length !== values.length) {
throw new Error('Failed to insert entities');
}
const where: FindOptionsWhere<T>[] = result.identifiers.map((i) => {
// idがnumber型でない場合はエラー
if (typeof i.id !== 'number') {
throw new Error('Failed to insert entities');
}
return { id: i.id } as T;
});
// 結果をもとにセレクトする
const inserted = await repository.find({
where,
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
if (!inserted) {
throw new Error('Failed to insert entity');
}
return inserted;
};
const updateEntity = async <T extends ObjectLiteral>(
repository: Repository<T>,
criteria:
| string
| ((qb: UpdateQueryBuilder<T>) => string)
| Brackets
| ObjectLiteral
| ObjectLiteral[],
values: QueryDeepPartialEntity<T>,
isCommentOut: boolean,
context: Context,
): Promise<UpdateResult> => {
let query = repository.createQueryBuilder().update();
if (isCommentOut) {
query = query.comment(
`${context.getTrackingId()}_${new Date().toUTCString()}`,
);
}
return await query.set(values).where(criteria).execute();
};
const deleteEntity = async <T extends ObjectLiteral>(
repository: Repository<T>,
criteria: string | Brackets | ObjectLiteral | ObjectLiteral[],
isCommentOut: boolean,
context: Context,
): Promise<DeleteResult> => {
let query = repository.createQueryBuilder().delete();
if (isCommentOut) {
query = query.comment(
`${context.getTrackingId()}_${new Date().toUTCString()}`,
);
}
return await query.where(criteria).execute();
};
export { insertEntity, insertEntities, updateEntity, deleteEntity };

View File

@ -0,0 +1,10 @@
import { ADMIN_ROLES, USER_ROLES } from '../../../constants';
/**
* Token.roleに配置されうる文字列リテラル型
*/
export type Roles =
| (typeof ADMIN_ROLES)[keyof typeof ADMIN_ROLES]
| (typeof USER_ROLES)[keyof typeof USER_ROLES];
export type UserRoles = (typeof USER_ROLES)[keyof typeof USER_ROLES];

View File

@ -0,0 +1,27 @@
import {
TASK_LIST_SORTABLE_ATTRIBUTES,
SORT_DIRECTIONS,
} from '../../../constants';
export type TaskListSortableAttribute =
(typeof TASK_LIST_SORTABLE_ATTRIBUTES)[number];
export type SortDirection = (typeof SORT_DIRECTIONS)[number];
export const isTaskListSortableAttribute = (
arg: string,
): arg is TaskListSortableAttribute => {
const param = arg as TaskListSortableAttribute;
if (TASK_LIST_SORTABLE_ATTRIBUTES.includes(param)) {
return true;
}
return false;
};
export const isSortDirection = (arg: string): arg is SortDirection => {
const param = arg as SortDirection;
if (SORT_DIRECTIONS.includes(param)) {
return true;
}
return false;
};

View File

@ -0,0 +1,11 @@
import { SortDirection, TaskListSortableAttribute } from '.';
export const getDirection = (direction: SortDirection): SortDirection => {
return direction;
};
export const getTaskListSortableAttribute = (
TaskListSortableAttribute: TaskListSortableAttribute,
): TaskListSortableAttribute => {
return TaskListSortableAttribute;
};

View File

@ -0,0 +1,155 @@
export class AccountsInputFile {
accountId: number;
type: number;
companyName: string;
country: string;
dealerAccountId?: number;
adminName: string;
adminMail: string;
userId: number;
}
export class UsersInputFile {
accountId: number;
userId: number;
name: string;
role: string;
authorId: string;
email: string;
}
export class LicensesInputFile {
expiry_date: string;
account_id: number;
type: string;
status: string;
allocated_user_id?: number;
}
export class WorktypesInputFile {
account_id: number;
custom_worktype_id: string;
}
export class CardLicensesInputFile {
license_id: number;
issue_id: number;
card_license_key: string;
activated_at?: string;
created_at?: string;
created_by?: string;
updated_at?: string;
updated_by?: string;
}
export function isAccountsInputFileArray(obj: any): obj is AccountsInputFile[] {
return Array.isArray(obj) && obj.every((item) => isAccountsInputFile(item));
}
export function isAccountsInputFile(obj: any): obj is AccountsInputFile {
return (
typeof obj === "object" &&
obj !== null &&
"accountId" in obj &&
typeof obj.accountId === "number" &&
"type" in obj &&
typeof obj.type === "number" &&
"companyName" in obj &&
typeof obj.companyName === "string" &&
"country" in obj &&
typeof obj.country === "string" &&
("dealerAccountId" in obj
? typeof obj.dealerAccountId === "number"
: true) &&
"adminName" in obj &&
typeof obj.adminName === "string" &&
"adminMail" in obj &&
typeof obj.adminMail === "string" &&
"userId" in obj &&
typeof obj.userId === "number"
);
}
export function isUsersInputFileArray(obj: any): obj is UsersInputFile[] {
return Array.isArray(obj) && obj.every((item) => isUsersInputFile(item));
}
export function isUsersInputFile(obj: any): obj is UsersInputFile {
return (
typeof obj === "object" &&
obj !== null &&
"accountId" in obj &&
"userId" in obj &&
"name" in obj &&
"role" in obj &&
"authorId" in obj &&
"email" in obj &&
typeof obj.accountId === "number" &&
typeof obj.userId === "number" &&
typeof obj.name === "string" &&
typeof obj.role === "string" &&
typeof obj.authorId === "string" &&
typeof obj.email === "string"
);
}
export function isLicensesInputFileArray(obj: any): obj is LicensesInputFile[] {
return Array.isArray(obj) && obj.every((item) => isLicensesInputFile(item));
}
export function isLicensesInputFile(obj: any): obj is LicensesInputFile {
return (
typeof obj === "object" &&
obj !== null &&
"expiry_date" in obj &&
"account_id" in obj &&
"type" in obj &&
"status" in obj &&
typeof obj.expiry_date === "string" &&
typeof obj.account_id === "number" &&
typeof obj.type === "string" &&
typeof obj.status === "string" &&
(obj.allocated_user_id === null ||
typeof obj.allocated_user_id === "number")
);
}
export function isWorktypesInputFileArray(
obj: any
): obj is WorktypesInputFile[] {
return Array.isArray(obj) && obj.every((item) => isWorktypesInputFile(item));
}
export function isWorktypesInputFile(obj: any): obj is WorktypesInputFile {
return (
typeof obj === "object" &&
obj !== null &&
"account_id" in obj &&
"custom_worktype_id" in obj &&
typeof obj.account_id === "number" &&
typeof obj.custom_worktype_id === "string"
);
}
export function isCardLicensesInputFileArray(
obj: any
): obj is CardLicensesInputFile[] {
return (
Array.isArray(obj) && obj.every((item) => isCardLicensesInputFile(item))
);
}
export function isCardLicensesInputFile(
obj: any
): obj is CardLicensesInputFile {
return (
typeof obj === "object" &&
obj !== null &&
"license_id" in obj &&
"issue_id" in obj &&
"card_license_key" in obj &&
typeof obj.license_id === "number" &&
typeof obj.issue_id === "number" &&
typeof obj.card_license_key === "string" &&
(obj.activated_at === null || typeof obj.activated_at === "string") &&
(obj.created_at === null || typeof obj.created_at === "string") &&
(obj.created_by === null || typeof obj.created_by === "string") &&
(obj.updated_at === null || typeof obj.updated_at === "string") &&
(obj.updated_by === null || typeof obj.updated_by === "string")
);
}

View File

@ -1,3 +1,20 @@
/**
*
* @const {number}
*/
export const TIERS = {
//OMDS東京
TIER1: 1,
//OMDS現地法人
TIER2: 2,
//代理店
TIER3: 3,
//販売店
TIER4: 4,
//エンドユーザー
TIER5: 5,
} as const;
/**
* East USに保存する国リスト
* @const {number}
@ -52,6 +69,213 @@ export const BLOB_STORAGE_REGION_EU = [
"GB",
];
/**
*
* @const {string[]}
*/
export const ADMIN_ROLES = {
ADMIN: "admin",
STANDARD: "standard",
} as const;
/**
*
* @const {string[]}
*/
export const USER_ROLES = {
NONE: "none",
AUTHOR: "author",
TYPIST: "typist",
} as const;
/**
*
* @const {string[]}
*/
export const USER_ROLE_ORDERS = [
USER_ROLES.AUTHOR,
USER_ROLES.TYPIST,
USER_ROLES.NONE,
] as string[];
/**
*
* @const {string[]}
*/
export const LICENSE_ISSUE_STATUS = {
ISSUE_REQUESTING: "Issue Requesting",
ISSUED: "Issued",
CANCELED: "Order Canceled",
};
/**
*
* @const {string[]}
*/
export const LICENSE_TYPE = {
TRIAL: "TRIAL",
NORMAL: "NORMAL",
CARD: "CARD",
} as const;
/**
*
* @const {string[]}
*/
export const LICENSE_ALLOCATED_STATUS = {
UNALLOCATED: "Unallocated",
ALLOCATED: "Allocated",
REUSABLE: "Reusable",
DELETED: "Deleted",
} as const;
/**
*
* @const {string[]}
*/
export const SWITCH_FROM_TYPE = {
NONE: "NONE",
CARD: "CARD",
TRIAL: "TRIAL",
} as const;
/**
*
* @const {number}
*/
export const LICENSE_EXPIRATION_THRESHOLD_DAYS = 14;
/**
*
* @const {number}
*/
export const LICENSE_EXPIRATION_DAYS = 365;
/**
* 8
* @const {number}
*/
export const LICENSE_EXPIRATION_TIME_WITH_TIMEZONE = 8;
/**
*
* @const {number}
*/
export const CARD_LICENSE_LENGTH = 20;
/**
*
* @const {string}
*/
export const OPTION_ITEM_NUM = 10;
/**
*
* @const {string[]}
*/
export const TASK_STATUS = {
UPLOADED: "Uploaded",
PENDING: "Pending",
IN_PROGRESS: "InProgress",
FINISHED: "Finished",
BACKUP: "Backup",
} as const;
/**
*
*/
export const TASK_LIST_SORTABLE_ATTRIBUTES = [
"JOB_NUMBER",
"STATUS",
"ENCRYPTION",
"AUTHOR_ID",
"WORK_TYPE",
"FILE_NAME",
"FILE_LENGTH",
"FILE_SIZE",
"RECORDING_STARTED_DATE",
"RECORDING_FINISHED_DATE",
"UPLOAD_DATE",
"TRANSCRIPTION_STARTED_DATE",
"TRANSCRIPTION_FINISHED_DATE",
] as const;
/**
*
*/
export const SORT_DIRECTIONS = ["ASC", "DESC"] as const;
/**
*
* NotificationHubの仕様上タグ式のOR条件で使えるタグは20個まで
* https://learn.microsoft.com/ja-jp/azure/notification-hubs/notification-hubs-tags-segment-push-message#tag-expressions
*/
export const TAG_MAX_COUNT = 20;
/**
*
*/
export const PNS = {
WNS: "wns",
APNS: "apns",
};
/**
*
*/
export const USER_LICENSE_EXPIRY_STATUS = {
NORMAL: "Normal",
NO_LICENSE: "NoLicense",
ALERT: "Alert",
RENEW: "Renew",
};
/**
*
* @const {number}
*/
export const TRIAL_LICENSE_EXPIRATION_DAYS = 30;
/**
*
* @const {number}
*/
export const TRIAL_LICENSE_ISSUE_NUM = 100;
/**
* worktypeの最大登録数
* @const {number}
*/
export const WORKTYPE_MAX_COUNT = 20;
/**
* worktypeのDefault値の取りうる値
**/
export const OPTION_ITEM_VALUE_TYPE = {
DEFAULT: "Default",
BLANK: "Blank",
LAST_INPUT: "LastInput",
} as const;
/**
*
**/
export const OPTION_ITEM_VALUE_TYPE_NUMBER: {
type: string;
value: number;
}[] = [
{
type: OPTION_ITEM_VALUE_TYPE.BLANK,
value: 1,
},
{
type: OPTION_ITEM_VALUE_TYPE.DEFAULT,
value: 2,
},
{
type: OPTION_ITEM_VALUE_TYPE.LAST_INPUT,
value: 3,
},
];
/**
* ADB2Cユーザのidentity.signInType
* @const {string[]}
@ -60,8 +284,52 @@ export const ADB2C_SIGN_IN_TYPE = {
EMAILADDRESS: "emailAddress",
} as const;
/**
* MANUAL_RECOVERY_REQUIRED
* @const {string}
*/
export const MANUAL_RECOVERY_REQUIRED = "[MANUAL_RECOVERY_REQUIRED]";
/**
*
* @const {string[]}
*/
export const TERM_TYPE = {
EULA: "EULA",
DPA: "DPA",
PRIVACY_NOTICE: "PrivacyNotice",
} as const;
/**
*
* @const {string}
*/
export const USER_AUDIO_FORMAT = "DS2(QP)";
/**
* NODE_ENVの値
* @const {string[]}
*/
export const NODE_ENV_TEST = "test";
/**
*
* @const {string[]}
*/
export const USER_LICENSE_STATUS = {
UNALLOCATED: "unallocated",
ALLOCATED: "allocated",
EXPIRED: "expired",
} as const;
/**
* AutoIncrementの初期値
* @const {number}
*/
export const AUTO_INCREMENT_START = 853211;
/**
* sleep間隔
* @const {number}
*/
export const MIGRATION_DATA_REGISTER_INTERVAL_MILLISEC = 13;

View File

@ -0,0 +1,12 @@
import { Controller, Logger } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import { AccountsService } from "./accounts.service";
@ApiTags("accounts")
@Controller("accounts")
export class AccountsController {
private readonly logger = new Logger(AccountsController.name);
constructor(
private readonly accountService: AccountsService //private readonly cryptoService: CryptoService,
) {}
}

View File

@ -0,0 +1,19 @@
import { Module } from "@nestjs/common";
import { UsersRepositoryModule } from "../../repositories/users/users.repository.module";
import { AccountsRepositoryModule } from "../../repositories/accounts/accounts.repository.module";
import { AccountsController } from "./accounts.controller";
import { AccountsService } from "./accounts.service";
import { AdB2cModule } from "../../gateways/adb2c/adb2c.module";
import { BlobstorageModule } from "../../gateways/blobstorage/blobstorage.module";
@Module({
imports: [
AccountsRepositoryModule,
UsersRepositoryModule,
AdB2cModule,
BlobstorageModule,
],
controllers: [AccountsController],
providers: [AccountsService],
})
export class AccountsModule {}

View File

@ -0,0 +1,227 @@
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
import { AccountsRepositoryService } from "../../repositories/accounts/accounts.repository.service";
import {
AdB2cService,
ConflictError,
isConflictError,
} from "../../gateways/adb2c/adb2c.service";
import { Account } from "../../repositories/accounts/entity/account.entity";
import { User } from "../../repositories/users/entity/user.entity";
import { MANUAL_RECOVERY_REQUIRED } from "../../constants";
import { makeErrorResponse } from "../../common/error/makeErrorResponse";
import { Context } from "../../common/log";
import { BlobstorageService } from "../../gateways/blobstorage/blobstorage.service";
@Injectable()
export class AccountsService {
constructor(
private readonly accountRepository: AccountsRepositoryService,
private readonly adB2cService: AdB2cService,
private readonly blobStorageService: BlobstorageService
) {}
private readonly logger = new Logger(AccountsService.name);
/**
* DBに作成する
* @param companyName
* @param country
* @param [dealerAccountId]
* @returns account
*/
async createAccount(
context: Context,
companyName: string,
country: string,
dealerAccountId: number | undefined,
email: string,
password: string,
username: string,
role: string,
acceptedEulaVersion: string,
acceptedPrivacyNoticeVersion: string,
acceptedDpaVersion: string,
type: number,
accountId: number,
userId: number
): Promise<{ accountId: number; userId: number; externalUserId: string }> {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.createAccount.name
} | params: { ` +
`dealerAccountId: ${dealerAccountId}, ` +
`role: ${role}, ` +
`acceptedEulaVersion: ${acceptedEulaVersion}, ` +
`acceptedPrivacyNoticeVersion: ${acceptedPrivacyNoticeVersion}, ` +
`acceptedDpaVersion: ${acceptedDpaVersion}, ` +
`type: ${type}, ` +
`accountId: ${accountId}, ` +
`userId: ${userId} };`
);
try {
let externalUser: { sub: string } | ConflictError;
try {
// idpにユーザーを作成
externalUser = await this.adB2cService.createUser(
context,
email,
password,
username
);
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
this.logger.error(
`[${context.getTrackingId()}] create externalUser failed`
);
throw new HttpException(
makeErrorResponse("E009999"),
HttpStatus.INTERNAL_SERVER_ERROR
);
}
// メールアドレス重複エラー
if (isConflictError(externalUser)) {
this.logger.error(
`[${context.getTrackingId()}] 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(
context,
companyName,
country,
dealerAccountId,
type,
externalUser.sub,
role,
accountId,
userId,
acceptedEulaVersion,
acceptedPrivacyNoticeVersion,
acceptedDpaVersion
);
account = newAccount;
user = adminUser;
this.logger.log(
`[${context.getTrackingId()}] adminUser.external_id: ${
user.external_id
}`
);
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
this.logger.error(`[${context.getTrackingId()}] 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(`[${context.getTrackingId()}] error=${e}`);
this.logger.error(
`[${context.getTrackingId()}] 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
);
}
return {
accountId: account.id,
userId: user.id,
externalUserId: user.external_id,
};
} catch (e) {
throw e;
} finally {
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.createAccount.name}`
);
}
}
// AdB2cのユーザーを削除
// TODO「タスク 2452: リトライ処理を入れる箇所を検討し、実装する」の候補
private async deleteAdB2cUser(
externalUserId: string,
context: Context
): Promise<void> {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.createAccount.name
} | params: { ` + `externalUserId: ${externalUserId}};`
);
try {
await this.adB2cService.deleteUser(externalUserId, context);
this.logger.log(
`[${context.getTrackingId()}] delete externalUser: ${externalUserId} | params: { ` +
`externalUserId: ${externalUserId}, };`
);
} catch (error) {
this.logger.error(`[${context.getTrackingId()}] error=${error}`);
this.logger.error(
`${MANUAL_RECOVERY_REQUIRED} [${context.getTrackingId()}] Failed to delete externalUser: ${externalUserId}`
);
} finally {
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.deleteAdB2cUser.name}`
);
}
}
// DBのアカウントを削除
private async deleteAccount(
accountId: number,
userId: number,
context: Context
): Promise<void> {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.deleteAccount.name
} | params: { accountId: ${accountId}, userId: ${userId} };`
);
try {
await this.accountRepository.deleteAccount(context, accountId, userId);
this.logger.log(
`[${context.getTrackingId()}] delete account: ${accountId}, user: ${userId}`
);
} catch (error) {
this.logger.error(`[${context.getTrackingId()}] error=${error}`);
this.logger.error(
`${MANUAL_RECOVERY_REQUIRED} [${context.getTrackingId()}] Failed to delete account: ${accountId}, user: ${userId}`
);
} finally {
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.deleteAccount.name}`
);
}
}
}

View File

@ -0,0 +1,209 @@
import {
Body,
Controller,
HttpStatus,
Post,
Req,
Logger,
HttpException,
} from "@nestjs/common";
import { makeErrorResponse } from "../../common/error/makeErrorResponse";
import fs from "fs";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { Request } from "express";
import { RegisterRequest, RegisterResponse } from "./types/types";
import { RegisterService } from "./register.service";
import { AccountsService } from "../accounts/accounts.service";
import { UsersService } from "../users/users.service";
import { makeContext } from "../../common/log";
import {
isAccountsInputFileArray,
isUsersInputFileArray,
isLicensesInputFileArray,
isWorktypesInputFileArray,
isCardLicensesInputFileArray,
} from "../../common/types/types";
import { makePassword } from "../../common/password/password";
import {
USER_ROLES,
MIGRATION_DATA_REGISTER_INTERVAL_MILLISEC,
} from "../../constants";
@ApiTags("register")
@Controller("register")
export class RegisterController {
private readonly logger = new Logger(RegisterController.name);
constructor(
private readonly registerService: RegisterService,
private readonly accountsService: AccountsService,
private readonly usersService: UsersService
) {}
@Post()
@ApiResponse({
status: HttpStatus.OK,
type: RegisterResponse,
description: "成功時のレスポンス",
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: "想定外のサーバーエラー",
})
@ApiOperation({ operationId: "dataRegist" })
async dataRegist(
@Body() body: RegisterRequest,
@Req() req: Request
): Promise<RegisterResponse> {
const context = makeContext("iko", "register");
const inputFilePath = body.inputFilePath;
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.dataRegist.name
} | params: { inputFilePath: ${inputFilePath}};`
);
try {
// 読み込みファイルのフルパス
const accouncsFileFullPath = inputFilePath + "accounts.json";
const usersFileFullPath = inputFilePath + "users.json";
const licensesFileFullPath = inputFilePath + "licenses.json";
const worktypesFileFullPath = inputFilePath + "worktypes.json";
const cardLicensesFileFullPath = inputFilePath + "cardLicenses.json";
// ファイル存在チェックと読み込み
if (
!fs.existsSync(accouncsFileFullPath) ||
!fs.existsSync(usersFileFullPath) ||
!fs.existsSync(licensesFileFullPath) ||
!fs.existsSync(worktypesFileFullPath) ||
!fs.existsSync(cardLicensesFileFullPath)
) {
this.logger.error(`file not exists from ${inputFilePath}`);
throw new Error(`file not exists from ${inputFilePath}`);
}
// アカウントの登録用ファイル読み込み
const accountsObject = JSON.parse(
fs.readFileSync(accouncsFileFullPath, "utf8")
);
// 型ガードaccount
if (!isAccountsInputFileArray(accountsObject)) {
throw new Error("input file is not accountsInputFiles");
}
for (const accountsInputFile of accountsObject) {
// ランダムなパスワードを生成する
const ramdomPassword = makePassword();
await this.accountsService.createAccount(
context,
accountsInputFile.companyName,
accountsInputFile.country,
accountsInputFile.dealerAccountId,
accountsInputFile.adminMail,
ramdomPassword,
accountsInputFile.adminName,
"none",
null,
null,
null,
accountsInputFile.type,
accountsInputFile.accountId,
accountsInputFile.userId
);
// ratelimit対応のためsleepを行う
await sleep(MIGRATION_DATA_REGISTER_INTERVAL_MILLISEC);
}
// const accountsInputFiles = accountsObject as AccountsInputFile[];
// ユーザの登録用ファイル読み込み
const usersObject = JSON.parse(
fs.readFileSync(usersFileFullPath, "utf8")
);
// 型ガードuser
if (!isUsersInputFileArray(usersObject)) {
throw new Error("input file is not usersInputFiles");
}
for (const usersInputFile of usersObject) {
this.logger.log(usersInputFile.name);
await this.usersService.createUser(
context,
usersInputFile.name,
usersInputFile.role === USER_ROLES.AUTHOR
? USER_ROLES.AUTHOR
: USER_ROLES.NONE,
usersInputFile.email,
true,
true,
usersInputFile.accountId,
usersInputFile.userId,
usersInputFile.authorId,
false,
null,
true
);
// ratelimit対応のためsleepを行う
await sleep(MIGRATION_DATA_REGISTER_INTERVAL_MILLISEC);
}
// ライセンスの登録用ファイル読み込み
const licensesObject = JSON.parse(
fs.readFileSync(licensesFileFullPath, "utf8")
);
// 型ガードlicense
if (!isLicensesInputFileArray(licensesObject)) {
throw new Error("input file is not licensesInputFiles");
}
// ワークタイプの登録用ファイル読み込み
const worktypesObject = JSON.parse(
fs.readFileSync(worktypesFileFullPath, "utf8")
);
// 型ガードWorktypes
if (!isWorktypesInputFileArray(worktypesObject)) {
throw new Error("input file is not WorktypesInputFiles");
}
// カードライセンスの登録用ファイル読み込み
const cardLicensesObject = JSON.parse(
fs.readFileSync(cardLicensesFileFullPath, "utf8")
);
// 型ガードcardLicenses
if (!isCardLicensesInputFileArray(cardLicensesObject)) {
throw new Error("input file is not cardLicensesInputFiles");
}
// ライセンス・ワークタイプ・カードライセンスの登録
await this.registerService.registLicenseAndWorktypeData(
context,
licensesObject,
worktypesObject,
cardLicensesObject
);
return {};
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
throw new HttpException(
makeErrorResponse("E009999"),
HttpStatus.INTERNAL_SERVER_ERROR
);
} finally {
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.dataRegist.name}`
);
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@ -0,0 +1,25 @@
import { Module } from "@nestjs/common";
import { RegisterController } from "./register.controller";
import { RegisterService } from "./register.service";
import { AccountsService } from "../accounts/accounts.service";
import { UsersService } from "../users/users.service";
import { LicensesRepositoryModule } from "../../repositories/licenses/licenses.repository.module";
import { WorktypesRepositoryModule } from "../../repositories/worktypes/worktypes.repository.module";
import { UsersRepositoryModule } from "../../repositories/users/users.repository.module";
import { AccountsRepositoryModule } from "../../repositories/accounts/accounts.repository.module";
import { AdB2cModule } from "../../gateways/adb2c/adb2c.module";
import { BlobstorageModule } from "../../gateways/blobstorage/blobstorage.module";
@Module({
imports: [
LicensesRepositoryModule,
WorktypesRepositoryModule,
AccountsRepositoryModule,
UsersRepositoryModule,
AdB2cModule,
BlobstorageModule,
],
controllers: [RegisterController],
providers: [RegisterService, AccountsService, UsersService],
})
export class RegisterModule {}

View File

@ -0,0 +1,68 @@
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
import { Context } from "../../common/log";
import {
LicensesInputFile,
WorktypesInputFile,
CardLicensesInputFile,
} from "../../common/types/types";
import { LicensesRepositoryService } from "../../repositories/licenses/licenses.repository.service";
import { WorktypesRepositoryService } from "../../repositories/worktypes/worktypes.repository.service";
import { makeErrorResponse } from "../../common/error/makeErrorResponse";
@Injectable()
export class RegisterService {
constructor(
private readonly licensesRepository: LicensesRepositoryService,
private readonly worktypesRepository: WorktypesRepositoryService
) {}
private readonly logger = new Logger(RegisterService.name);
/**
* Regist Data
* @param inputFilePath: string
*/
async registLicenseAndWorktypeData(
context: Context,
licensesInputFiles: LicensesInputFile[],
worktypesInputFiles: WorktypesInputFile[],
cardlicensesInputFiles: CardLicensesInputFile[]
): Promise<void> {
// パラメータ内容が長大なのでログには出さない
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.registLicenseAndWorktypeData.name
}`
);
try {
this.logger.log("Licenses register start");
await this.licensesRepository.insertLicenses(context, licensesInputFiles);
this.logger.log("Licenses register end");
this.logger.log("Worktypes register start");
await this.worktypesRepository.createWorktype(
context,
worktypesInputFiles
);
this.logger.log("Worktypes register end");
this.logger.log("CardLicenses register start");
await this.licensesRepository.insertCardLicenses(
context,
cardlicensesInputFiles
);
this.logger.log("CardLicenses register end");
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
throw new HttpException(
makeErrorResponse("E009999"),
HttpStatus.INTERNAL_SERVER_ERROR
);
} finally {
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${
this.registLicenseAndWorktypeData.name
}`
);
}
}
}

View File

@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
export class RegisterRequest {
@ApiProperty()
inputFilePath: string;
}
export class RegisterResponse {}

View File

@ -0,0 +1,10 @@
import { Controller, Logger } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import { UsersService } from "./users.service";
@ApiTags("users")
@Controller("users")
export class UsersController {
private readonly logger = new Logger(UsersController.name);
constructor(private readonly usersService: UsersService) {}
}

View File

@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AdB2cModule } from "../../gateways/adb2c/adb2c.module";
import { UsersRepositoryModule } from "../../repositories/users/users.repository.module";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
@Module({
imports: [UsersRepositoryModule, AdB2cModule],
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}

View File

@ -0,0 +1,306 @@
import { HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
import { makeErrorResponse } from "../../common/error/makeErrorResponse";
import { makePassword } from "../../common/password/password";
import {
AdB2cService,
ConflictError,
isConflictError,
} from "../../gateways/adb2c/adb2c.service";
import {
User as EntityUser,
newUser,
} from "../../repositories/users/entity/user.entity";
import { UsersRepositoryService } from "../../repositories/users/users.repository.service";
import { MANUAL_RECOVERY_REQUIRED, USER_ROLES } from "../../constants";
import { Context } from "../../common/log";
import { UserRoles } from "../../common/types/role";
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
constructor(
private readonly usersRepository: UsersRepositoryService,
private readonly adB2cService: AdB2cService
) {}
/**
* Creates user
* @param context
* @param name
* @param role
* @param email
* @param autoRenew
* @param notification
* @param accountId
* @param userid
* @param [authorId]
* @param [encryption]
* @param [encryptionPassword]
* @param [prompt]
* @returns user
*/
async createUser(
context: Context,
name: string,
role: UserRoles,
email: string,
autoRenew: boolean,
notification: boolean,
accountId: number,
userid: number,
authorId?: string | undefined,
encryption?: boolean | undefined,
encryptionPassword?: string | undefined,
prompt?: boolean | undefined
): Promise<void> {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${this.createUser.name} | params: { ` +
`role: ${role}, ` +
`autoRenew: ${autoRenew}, ` +
`notification: ${notification}, ` +
`accountId: ${accountId}, ` +
`userid: ${userid}, ` +
`authorId: ${authorId}, ` +
`encryption: ${encryption}, ` +
`prompt: ${prompt} };`
);
//authorIdが重複していないかチェックする
if (authorId) {
let isAuthorIdDuplicated = false;
try {
isAuthorIdDuplicated = await this.usersRepository.existsAuthorId(
context,
accountId,
authorId
);
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
throw new HttpException(
makeErrorResponse("E009999"),
HttpStatus.INTERNAL_SERVER_ERROR
);
}
if (isAuthorIdDuplicated) {
throw new HttpException(
makeErrorResponse("E010302"),
HttpStatus.BAD_REQUEST
);
}
}
// ランダムなパスワードを生成する
const ramdomPassword = makePassword();
//Azure AD B2Cにユーザーを新規登録する
let externalUser: { sub: string } | ConflictError;
try {
this.logger.log(`name=${name}`);
// idpにユーザーを作成
externalUser = await this.adB2cService.createUser(
context,
email,
ramdomPassword,
name
);
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
this.logger.error(
`[${context.getTrackingId()}] create externalUser failed`
);
throw new HttpException(
makeErrorResponse("E009999"),
HttpStatus.INTERNAL_SERVER_ERROR
);
}
// メールアドレス重複エラー
if (isConflictError(externalUser)) {
throw new HttpException(
makeErrorResponse("E010301"),
HttpStatus.BAD_REQUEST
);
}
//Azure AD B2Cに登録したユーザー情報のID(sub)と受け取った情報を使ってDBにユーザーを登録する
let newUser: EntityUser;
try {
//roleに応じてユーザー情報を作成する
const newUserInfo = this.createNewUserInfo(
context,
userid,
role,
accountId,
externalUser.sub,
autoRenew,
notification,
authorId,
encryption,
encryptionPassword,
prompt
);
// ユーザ作成
newUser = await this.usersRepository.createNormalUser(
context,
newUserInfo
);
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
this.logger.error(`[${context.getTrackingId()}]create user failed`);
//リカバリー処理
//Azure AD B2Cに登録したユーザー情報を削除する
await this.deleteB2cUser(externalUser.sub, context);
switch (e.code) {
case "ER_DUP_ENTRY":
//AuthorID重複エラー
throw new HttpException(
makeErrorResponse("E010302"),
HttpStatus.BAD_REQUEST
);
default:
throw new HttpException(
makeErrorResponse("E009999"),
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.createUser.name}`
);
return;
}
// Azure AD B2Cに登録したユーザー情報を削除する
// TODO 「タスク 2452: リトライ処理を入れる箇所を検討し、実装する」の候補
private async deleteB2cUser(externalUserId: string, context: Context) {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.deleteB2cUser.name
} | params: { externalUserId: ${externalUserId} }`
);
try {
await this.adB2cService.deleteUser(externalUserId, context);
this.logger.log(
`[${context.getTrackingId()}] delete externalUser: ${externalUserId}`
);
} catch (error) {
this.logger.error(`[${context.getTrackingId()}] error=${error}`);
this.logger.error(
`${MANUAL_RECOVERY_REQUIRED} [${context.getTrackingId()}] Failed to delete externalUser: ${externalUserId}`
);
} finally {
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.deleteB2cUser.name}`
);
}
}
// DBに登録したユーザー情報を削除する
private async deleteUser(userId: number, context: Context) {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.deleteUser.name
} | params: { userId: ${userId} }`
);
try {
await this.usersRepository.deleteNormalUser(context, userId);
this.logger.log(`[${context.getTrackingId()}] delete user: ${userId}`);
} catch (error) {
this.logger.error(`[${context.getTrackingId()}] error=${error}`);
this.logger.error(
`${MANUAL_RECOVERY_REQUIRED} [${context.getTrackingId()}] Failed to delete user: ${userId}`
);
} finally {
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.deleteUser.name}`
);
}
}
// roleを受け取って、roleに応じたnewUserを作成して返却する
private createNewUserInfo(
context: Context,
id: number,
role: UserRoles,
accountId: number,
externalId: string,
autoRenew: boolean,
notification: boolean,
authorId?: string | undefined,
encryption?: boolean | undefined,
encryptionPassword?: string | undefined,
prompt?: boolean | undefined
): newUser {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.createNewUserInfo.name
} | params: { ` +
`id: ${id}, ` +
`role: ${role}, ` +
`accountId: ${accountId}, ` +
`authorId: ${authorId}, ` +
`externalId: ${externalId}, ` +
`autoRenew: ${autoRenew}, ` +
`notification: ${notification}, ` +
`authorId: ${authorId}, ` +
`encryption: ${encryption}, ` +
`prompt: ${prompt} };`
);
try {
switch (role) {
case USER_ROLES.NONE:
case USER_ROLES.TYPIST:
return {
id,
account_id: accountId,
external_id: externalId,
auto_renew: autoRenew,
notification,
role,
accepted_dpa_version: null,
accepted_eula_version: null,
accepted_privacy_notice_version: null,
encryption: false,
encryption_password: null,
prompt: false,
author_id: null,
};
case USER_ROLES.AUTHOR:
return {
id,
account_id: accountId,
external_id: externalId,
auto_renew: autoRenew,
notification,
role,
author_id: authorId ?? null,
encryption: encryption ?? false,
encryption_password: encryptionPassword ?? null,
prompt: prompt ?? false,
accepted_dpa_version: null,
accepted_eula_version: null,
accepted_privacy_notice_version: null,
};
default:
//不正なroleが指定された場合はログを出力してエラーを返す
this.logger.error(
`[${context.getTrackingId()}] [NOT IMPLEMENT] [RECOVER] role: ${role}`
);
throw new HttpException(
makeErrorResponse("E009999"),
HttpStatus.INTERNAL_SERVER_ERROR
);
}
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
return e;
} finally {
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.createNewUserInfo.name}`
);
}
}
}

View File

@ -5,6 +5,8 @@ import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { AdB2cResponse, AdB2cUser } from "./types/types";
import { isPromiseRejectedResult } from "./utils/utils";
import { Context } from "../../common/log";
import { ADB2C_SIGN_IN_TYPE } from "../../constants";
export type ConflictError = {
reason: "email";
@ -27,9 +29,16 @@ export const isConflictError = (arg: unknown): arg is ConflictError => {
@Injectable()
export class AdB2cService {
private readonly logger = new Logger(AdB2cService.name);
private readonly tenantName: string;
private readonly flowName: string;
private readonly ttl: number;
private graphClient: Client;
constructor(private readonly configService: ConfigService) {
this.tenantName = this.configService.getOrThrow<string>("TENANT_NAME");
this.flowName = this.configService.getOrThrow<string>("SIGNIN_FLOW_NAME");
this.ttl = this.configService.getOrThrow<number>("ADB2C_CACHE_TTL");
// ADB2Cへの認証情報
const credential = new ClientSecretCredential(
this.configService.getOrThrow<string>("ADB2C_TENANT_ID"),
@ -42,6 +51,61 @@ export class AdB2cService {
this.graphClient = Client.initWithMiddleware({ authProvider });
}
/**
* Creates user AzureADB2Cにユーザーを追加する
* @param email
* @param password
* @param username
* @returns user
*/
async createUser(
context: Context,
email: string,
password: string,
username: string
): Promise<{ sub: string } | ConflictError> {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${this.createUser.name}`
);
try {
// ユーザをADB2Cに登録
const newUser = await this.graphClient.api("users/").post({
accountEnabled: true,
displayName: username,
passwordPolicies: "DisableStrongPassword",
passwordProfile: {
forceChangePasswordNextSignIn: false,
password: password,
},
identities: [
{
signinType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS,
issuer: `${this.tenantName}.onmicrosoft.com`,
issuerAssignedId: email,
},
],
});
return { sub: newUser.id };
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
if (e?.statusCode === 400 && e?.body) {
const error = JSON.parse(e.body);
// エラーが競合エラーである場合は、メールアドレス重複としてエラーを返す
if (error?.details?.find((x) => x.code === "ObjectConflict")) {
return { reason: "email", message: "ObjectConflict" };
}
}
throw e;
} finally {
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.createUser.name}`
);
}
}
/**
* Gets users
* @param externalIds
@ -71,6 +135,44 @@ export class AdB2cService {
}
}
/**
* Azure AD B2Cからユーザ情報を削除する
* @param externalId ID
* @param context
*/
async deleteUser(externalId: string, context: Context): Promise<void> {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
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();
this.logger.log(
`[${context.getTrackingId()}] [ADB2C DELETE] externalId: ${externalId}`
);
// キャッシュからも削除する
// 移行ツール特別対応:キャッシュ登録は行わないので削除も不要
/*
try {
await this.redisService.del(context, makeADB2CKey(externalId));
} catch (e) {
// キャッシュからの削除に失敗しても、ADB2Cからの削除は成功しているため例外はスローしない
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
}
*/
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
throw e;
} finally {
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.deleteUser.name}`
);
}
}
/**
* Azure AD B2Cからユーザ情報を削除する
* @param externalIds ID

View File

@ -1,10 +1,16 @@
import { Injectable, Logger } from "@nestjs/common";
import {
ContainerClient,
BlobServiceClient,
StorageSharedKeyCredential,
} from "@azure/storage-blob";
import {
BLOB_STORAGE_REGION_AU,
BLOB_STORAGE_REGION_EU,
BLOB_STORAGE_REGION_US,
} from "../../constants";
import { ConfigService } from "@nestjs/config";
import { Context } from "../../common/log";
@Injectable()
export class BlobstorageService {
private readonly logger = new Logger(BlobstorageService.name);
@ -41,6 +47,44 @@ export class BlobstorageService {
);
}
/**
* Creates container
* @param context
* @param accountId
* @param country
* @returns container
*/
async createContainer(
context: Context,
accountId: number,
country: string
): Promise<void> {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.createContainer.name
} | params: { ` + `accountId: ${accountId} };`
);
// 国に応じたリージョンでコンテナ名を指定してClientを取得
const containerClient = this.getContainerClient(
context,
accountId,
country
);
try {
// コンテナ作成
await containerClient.create();
} catch (e) {
this.logger.error(`[${context.getTrackingId()}] error=${e}`);
throw e;
} finally {
this.logger.log(
`[OUT] [${context.getTrackingId()}] ${this.createContainer.name}`
);
}
}
/**
*
* @returns containers
@ -80,4 +124,32 @@ export class BlobstorageService {
this.logger.log(`[OUT] ${this.deleteContainers.name}`);
}
}
/**
* Gets container client
* @param companyName
* @returns container client
*/
private getContainerClient(
context: Context,
accountId: number,
country: string
): ContainerClient {
this.logger.log(
`[IN] [${context.getTrackingId()}] ${
this.getContainerClient.name
} | params: { ` + `accountId: ${accountId} };`
);
const containerName = `account-${accountId}`;
if (BLOB_STORAGE_REGION_US.includes(country)) {
return this.blobServiceClientUS.getContainerClient(containerName);
} else if (BLOB_STORAGE_REGION_AU.includes(country)) {
return this.blobServiceClientAU.getContainerClient(containerName);
} else if (BLOB_STORAGE_REGION_EU.includes(country)) {
return this.blobServiceClientEU.getContainerClient(containerName);
} else {
throw new Error("invalid country");
}
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Account } from './entity/account.entity';
import { AccountsRepositoryService } from './accounts.repository.service';
@Module({
imports: [TypeOrmModule.forFeature([Account])],
providers: [AccountsRepositoryService],
exports: [AccountsRepositoryService],
})
export class AccountsRepositoryModule {}

View File

@ -0,0 +1,164 @@
import { Injectable } from '@nestjs/common';
import {
DataSource,
} from 'typeorm';
import { User } from '../users/entity/user.entity';
import { Account } from './entity/account.entity';
import {
getDirection,
getTaskListSortableAttribute,
} from '../../common/types/sort/util';
import { SortCriteria } from "../sort_criteria/entity/sort_criteria.entity";
import {
insertEntity,
updateEntity,
deleteEntity,
} from '../../common/repository';
import { Context } from '../../common/log';
@Injectable()
export class AccountsRepositoryService {
// クエリログにコメントを出力するかどうか
private readonly isCommentOut = process.env.STAGE !== "local";
constructor(private dataSource: DataSource) {}
/**
*
* @param companyName
* @param country
* @param dealerAccountId
* @param tier
* @param adminExternalUserId
* @param adminUserRole
* @param accountId
* @param userId
* @param adminUserAcceptedEulaVersion
* @param adminUserAcceptedPrivacyNoticeVersion
* @param adminUserAcceptedDpaVersion
* @returns account/admin user
*/
async createAccount(
context: Context,
companyName: string,
country: string,
dealerAccountId: number | undefined,
tier: number,
adminExternalUserId: string,
adminUserRole: string,
accountId: number,
userId: number,
adminUserAcceptedEulaVersion?: string,
adminUserAcceptedPrivacyNoticeVersion?: string,
adminUserAcceptedDpaVersion?: string
): Promise<{ newAccount: Account; adminUser: User }> {
return await this.dataSource.transaction(async (entityManager) => {
const account = new Account();
{
account.id = accountId;
account.parent_account_id = dealerAccountId ?? null;
account.company_name = companyName;
account.country = country;
account.tier = tier;
}
const accountsRepo = entityManager.getRepository(Account);
const newAccount = accountsRepo.create(account);
const persistedAccount = await insertEntity(
Account,
accountsRepo,
newAccount,
this.isCommentOut,
context
);
// 作成されたAccountのIDを使用してユーザーを作成
const user = new User();
{
user.id = userId;
user.account_id = persistedAccount.id;
user.external_id = adminExternalUserId;
user.role = adminUserRole;
user.accepted_eula_version = adminUserAcceptedEulaVersion ?? null;
user.accepted_privacy_notice_version =
adminUserAcceptedPrivacyNoticeVersion ?? null;
user.accepted_dpa_version = adminUserAcceptedDpaVersion ?? null;
}
const usersRepo = entityManager.getRepository(User);
const newUser = usersRepo.create(user);
const persistedUser = await insertEntity(
User,
usersRepo,
newUser,
this.isCommentOut,
context
);
// アカウントに管理者を設定して更新
persistedAccount.primary_admin_user_id = persistedUser.id;
const result = await updateEntity(
accountsRepo,
{ id: persistedAccount.id },
persistedAccount,
this.isCommentOut,
context
);
// 想定外の更新が行われた場合はロールバックを行った上でエラー送出
if (result.affected !== 1) {
throw new Error(`invalid update. result.affected=${result.affected}`);
}
// ユーザーのタスクソート条件を作成
const sortCriteria = new SortCriteria();
{
sortCriteria.parameter = getTaskListSortableAttribute("JOB_NUMBER");
sortCriteria.direction = getDirection("ASC");
sortCriteria.user_id = persistedUser.id;
}
const sortCriteriaRepo = entityManager.getRepository(SortCriteria);
const newSortCriteria = sortCriteriaRepo.create(sortCriteria);
await insertEntity(
SortCriteria,
sortCriteriaRepo,
newSortCriteria,
this.isCommentOut,
context
);
return { newAccount: persistedAccount, adminUser: persistedUser };
});
}
/**
*
* @param accountId
* @returns delete
*/
async deleteAccount(
context: Context,
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 deleteEntity(
sortCriteriaRepo,
{ user_id: userId },
this.isCommentOut,
context
);
// プライマリ管理者を削除
await deleteEntity(usersRepo, { id: userId }, this.isCommentOut, context);
// アカウントを削除
await deleteEntity(
accountsRepo,
{ id: accountId },
this.isCommentOut,
context
);
});
}
}

View File

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

View File

@ -0,0 +1,28 @@
// アカウント未発見エラー
export class AccountNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'AccountNotFoundError';
}
}
// ディーラーアカウント未存在エラー
export class DealerAccountNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'DealerAccountNotFoundError';
}
}
// 管理者ユーザ未存在エラー
export class AdminUserNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'AdminUserNotFoundError';
}
}
// アカウントロックエラー
export class AccountLockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'AccountLockedError';
}
}

View File

@ -0,0 +1,137 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { bigintTransformer } from '../../../common/entity';
@Entity({ name: 'licenses' })
export class License {
@PrimaryGeneratedColumn()
id: number;
@Column({ nullable: true, type: 'datetime' })
expiry_date: Date | null;
@Column()
account_id: number;
@Column()
type: string;
@Column()
status: string;
@Column({ nullable: true, type: 'bigint', transformer: bigintTransformer })
allocated_user_id: number | null;
@Column({ nullable: true, type: 'bigint', transformer: bigintTransformer })
order_id: number | null;
@Column({ nullable: true, type: 'datetime' })
deleted_at: Date | null;
@Column({ nullable: true, type: 'bigint', transformer: bigintTransformer })
delete_order_id: number | null;
@Column({ nullable: true, type: 'datetime' })
created_by: string | null;
@CreateDateColumn({
default: () => "datetime('now', 'localtime')",
type: 'datetime',
})
created_at: Date;
@Column({ nullable: true, type: 'datetime' })
updated_by: string | null;
@UpdateDateColumn({
default: () => "datetime('now', 'localtime')",
type: 'datetime',
})
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()
account_id: number;
@Column()
executed_at: Date;
@Column()
switch_from_type: string;
@Column({ nullable: true, type: 'datetime' })
deleted_at: Date | null;
@Column({ nullable: true, type: 'datetime' })
created_by: string | null;
@CreateDateColumn({
default: () => "datetime('now', 'localtime')",
type: 'datetime',
})
created_at: Date;
@Column({ nullable: true, type: 'datetime' })
updated_by: string | null;
@UpdateDateColumn({
default: () => "datetime('now', 'localtime')",
type: 'datetime',
})
updated_at: Date;
}
@Entity({ name: "card_licenses" })
export class CardLicense {
@PrimaryGeneratedColumn()
license_id: number;
@Column()
issue_id: number;
@Column()
card_license_key: string;
@Column({ nullable: true, type: "datetime" })
activated_at: Date | null;
@Column({ nullable: true, type: "datetime" })
created_by: string | null;
@CreateDateColumn({
default: () => "datetime('now', 'localtime')",
type: "datetime",
})
created_at: Date;
@Column({ nullable: true, type: "datetime" })
updated_by: string | null;
@UpdateDateColumn({
default: () => "datetime('now', 'localtime')",
type: "datetime",
})
updated_at: Date;
}

View File

@ -0,0 +1,108 @@
// POナンバーがすでに存在するエラー
export class PoNumberAlreadyExistError extends Error {
constructor(message: string) {
super(message);
this.name = 'PoNumberAlreadyExistError';
}
}
// 取り込むカードライセンスが存在しないエラー
export class LicenseNotExistError extends Error {
constructor(message: string) {
super(message);
this.name = 'LicenseNotExistError';
}
}
// 取り込むライセンスが既に取り込み済みのエラー
export class LicenseKeyAlreadyActivatedError extends Error {
constructor(message: string) {
super(message);
this.name = 'LicenseKeyAlreadyActivatedError';
}
}
// 注文不在エラー
export class OrderNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'OrderNotFoundError';
}
}
// 注文発行済エラー
export class AlreadyIssuedError extends Error {
constructor(message: string) {
super(message);
this.name = 'AlreadyIssuedError';
}
}
// ライセンス不足エラー
export class LicensesShortageError extends Error {
constructor(message: string) {
super(message);
this.name = 'LicensesShortageError';
}
}
// ライセンス有効期限切れエラー
export class LicenseExpiredError extends Error {
constructor(message: string) {
super(message);
this.name = 'LicenseExpiredError';
}
}
// ライセンス割り当て不可エラー
export class LicenseUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = 'LicenseUnavailableError';
}
}
// ライセンス割り当て解除済みエラー
export class LicenseAlreadyDeallocatedError extends Error {
constructor(message: string) {
super(message);
this.name = 'LicenseAlreadyDeallocatedError';
}
}
// 注文キャンセル失敗エラー
export class CancelOrderFailedError extends Error {
constructor(message: string) {
super(message);
this.name = 'CancelOrderFailedError';
}
}
// ライセンス発行キャンセル不可エラー(ステータスが変えられている場合)
export class AlreadyLicenseStatusChangedError extends Error {
constructor(message: string) {
super(message);
this.name = 'AlreadyLicenseStatusChangedError';
}
}
// ライセンス発行キャンセル不可エラー(発行から一定期間経過した場合)
export class CancellationPeriodExpiredError extends Error {
constructor(message: string) {
super(message);
this.name = 'CancellationPeriodExpiredError';
}
}
// ライセンス発行キャンセル不可エラー(発行したライセンスが割り当てされている場合)
export class AlreadyLicenseAllocatedError extends Error {
constructor(message: string) {
super(message);
this.name = 'AlreadyLicenseAllocatedError';
}
}
// ライセンス未割当エラー
export class LicenseNotAllocatedError extends Error {
constructor(message: string) {
super(message);
this.name = 'LicenseNotAllocatedError';
}
}

View File

@ -0,0 +1,17 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import {
CardLicense,
License,
LicenseAllocationHistory,
} from "./entity/license.entity";
import { LicensesRepositoryService } from "./licenses.repository.service";
@Module({
imports: [
TypeOrmModule.forFeature([License, CardLicense, LicenseAllocationHistory]),
],
providers: [LicensesRepositoryService],
exports: [LicensesRepositoryService],
})
export class LicensesRepositoryModule {}

View File

@ -0,0 +1,130 @@
import { Injectable, Logger } from "@nestjs/common";
import { DataSource, In } from "typeorm";
import {
License,
LicenseAllocationHistory,
CardLicense,
} from "./entity/license.entity";
import { insertEntities } from "../../common/repository";
import { Context } from "../../common/log";
import {
LicensesInputFile,
CardLicensesInputFile,
} from "../../common/types/types";
@Injectable()
export class LicensesRepositoryService {
//クエリログにコメントを出力するかどうか
private readonly isCommentOut = process.env.STAGE !== "local";
constructor(private dataSource: DataSource) {}
private readonly logger = new Logger(LicensesRepositoryService.name);
/**
*
* @context Context
* @param licensesInputFiles
*/
async insertLicenses(
context: Context,
licensesInputFiles: LicensesInputFile[]
): Promise<{}> {
const nowDate = new Date();
return await this.dataSource.transaction(async (entityManager) => {
const licenseRepo = entityManager.getRepository(License);
let newLicenses: License[] = [];
licensesInputFiles.forEach((licensesInputFile) => {
const license = new License();
license.account_id = licensesInputFile.account_id;
license.status = licensesInputFile.status;
license.type = licensesInputFile.type;
license.expiry_date = (licensesInputFile.expiry_date) ? new Date(licensesInputFile.expiry_date) : null;
if (licensesInputFile.allocated_user_id) {
license.allocated_user_id = licensesInputFile.allocated_user_id;
}
newLicenses.push(license);
});
// ライセンステーブルを登録
const insertedlicenses = await insertEntities(
License,
licenseRepo,
newLicenses,
this.isCommentOut,
context
);
const licenseAllocationHistoryRepo = entityManager.getRepository(
LicenseAllocationHistory
);
// ユーザに割り当てた場合はライセンス割り当て履歴にも登録
let newLicenseAllocationHistories: LicenseAllocationHistory[] = [];
insertedlicenses.forEach((insertedlicense) => {
if (insertedlicense.allocated_user_id) {
const licenseAllocationHistory = new LicenseAllocationHistory();
licenseAllocationHistory.user_id = insertedlicense.allocated_user_id;
licenseAllocationHistory.license_id = insertedlicense.id;
licenseAllocationHistory.is_allocated = true;
licenseAllocationHistory.account_id = insertedlicense.account_id;
licenseAllocationHistory.switch_from_type = "NONE";
licenseAllocationHistory.executed_at = insertedlicense.created_at;
newLicenseAllocationHistories.push(licenseAllocationHistory);
}
});
// ライセンス割り当てテーブルを登録
await insertEntities(
LicenseAllocationHistory,
licenseAllocationHistoryRepo,
newLicenseAllocationHistories,
this.isCommentOut,
context
);
return {};
});
}
/**
*
* @context Context
* @param cardLicensesInputFiles
*/
async insertCardLicenses(
context: Context,
cardLicensesInputFiles: CardLicensesInputFile[]
): Promise<{}> {
return await this.dataSource.transaction(async (entityManager) => {
const cardLicenseRepo = entityManager.getRepository(CardLicense);
let newCardLicenses: CardLicense[] = [];
cardLicensesInputFiles.forEach((cardLicensesInputFile) => {
const cardLicense = new CardLicense();
cardLicense.license_id = cardLicensesInputFile.license_id;
cardLicense.issue_id = cardLicensesInputFile.issue_id;
cardLicense.card_license_key = cardLicensesInputFile.card_license_key;
cardLicense.activated_at = (cardLicensesInputFile.activated_at) ? new Date(cardLicensesInputFile.activated_at) : null;
cardLicense.created_at = (cardLicensesInputFile.created_at) ? new Date(cardLicensesInputFile.created_at) : null;
cardLicense.created_by = cardLicensesInputFile.created_by;
cardLicense.updated_at = (cardLicensesInputFile.updated_at) ? new Date(cardLicensesInputFile.updated_at) : null;
cardLicense.updated_by = cardLicensesInputFile.updated_by;
newCardLicenses.push(cardLicense);
});
const query = cardLicenseRepo
.createQueryBuilder()
.insert()
.into(CardLicense);
if (this.isCommentOut) {
query.comment(`${context.getTrackingId()}_${new Date().toUTCString()}`);
}
query.values(newCardLicenses).execute();
return {};
});
}
}

View File

@ -0,0 +1,16 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity({ name: 'sort_criteria' })
export class SortCriteria {
@PrimaryGeneratedColumn()
id: number;
@Column()
user_id: number;
@Column()
parameter: string;
@Column()
direction: string;
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SortCriteria } from './entity/sort_criteria.entity';
@Module({
imports: [TypeOrmModule.forFeature([SortCriteria])],
})
export class SortCriteriaRepositoryModule {}

View File

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

View File

@ -0,0 +1,56 @@
// Email検証済みエラー
export class EmailAlreadyVerifiedError extends Error {
constructor(message: string) {
super(message);
this.name = 'EmailAlreadyVerifiedError';
}
}
// ユーザー未発見エラー
export class UserNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'UserNotFoundError';
}
}
// AuthorID重複エラー
export class AuthorIdAlreadyExistsError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuthorIdAlreadyExistsError';
}
}
// 不正なRole変更エラー
export class InvalidRoleChangeError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidRoleChangeError';
}
}
// 暗号化パスワード不足エラー
export class EncryptionPasswordNeedError extends Error {
constructor(message: string) {
super(message);
this.name = 'EncryptionPasswordNeedError';
}
}
// 利用規約バージョン情報不在エラー
export class TermInfoNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'TermInfoNotFoundError';
}
}
// 利用規約バージョンパラメータ不在エラー
export class UpdateTermsVersionNotSetError extends Error {
constructor(message: string) {
super(message);
this.name = 'UpdateTermsVersionNotSetError';
}
}
// 代行操作不許可エラー
export class DelegationNotAllowedError extends Error {
constructor(message: string) {
super(message);
this.name = 'DelegationNotAllowedError';
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entity/user.entity';
import { UsersRepositoryService } from './users.repository.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersRepositoryService],
exports: [UsersRepositoryService],
})
export class UsersRepositoryModule {}

View File

@ -0,0 +1,141 @@
import { Injectable } from '@nestjs/common';
import { User, newUser } from './entity/user.entity';
import {
DataSource,
} from 'typeorm';
import { SortCriteria } from '../sort_criteria/entity/sort_criteria.entity';
import {
getDirection,
getTaskListSortableAttribute,
} from '../../common/types/sort/util';
import { Context } from '../../common/log';
import {
insertEntity,
deleteEntity,
} from '../../common/repository';
@Injectable()
export class UsersRepositoryService {
// クエリログにコメントを出力するかどうか
private readonly isCommentOut = process.env.STAGE !== "local";
constructor(private dataSource: DataSource) {}
/**
*
* @param user
* @returns User
*/
async createNormalUser(context: Context, user: newUser): Promise<User> {
const {
id,
account_id: accountId,
external_id: externalUserId,
role,
auto_renew,
notification,
author_id,
accepted_eula_version,
accepted_dpa_version,
encryption,
encryption_password: encryptionPassword,
prompt,
} = user;
const userEntity = new User();
userEntity.id = id;
userEntity.role = role;
userEntity.account_id = accountId;
userEntity.external_id = externalUserId;
userEntity.auto_renew = auto_renew;
userEntity.notification = notification;
userEntity.author_id = author_id;
userEntity.accepted_eula_version = accepted_eula_version;
userEntity.accepted_dpa_version = accepted_dpa_version;
userEntity.encryption = encryption;
userEntity.encryption_password = encryptionPassword;
userEntity.prompt = prompt;
userEntity.email_verified = true;
const createdEntity = await this.dataSource.transaction(
async (entityManager) => {
const repo = entityManager.getRepository(User);
const newUser = repo.create(userEntity);
const persisted = await insertEntity(
User,
repo,
newUser,
this.isCommentOut,
context
);
// ユーザーのタスクソート条件を作成
const sortCriteria = new SortCriteria();
{
sortCriteria.parameter = getTaskListSortableAttribute("JOB_NUMBER");
sortCriteria.direction = getDirection("ASC");
sortCriteria.user_id = persisted.id;
}
const sortCriteriaRepo = entityManager.getRepository(SortCriteria);
const newSortCriteria = sortCriteriaRepo.create(sortCriteria);
await insertEntity(
SortCriteria,
sortCriteriaRepo,
newSortCriteria,
this.isCommentOut,
context
);
return persisted;
}
);
return createdEntity;
}
/**
* AuthorIdが既に存在するか確認する
* @param user
* @returns true false
*/
async existsAuthorId(
context: Context,
accountId: number,
authorId: string
): Promise<boolean> {
const user = await this.dataSource.getRepository(User).findOne({
where: [
{
account_id: accountId,
author_id: authorId,
},
],
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
});
if (user) {
return true;
}
return false;
}
/**
* UserID指定のユーザーとソート条件を同時に削除する
* @param userId
* @returns delete
*/
async deleteNormalUser(context: Context, userId: number): Promise<void> {
await this.dataSource.transaction(async (entityManager) => {
const usersRepo = entityManager.getRepository(User);
const sortCriteriaRepo = entityManager.getRepository(SortCriteria);
// ソート条件を削除
await deleteEntity(
sortCriteriaRepo,
{ user_id: userId },
this.isCommentOut,
context
);
// プライマリ管理者を削除
await deleteEntity(usersRepo, { id: userId }, this.isCommentOut, context);
});
}
}

View File

@ -0,0 +1,42 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
UpdateDateColumn,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Worktype } from './worktype.entity';
@Entity({ name: 'option_items' })
export class OptionItem {
@PrimaryGeneratedColumn()
id: number;
@Column()
worktype_id: number;
@Column()
item_label: string;
@Column()
default_value_type: string;
@Column()
initial_value: string;
@Column({ nullable: true, type: 'datetime' })
created_by: string | null;
@CreateDateColumn({
default: () => "datetime('now', 'localtime')",
type: 'datetime',
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
created_at: Date | null;
@Column({ nullable: true, type: 'datetime' })
updated_by: string | null;
@UpdateDateColumn({
default: () => "datetime('now', 'localtime')",
type: 'datetime',
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at: Date | null;
@ManyToOne(() => Worktype, (worktype) => worktype.id)
@JoinColumn({ name: 'worktype_id' })
worktype: Worktype;
}

View File

@ -0,0 +1,44 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'worktypes' })
export class Worktype {
@PrimaryGeneratedColumn()
id: number;
@Column()
account_id: number;
@Column()
custom_worktype_id: string;
@Column({ nullable: true, type: 'varchar' })
description: string | null;
@Column({ nullable: true, type: 'datetime' })
deleted_at: Date | null;
@Column({ nullable: true, type: 'datetime' })
created_by: string | null;
@CreateDateColumn({
default: () => "datetime('now', 'localtime')",
type: 'datetime',
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
created_at: Date;
@Column({ nullable: true, type: 'datetime' })
updated_by: string | null;
@UpdateDateColumn({
default: () => "datetime('now', 'localtime')",
type: 'datetime',
}) // defaultはSQLite用設定値.本番用は別途migrationで設定
updated_at: Date;
}

View File

@ -0,0 +1,28 @@
// WorktypeID重複エラー
export class WorktypeIdAlreadyExistsError extends Error {
constructor(message: string) {
super(message);
this.name = 'WorktypeIdAlreadyExistsError';
}
}
// WorktypeID登録上限エラー
export class WorktypeIdMaxCountError extends Error {
constructor(message: string) {
super(message);
this.name = 'WorktypeIdMaxCountError';
}
}
// WorktypeID不在エラー
export class WorktypeIdNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'WorktypeIdNotFoundError';
}
}
// WorktypeID使用中エラー
export class WorktypeIdInUseError extends Error {
constructor(message: string) {
super(message);
this.name = 'WorktypeIdInUseError';
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Worktype } from './entity/worktype.entity';
import { WorktypesRepositoryService } from './worktypes.repository.service';
import { OptionItem } from "./entity/option_item.entity";
@Module({
imports: [TypeOrmModule.forFeature([Worktype, OptionItem])],
providers: [WorktypesRepositoryService],
exports: [WorktypesRepositoryService],
})
export class WorktypesRepositoryModule {}

View File

@ -0,0 +1,104 @@
import { Injectable } from "@nestjs/common";
import { DataSource, Not } from "typeorm";
import { Worktype } from "./entity/worktype.entity";
import {
OPTION_ITEM_NUM,
OPTION_ITEM_VALUE_TYPE,
WORKTYPE_MAX_COUNT,
} from "../../constants";
import {
WorktypeIdAlreadyExistsError,
WorktypeIdMaxCountError,
} from "./errors/types";
import { OptionItem } from "./entity/option_item.entity";
import { insertEntities, insertEntity } from "../../common/repository";
import { Context } from "../../common/log";
import { WorktypesInputFile } from "../../common/types/types";
@Injectable()
export class WorktypesRepositoryService {
// クエリログにコメントを出力するかどうか
private readonly isCommentOut = process.env.STAGE !== "local";
constructor(private dataSource: DataSource) {}
/**
*
* @param accountId
* @param worktypeId
* @param [description]
*/
async createWorktype(
context: Context,
worktypesInputFiles: WorktypesInputFile[]
): Promise<void> {
await this.dataSource.transaction(async (entityManager) => {
const worktypeRepo = entityManager.getRepository(Worktype);
const optionItemRepo = entityManager.getRepository(OptionItem);
for (const worktypesInputFile of worktypesInputFiles) {
const accountId = worktypesInputFile.account_id;
const worktypeId = worktypesInputFile.custom_worktype_id;
const description = null;
const duplicatedWorktype = await worktypeRepo.findOne({
where: { account_id: accountId, custom_worktype_id: worktypeId },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
lock: { mode: "pessimistic_write" },
});
// ワークタイプIDが重複している場合はエラー
if (duplicatedWorktype) {
throw new WorktypeIdAlreadyExistsError(
`WorktypeID is already exists. WorktypeID: ${worktypeId}`
);
}
const worktypeCount = await worktypeRepo.count({
where: { account_id: accountId },
comment: `${context.getTrackingId()}_${new Date().toUTCString()}`,
lock: { mode: "pessimistic_write" },
});
// ワークタイプの登録数が上限に達している場合はエラー
if (worktypeCount >= WORKTYPE_MAX_COUNT) {
throw new WorktypeIdMaxCountError(
`Number of worktype is exceeded the limit. MAX_COUNT: ${WORKTYPE_MAX_COUNT}, currentCount: ${worktypeCount}`
);
}
// ワークタイプを作成
const worktype = await insertEntity(
Worktype,
worktypeRepo,
{
account_id: accountId,
custom_worktype_id: worktypeId,
description: description ?? null,
},
this.isCommentOut,
context
);
// ワークタイプに紐づくオプションアイテムを10件作成
const newOptionItems = Array.from({ length: OPTION_ITEM_NUM }, () => {
const optionItem = new OptionItem();
optionItem.worktype_id = worktype.id;
optionItem.item_label = "";
optionItem.default_value_type = OPTION_ITEM_VALUE_TYPE.DEFAULT;
optionItem.initial_value = "";
return optionItem;
});
await insertEntities(
OptionItem,
optionItemRepo,
newOptionItems,
this.isCommentOut,
context
);
}
});
}
}