makabe.t ad969bd2cf Merged PR 323: アカウント登録APIを修正
## 概要
[Task2353: アカウント登録APIを修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2353)

- アカウント登録時にコンテナを作成するように修正
- ログ追加
- リクエストのバリデータを追加

## レビューポイント
- 処理の流れに問題はないか
- テストケースに不足はないか
- バリデータに問題はないか

## UIの変更
- なし

## 動作確認状況
- ローカルで確認
2023-08-16 09:12:56 +00:00

885 lines
26 KiB
TypeScript

import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { isVerifyError, verify } from '../../common/jwt';
import { getPublicKey } from '../../common/jwt/jwt';
import { makePassword } from '../../common/password/password';
import { AccessToken } from '../../common/token';
import {
SortDirection,
TaskListSortableAttribute,
isSortDirection,
isTaskListSortableAttribute,
} from '../../common/types/sort';
import {
AdB2cService,
ConflictError,
isConflictError,
} from '../../gateways/adb2c/adb2c.service';
import { SendGridService } from '../../gateways/sendgrid/sendgrid.service';
import { SortCriteriaRepositoryService } from '../../repositories/sort_criteria/sort_criteria.repository.service';
import {
User as EntityUser,
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,
EmailAlreadyVerifiedError,
EncryptionPasswordNeedError,
InvalidRoleChangeError,
UserNotFoundError,
} from '../../repositories/users/errors/types';
import {
LICENSE_EXPIRATION_THRESHOLD_DAYS,
USER_LICENSE_STATUS,
USER_ROLES,
} from '../../constants';
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,
private readonly sendgridService: SendGridService,
) {}
private readonly logger = new Logger(UsersService.name);
/**
* Confirms user
* @param token ユーザ仮登録時に払いだされるトークン
*/
async confirmUser(token: string): Promise<void> {
this.logger.log(`[IN] ${this.confirmUser.name}`);
const pubKey = getPublicKey(this.configService);
const decodedToken = verify<{
accountId: number;
userId: number;
email: string;
}>(token, pubKey);
if (isVerifyError(decodedToken)) {
throw new HttpException(
makeErrorResponse('E000101'),
HttpStatus.BAD_REQUEST,
);
}
try {
// トランザクションで取得と更新をまとめる
const userId = decodedToken.userId;
await this.usersRepository.updateUserVerifiedAndCreateTrialLicense(
userId,
);
} catch (e) {
this.logger.error(e);
if (e instanceof Error) {
switch (e.constructor) {
case EmailAlreadyVerifiedError:
throw new HttpException(
makeErrorResponse('E010202'),
HttpStatus.BAD_REQUEST,
);
default:
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
* Creates user
* @param accessToken
* @param name
* @param role
* @param email
* @param autoRenew
* @param licenseAlert
* @param notification
* @param [authorId]
* @param [encryption]
* @param [encryptionPassword]
* @param [prompt]
* @returns void
*/
async createUser(
context: Context,
accessToken: AccessToken,
name: string,
role: UserRoles,
email: string,
autoRenew: boolean,
licenseAlert: boolean,
notification: boolean,
authorId?: string | undefined,
encryption?: boolean | undefined,
encryptionPassword?: string | undefined,
prompt?: boolean | undefined,
): Promise<void> {
this.logger.log(`[IN] [${context.trackingId}] ${this.createUser.name}`);
//DBよりアクセス者の所属するアカウントIDを取得する
let adminUser: EntityUser;
try {
adminUser = await this.usersRepository.findUserByExternalId(
accessToken.userId,
);
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const accountId = adminUser.account_id;
//authorIdが重複していないかチェックする
if (authorId) {
let isAuthorIdDuplicated = false;
try {
isAuthorIdDuplicated = await this.usersRepository.existsAuthorId(
accountId,
authorId,
);
} catch (e) {
this.logger.error(`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 {
// idpにユーザーを作成
externalUser = await this.adB2cService.createUser(
context,
email,
ramdomPassword,
name,
);
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.error('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(
role,
accountId,
externalUser.sub,
autoRenew,
licenseAlert,
notification,
authorId,
encryption,
encryptionPassword,
prompt,
);
// ユーザ作成
newUser = await this.usersRepository.createNormalUser(newUserInfo);
} catch (e) {
this.logger.error(`error=${e}`);
this.logger.error('create user failed');
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,
);
}
}
//Email送信用のコンテンツを作成する
try {
// メールの送信元を取得
const from = this.configService.get<string>('MAIL_FROM') ?? '';
// メールの内容を構成
const { subject, text, html } =
await this.sendgridService.createMailContentFromEmailConfirmForNormalUser(
accountId,
newUser.id,
email,
);
//SendGridAPIを呼び出してメールを送信する
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}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
this.logger.log(`[OUT] ${this.createUser.name}`);
return;
}
// roleを受け取って、roleに応じたnewUserを作成して返却する
private createNewUserInfo(
role: UserRoles,
accountId: number,
externalId: string,
autoRenew: boolean,
licenseAlert: boolean,
notification: boolean,
authorId?: string | undefined,
encryption?: boolean | undefined,
encryptionPassword?: string | undefined,
prompt?: boolean | undefined,
): newUser {
switch (role) {
case USER_ROLES.NONE:
case USER_ROLES.TYPIST:
return {
account_id: accountId,
external_id: externalId,
auto_renew: autoRenew,
license_alert: licenseAlert,
notification,
role,
};
case USER_ROLES.AUTHOR:
return {
account_id: accountId,
external_id: externalId,
auto_renew: autoRenew,
license_alert: licenseAlert,
notification,
role,
author_id: authorId,
encryption,
encryption_password: encryptionPassword,
prompt,
};
default:
//不正なroleが指定された場合はログを出力してエラーを返す
this.logger.error(`[NOT IMPLEMENT] [RECOVER] role: ${role}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
* confirm User And Init Password
* @param token ユーザ仮登録時に払いだされるトークン
*/
async confirmUserAndInitPassword(
context: Context,
token: string,
): Promise<void> {
this.logger.log(
`[IN] [${context.trackingId}] ${this.confirmUserAndInitPassword.name}`,
);
const pubKey = getPublicKey(this.configService);
const decodedToken = verify<{
accountId: number;
userId: number;
email: string;
}>(token, pubKey);
if (isVerifyError(decodedToken)) {
throw new HttpException(
makeErrorResponse('E000101'),
HttpStatus.BAD_REQUEST,
);
}
// ランダムなパスワードを生成する
const ramdomPassword = makePassword();
const { userId, email } = decodedToken;
try {
// ユーザー情報からAzure AD B2CのIDを特定する
const user = await this.usersRepository.findUserById(userId);
const extarnalId = user.external_id;
// パスワードを変更する
await this.adB2cService.changePassword(extarnalId, ramdomPassword);
// ユーザを認証済みにする
await this.usersRepository.updateUserVerified(userId);
// メールの送信元を取得
const from = this.configService.get<string>('MAIL_FROM') ?? '';
// TODO [Task2163] ODMS側が正式にメッセージを決めるまで仮のメール内容とする
const subject = 'A temporary password has been issued.';
const text = 'temporary password: ' + ramdomPassword;
const domains = this.configService.get<string>('APP_DOMAIN');
const html = `<p>OMDS TOP PAGE URL.<p><a href="${domains}">${domains}"</a><br>temporary password: ${ramdomPassword}`;
// メールを送信
await this.sendgridService.sendMail(
context,
email,
from,
subject,
text,
html,
);
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
case EmailAlreadyVerifiedError:
throw new HttpException(
makeErrorResponse('E010202'),
HttpStatus.BAD_REQUEST,
);
default:
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
}
/**
* Get Users
* @param accessToken
* @returns users
*/
async getUsers(externalId: string): Promise<User[]> {
this.logger.log(`[IN] ${this.getUsers.name}`);
try {
// DBから同一アカウントのユーザ一覧を取得する
const dbUsers = await this.usersRepository.findSameAccountUsers(
externalId,
);
// DBから取得したユーザーの外部IDをもとにADB2Cからユーザーを取得する
const externalIds = dbUsers.map((x) => x.external_id);
const adb2cUsers = await this.adB2cService.getUsers(
// TODO: 外部連携以外のログ強化時に、ContollerからContextを取得するように修正する
{ trackingId: 'dummy' },
externalIds,
);
// DBから取得した各ユーザーをもとにADB2C情報をマージしライセンス情報を算出
const users = dbUsers.map((x) => {
// ユーザーの所属グループ名を取得する
const groupNames =
x.userGroupMembers?.map((group) => group.userGroup?.name) ?? [];
const adb2cUser = adb2cUsers.find((user) => user.id === x.external_id);
// メールアドレスを取得する
const mail = adb2cUser.identities.find(
(identity) => identity.signInType === 'emailAddress',
).issuerAssignedId;
let status = USER_LICENSE_STATUS.NORMAL;
// ライセンスの有効期限と残日数は、ライセンスが存在する場合のみ算出する
// ライセンスが存在しない場合は、undefinedのままとする
let expiration: string | undefined = undefined;
let remaining: number | undefined = undefined;
if (x.license) {
// 有効期限日付 YYYY/MM/DD
const expiry_date = x.license.expiry_date;
expiration = `${expiry_date.getFullYear()}/${
expiry_date.getMonth() + 1
}/${expiry_date.getDate()}`;
const currentDate = new DateWithZeroTime();
// 有効期限までの日数
remaining = Math.floor(
(expiry_date.getTime() - currentDate.getTime()) /
(1000 * 60 * 60 * 24),
);
if (remaining <= LICENSE_EXPIRATION_THRESHOLD_DAYS) {
status = x.auto_renew
? USER_LICENSE_STATUS.RENEW
: USER_LICENSE_STATUS.ALERT;
}
} else {
status = USER_LICENSE_STATUS.NO_LICENSE;
}
return {
id: x.id,
name: adb2cUser.displayName,
role: x.role,
authorId: x.author_id ?? undefined,
typistGroupName: groupNames,
email: mail,
emailVerified: x.email_verified,
autoRenew: x.auto_renew,
licenseAlert: x.license_alert,
notification: x.notification,
encryption: x.encryption,
prompt: x.prompt,
expiration: expiration,
remaining: remaining,
licenseStatus: status,
};
});
return users;
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.NOT_FOUND,
);
} finally {
this.logger.log(`[OUT] ${this.getUsers.name}`);
}
}
/**
* Updates sort criteria
* @param paramName
* @param direction
* @param token
* @returns sort criteria
*/
async updateSortCriteria(
paramName: TaskListSortableAttribute,
direction: SortDirection,
token: AccessToken,
): Promise<void> {
this.logger.log(`[IN] ${this.updateSortCriteria.name}`);
let user: EntityUser;
try {
// ユーザー情報を取得
const sub = token.userId;
user = await this.usersRepository.findUserByExternalId(sub);
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
// ユーザーのソート条件を更新
await this.sortCriteriaRepository.updateSortCriteria(
user.id,
paramName,
direction,
);
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
this.logger.log(`[OUT] ${this.updateSortCriteria.name}`);
}
}
/**
* Gets sort criteria
* @param token
* @returns sort criteria
*/
async getSortCriteria(token: AccessToken): Promise<{
paramName: TaskListSortableAttribute;
direction: SortDirection;
}> {
this.logger.log(`[IN] ${this.getSortCriteria.name}`);
let user: EntityUser;
try {
// ユーザー情報を取得
const sub = token.userId;
user = await this.usersRepository.findUserByExternalId(sub);
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
// ユーザーのソート条件を取得
const sortCriteria = await this.sortCriteriaRepository.getSortCriteria(
user.id,
);
const { direction, parameter } = sortCriteria;
//型チェック
if (
!isTaskListSortableAttribute(parameter) ||
!isSortDirection(direction)
) {
throw new Error('The value stored in the DB is invalid.');
}
return { direction, paramName: parameter };
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
this.logger.log(`[OUT] ${this.getSortCriteria.name}`);
}
}
/**
* 指定したユーザーの文字起こし業務に関連する情報を取得します
* @param userId
* @returns relations
*/
async getRelations(
context: Context,
userId: string,
): Promise<GetRelationsResponse> {
this.logger.log(`[IN] [${context.trackingId}] ${this.getRelations.name}`);
try {
const user = await this.usersRepository.findUserByExternalId(userId);
// TODO: PBI2105 本実装時に修正すること
return {
authorId: user.author_id,
authorIdList: [user.author_id, 'XXX'],
isEncrypted: true,
encryptionPassword: 'abcd@123?dcba',
audioFormat: 'DS2(QP)',
prompt: true,
workTypeList: [
{
workTypeId: 'workType1',
optionItemList: [
{
label: 'optionItem11',
initialValueType: 2,
defaultValue: 'default11',
},
{
label: 'optionItem12',
initialValueType: 2,
defaultValue: 'default12',
},
{
label: 'optionItem13',
initialValueType: 2,
defaultValue: 'default13',
},
{
label: 'optionItem14',
initialValueType: 2,
defaultValue: 'default14',
},
{
label: 'optionItem15',
initialValueType: 2,
defaultValue: 'default15',
},
{
label: 'optionItem16',
initialValueType: 2,
defaultValue: 'default16',
},
{
label: 'optionItem17',
initialValueType: 2,
defaultValue: 'default17',
},
{
label: 'optionItem18',
initialValueType: 2,
defaultValue: 'default18',
},
{
label: 'optionItem19',
initialValueType: 1,
defaultValue: '',
},
{
label: 'optionItem110',
initialValueType: 3,
defaultValue: '',
},
],
},
{
workTypeId: 'workType2',
optionItemList: [
{
label: 'optionItem21',
initialValueType: 2,
defaultValue: 'default21',
},
{
label: 'optionItem22',
initialValueType: 2,
defaultValue: 'default22',
},
{
label: 'optionItem23',
initialValueType: 2,
defaultValue: 'defaul23',
},
{
label: 'optionItem24',
initialValueType: 2,
defaultValue: 'default24',
},
{
label: 'optionItem25',
initialValueType: 2,
defaultValue: 'default25',
},
{
label: 'optionItem26',
initialValueType: 2,
defaultValue: 'default26',
},
{
label: 'optionItem27',
initialValueType: 2,
defaultValue: 'default27',
},
{
label: 'optionItem28',
initialValueType: 2,
defaultValue: 'default28',
},
{
label: 'optionItem29',
initialValueType: 1,
defaultValue: '',
},
{
label: 'optionItem210',
initialValueType: 3,
defaultValue: '',
},
],
},
],
activeWorktype: 'workType1',
};
} catch (e) {
this.logger.error(`error=${e}`);
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
this.logger.log(
`[OUT] [${context.trackingId}] ${this.getRelations.name}`,
);
}
}
/**
* 指定したユーザーの情報を更新します
* @param context
* @param extarnalId
* @param id
* @param role
* @param authorId
* @param autoRenew
* @param licenseAlart
* @param notification
* @param encryption
* @param encryptionPassword
* @param prompt
* @returns user
*/
async updateUser(
context: Context,
extarnalId: string,
id: number,
role: string,
authorId: string | undefined,
autoRenew: boolean,
licenseAlart: boolean,
notification: boolean,
encryption: boolean | undefined,
encryptionPassword: string | undefined,
prompt: boolean | undefined,
): Promise<void> {
try {
this.logger.log(
`[IN] [${context.trackingId}] ${this.updateUser.name} | params: { ` +
`role: ${role}, ` +
`authorId: ${authorId}, ` +
`autoRenew: ${autoRenew}, ` +
`licenseAlart: ${licenseAlart}, ` +
`notification: ${notification}, ` +
`encryption: ${encryption}, ` +
`encryptionPassword: ********, ` +
`prompt: ${prompt} }`,
);
// 実行ユーザーのアカウントIDを取得
const accountId = (
await this.usersRepository.findUserByExternalId(extarnalId)
).account_id;
// ユーザー情報を更新
await this.usersRepository.update(
accountId,
id,
role,
authorId,
autoRenew,
licenseAlart,
notification,
encryption,
encryptionPassword,
prompt,
);
} catch (e) {
this.logger.error(`error=${e}`);
if (e instanceof Error) {
switch (e.constructor) {
case UserNotFoundError:
throw new HttpException(
makeErrorResponse('E010204'),
HttpStatus.BAD_REQUEST,
);
case AuthorIdAlreadyExistsError:
throw new HttpException(
makeErrorResponse('E010302'),
HttpStatus.BAD_REQUEST,
);
case InvalidRoleChangeError:
throw new HttpException(
makeErrorResponse('E010207'),
HttpStatus.BAD_REQUEST,
);
case EncryptionPasswordNeedError:
throw new HttpException(
makeErrorResponse('E010208'),
HttpStatus.BAD_REQUEST,
);
default:
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
throw new HttpException(
makeErrorResponse('E009999'),
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
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}`,
);
}
}
}