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 { 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 { 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('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 { 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('MAIL_FROM') ?? ''; // TODO [Task2163] ODMS側が正式にメッセージを決めるまで仮のメール内容とする const subject = 'A temporary password has been issued.'; const text = 'temporary password: ' + ramdomPassword; const domains = this.configService.get('APP_DOMAIN'); const html = `

OMDS TOP PAGE URL.

${domains}"
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 { 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 { 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 { 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 { 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 { 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}`, ); } } }