import { app, InvocationContext, Timer } from "@azure/functions"; import { Between, DataSource, In, IsNull, MoreThan, Repository } from "typeorm"; import { User } from "../entity/user.entity"; import { Account } from "../entity/account.entity"; import { License, LicenseAllocationHistory } from "../entity/license.entity"; import * as dotenv from "dotenv"; import { ADB2C_SIGN_IN_TYPE, CUSTOMER_NAME, DEALER_NAME, LICENSE_ALLOCATED_STATUS, LICENSE_TYPE, SWITCH_FROM_TYPE, TIERS, TOP_URL, USER_EMAIL, USER_NAME, USER_ROLES, } from "../constants"; import { DateWithDayEndTime, DateWithZeroTime, NewAllocatedLicenseExpirationDate, } from "../common/types/types"; import { initializeDataSource } from "../database/initializeDataSource"; import { readFileSync } from "fs"; import path from "path"; import { SendGridService } from "../sendgrid/sendgrid"; import { AdB2cService } from "../adb2c/adb2c"; import { RedisClient } from "redis"; import { createRedisClient } from "../redis/redis"; export async function licenseAutoAllocationProcessing( context: InvocationContext, datasource: DataSource, redisClient: RedisClient, sendGrid: SendGridService, adb2c: AdB2cService, dateToTrigger?: Date ): Promise { try { context.log("[IN]licenseAutoAllocationProcessing"); // ライセンスの有効期間判定用 let currentDateZeroTime = new DateWithZeroTime(); let currentDateEndTime = new DateWithDayEndTime(); if (dateToTrigger) { currentDateZeroTime = new DateWithZeroTime(dateToTrigger); currentDateEndTime = new DateWithDayEndTime(dateToTrigger); } // 自動更新対象の候補となるアカウントを取得 const accountRepository = datasource.getRepository(Account); const targetAccounts = await accountRepository.find({ where: { tier: TIERS.TIER5, }, }); // 自動更新対象となるアカウント・ユーザーを取得 const autoAllocationLists = await findTargetUser( context, datasource, targetAccounts, currentDateZeroTime, currentDateEndTime ); // 対象となるアカウント数分ループ for (const autoAllocationList of autoAllocationLists) { // ライセンスを割り当てる await allocateLicense( context, datasource, redisClient, sendGrid, adb2c, autoAllocationList, currentDateZeroTime, currentDateEndTime ); } } catch (e) { context.log("licenseAutoAllocationProcessing failed."); context.error(e); throw e; } finally { context.log("[OUT]licenseAutoAllocationProcessing"); } } export async function licenseAutoAllocation( myTimer: Timer, context: InvocationContext ): Promise { try { context.log("[IN]licenseAutoAllocation"); dotenv.config({ path: ".env" }); dotenv.config({ path: ".env.local", override: true }); const datasource = await initializeDataSource(context); let redisClient: RedisClient; try { // redis接続 redisClient = createRedisClient(); } catch (e) { context.log("redis client create failed."); context.error(e); throw e; } const sendGrid = new SendGridService(); const adb2c = new AdB2cService(); await licenseAutoAllocationProcessing( context, datasource, redisClient, sendGrid, adb2c ); } catch (e) { context.log("licenseAutoAllocation failed."); context.error(e); throw e; } finally { context.log("[OUT]licenseAutoAllocation"); } } /** * 自動更新対象のアカウント・ユーザーを取得する * @param context * @param datasource * @param targetAccounts 自動更新対象候補のアカウント * @param currentDateZeroTime * @param currentDateEndTime * @returns autoAllocationList[] 自動更新対象のアカウント・ユーザーのIDリスト */ export async function findTargetUser( context: InvocationContext, datasource: DataSource, targetAccounts: Account[], currentDateZeroTime: DateWithZeroTime, currentDateEndTime: DateWithDayEndTime ): Promise { try { context.log("[IN]findTargetUser"); const autoAllocationList = [] as autoAllocationList[]; // ライセンス期限が今日で自動更新対象のユーザーを取得 const userRepository = datasource.getRepository(User); for (const account of targetAccounts) { // Author→Typist→Noneの優先度で割り当てたいので、roleごとに個別で取得 const targetAuthorUsers = await userRepository.find({ where: { account_id: account.id, auto_renew: true, role: USER_ROLES.AUTHOR, license: { expiry_date: Between(currentDateZeroTime, currentDateEndTime), }, }, relations: { license: true, }, }); const targetTypistUsers = await userRepository.find({ where: { account_id: account.id, auto_renew: true, role: USER_ROLES.TYPIST, license: { expiry_date: Between(currentDateZeroTime, currentDateEndTime), }, }, relations: { license: true, }, }); const targetNoneUsers = await userRepository.find({ where: { account_id: account.id, auto_renew: true, role: USER_ROLES.NONE, license: { expiry_date: Between(currentDateZeroTime, currentDateEndTime), }, }, relations: { license: true, }, }); // Author→Typist→Noneの順で配列に格納 const userIds = [] as number[]; for (const user of targetAuthorUsers) { userIds.push(Number(user.id)); } for (const user of targetTypistUsers) { userIds.push(Number(user.id)); } for (const user of targetNoneUsers) { userIds.push(Number(user.id)); } // 対象ユーザーが0件なら自動更新リストには含めない if (userIds.length !== 0) { autoAllocationList.push({ accountId: account.id, userIds: userIds, }); } } return autoAllocationList; } catch (e) { context.error(e); context.log("findTargetUser failed."); throw e; } finally { context.log("[OUT]findTargetUser"); } } /** * 割り当て可能なライセンスを取得する * @param context * @param licenseRepository * @param accountId 自動更新対象のアカウントID * @returns License 割り当て可能なライセンス */ export async function getAutoAllocatableLicense( context: InvocationContext, licenseRepository: Repository, accountId: number, currentDateEndTime: DateWithDayEndTime ): Promise { try { context.log("[IN]getAutoAllocatableLicense"); // 割り当て可能なライセンスを取得 const license = await licenseRepository.find({ where: [ { account_id: accountId, status: In([ LICENSE_ALLOCATED_STATUS.REUSABLE, LICENSE_ALLOCATED_STATUS.UNALLOCATED, ]), expiry_date: MoreThan(currentDateEndTime), }, { account_id: accountId, status: In([ LICENSE_ALLOCATED_STATUS.REUSABLE, LICENSE_ALLOCATED_STATUS.UNALLOCATED, ]), expiry_date: IsNull(), }, ], }); if (license.length === 0) { // 割り当て可能なライセンスが存在しない場合でもエラーとはしたくないので、undifinedを返却する return undefined; } // ライセンスをソートする // 有効期限が近いものから割り当てるため、expiry_dateがnullのものは最後にする const sortedLicense = license.sort((a, b) => { if (a.expiry_date && b.expiry_date) { return a.expiry_date.getTime() - b.expiry_date.getTime(); } else if (a.expiry_date && !b.expiry_date) { return -1; } else if (!a.expiry_date && b.expiry_date) { return 1; } else { return 0; } }); // 有効期限が近いライセンスを返却する return sortedLicense[0]; } catch (e) { context.error(e); context.log("getAutoAllocatableLicense failed."); throw e; } finally { context.log("[OUT]getAutoAllocatableLicense"); } } /** * ライセンスを割り当てる * @param context * @param datasource * @param account アカウント・ユーザーID * @param currentDateZeroTime * @param currentDateEndTime */ export async function allocateLicense( context: InvocationContext, datasource: DataSource, redisClient: RedisClient, sendGrid: SendGridService, adb2c: AdB2cService, autoAllocationList: autoAllocationList, currentDateZeroTime: DateWithZeroTime, currentDateEndTime: DateWithDayEndTime ): Promise { context.log("[IN]allocateLicense"); try { // 割り当て可能なライセンスが存在するかどうかのフラグ let hasAllocatebleLicense = true; // ユーザーに割り当てられているライセンスが自動更新対象であるかどうかのフラグ let hasAutoRenewLicense = true; for (const userId of autoAllocationList.userIds) { await datasource.transaction(async (entityManager) => { // フラグの初期化 hasAutoRenewLicense = true; const licenseRepository = entityManager.getRepository(License); const licenseAllocationHistoryRepo = entityManager.getRepository( LicenseAllocationHistory ); // 割り当て可能なライセンスを取得する(自動割り当て用) const autoAllocatableLicense = await getAutoAllocatableLicense( context, licenseRepository, autoAllocationList.accountId, currentDateEndTime ); // 割り当て可能なライセンスが存在しなければreturnし、その後ループ終了 if (!autoAllocatableLicense) { context.log(`allocatable license not exist.`); hasAllocatebleLicense = false; return; } // ライセンスが直前で手動割り当てされていたら、自動割り当てしない const allocatedLicense = await licenseRepository.findOne({ where: { allocated_user_id: userId, expiry_date: Between(currentDateZeroTime, currentDateEndTime), }, }); if (!allocatedLicense) { context.log(`skip auto allocation. userID:${userId}`); hasAutoRenewLicense = false; return; } // 古いライセンスの割り当て解除 allocatedLicense.status = LICENSE_ALLOCATED_STATUS.REUSABLE; allocatedLicense.allocated_user_id = null; await licenseRepository.save(allocatedLicense); // ライセンス割り当て履歴テーブルへ登録 const deallocationHistory = new LicenseAllocationHistory(); deallocationHistory.user_id = userId; deallocationHistory.license_id = allocatedLicense.id; deallocationHistory.account_id = autoAllocationList.accountId; deallocationHistory.is_allocated = false; deallocationHistory.executed_at = new Date(); deallocationHistory.switch_from_type = SWITCH_FROM_TYPE.NONE; await licenseAllocationHistoryRepo.save(deallocationHistory); // 新規ライセンス割り当て autoAllocatableLicense.status = LICENSE_ALLOCATED_STATUS.ALLOCATED; autoAllocatableLicense.allocated_user_id = userId; // 有効期限が未設定なら365日後に設定 if (!autoAllocatableLicense.expiry_date) { autoAllocatableLicense.expiry_date = new NewAllocatedLicenseExpirationDate(); } await licenseRepository.save(autoAllocatableLicense); context.log( `license allocated. userID:${userId}, licenseID:${autoAllocatableLicense.id}` ); // ライセンス割り当て履歴テーブルを更新するための処理 // 直近割り当てたライセンス種別を取得 const oldLicenseType = await licenseAllocationHistoryRepo.findOne({ relations: { license: true, }, where: { user_id: userId, is_allocated: true }, order: { executed_at: "DESC" }, }); let switchFromType = ""; if (oldLicenseType && oldLicenseType.license) { switch (oldLicenseType.license.type) { case LICENSE_TYPE.CARD: switchFromType = SWITCH_FROM_TYPE.CARD; break; case LICENSE_TYPE.TRIAL: switchFromType = SWITCH_FROM_TYPE.TRIAL; break; default: switchFromType = SWITCH_FROM_TYPE.NONE; break; } } else { switchFromType = SWITCH_FROM_TYPE.NONE; } // ライセンス割り当て履歴テーブルへ登録 const allocationHistory = new LicenseAllocationHistory(); allocationHistory.user_id = userId; allocationHistory.license_id = autoAllocatableLicense.id; allocationHistory.account_id = autoAllocationList.accountId; allocationHistory.is_allocated = true; allocationHistory.executed_at = new Date(); // TODO switchFromTypeの値については「PBI1234: 第一階層として、ライセンス数推移情報をCSV出力したい」で正式対応 allocationHistory.switch_from_type = switchFromType; await licenseAllocationHistoryRepo.save(allocationHistory); }); // 割り当て可能なライセンスが存在しなければループ終了 if (!hasAllocatebleLicense) { break; } // ユーザーに割り当てられているライセンスが自動更新対象であるかどうかのフラグがfalseの場合、次のユーザーへ if (!hasAutoRenewLicense) { continue; } try { //メール送信に必要な情報をDBから取得 const userRepository = datasource.getRepository(User); const accountRepository = datasource.getRepository(Account); // ライセンスを割り当てたユーザーとアカウントの情報を取得 const user = await userRepository.findOne({ where: { id: userId }, }); if (!user) { throw new Error(`Target user not found. ${userId}`); } const account = await accountRepository.findOne({ where: { id: autoAllocationList.accountId }, relations: { primaryAdminUser: true, secondaryAdminUser: true, }, }); if (!account) { throw new Error( `Target account not found. ${autoAllocationList.accountId}` ); } // アカウントのプライマリー管理者が存在しない場合はエラー if (!account.primaryAdminUser) { throw new Error( `Primary admin user not found. accountID: ${account.id}` ); } // 親アカウントが存在する場合は取得 let parentAccount: Account | null = null; if (account.parent_account_id) { parentAccount = await accountRepository.findOne({ where: { id: account.parent_account_id }, }); if (!parentAccount) { throw new Error( `Parent account not found. accountID: ${account.parent_account_id}` ); } } // アカウントの管理者とライセンスを割り当てたユーザーのメールアドレス取得に必要な外部IDを抽出 const externalIds: string[] = []; externalIds.push(user.external_id); externalIds.push(account.primaryAdminUser.external_id); // セカンダリ管理者が存在する場合はセカンダリ管理者の外部IDも抽出 if (account.secondaryAdminUser) { externalIds.push(account.secondaryAdminUser.external_id); } const adb2cUsers = await getMailAddressAndDisplayNameList( context, redisClient, adb2c, externalIds ); // ライセンス割り当てされたユーザーの名前を取得 const userName = adb2cUsers.find( (adb2cUser) => adb2cUser.externalId === user.external_id )?.displayName; if (!userName) { throw new Error( `Target ADb2Cuser name not found. externalId=${user.external_id}` ); } // ライセンス割り当てされたユーザーのメールアドレスを取得 const userMail = adb2cUsers.find( (adb2cUser) => adb2cUser.externalId === user.external_id )?.mailAddress; if (!userMail) { throw new Error( `Target ADb2Cuser mail not found. externalId=${user.external_id}` ); } // アカウントのプライマリー管理者のメールアドレスを取得 const adminMails: string[] = []; const primaryAdminMail = adb2cUsers.find( (adb2cUser) => adb2cUser.externalId === account.primaryAdminUser?.external_id )?.mailAddress; if (!primaryAdminMail) { throw new Error( `Primary admin user mail not found. externalId=${account.primaryAdminUser.external_id}` ); } adminMails.push(primaryAdminMail); // アカウントのセカンダリ管理者のメールアドレスを取得 const secondaryAdminMail = adb2cUsers.find( (adb2cUser) => adb2cUser.externalId === account.secondaryAdminUser?.external_id )?.mailAddress; if (secondaryAdminMail) { adminMails.push(secondaryAdminMail); } // メール送信 await sendMailWithU108( context, userName, userMail, adminMails, account.company_name, parentAccount ? parentAccount.company_name : null, sendGrid ); } catch (e) { context.error(`error=${e}`); // メール送信に関する例外はログだけ出して握りつぶす } } } catch (e) { // エラーが発生しても次のアカウントへの処理は継続させるため、例外をthrowせずにreturnだけする context.log("allocateLicense failed."); context.error(e); return; } finally { context.log("[OUT]allocateLicense"); } } // adb2cから指定した外部IDのユーザー情報を取得する export async function getMailAddressAndDisplayNameList( context: InvocationContext, redisClient: RedisClient, adb2c: AdB2cService, externalIds: string[] ): Promise< { externalId: string; displayName: string; mailAddress: string; }[] > { context.log("[IN]getUsers"); try { const users = [] as { externalId: string; displayName: string; mailAddress: string; }[]; // 外部IDからADB2Cユーザー情報を取得 const adb2cUsers = await adb2c.getUsers(context, redisClient, externalIds); for (const externalId of externalIds) { const adb2cUser = adb2cUsers.find((user) => user.id === externalId); if (!adb2cUser) { throw new Error(`ADB2C user not found. externalId=${externalId}`); } const mailAddress = adb2cUser.identities?.find( (identity) => identity.signInType === ADB2C_SIGN_IN_TYPE.EMAILADDRESS )?.issuerAssignedId; if (!mailAddress) { throw new Error(`ADB2C user mail not found. externalId=${externalId}`); } users.push({ externalId: externalId, displayName: adb2cUser.displayName, mailAddress: mailAddress, }); } return users; } catch (e) { context.error(e); context.log("getUsers failed."); throw e; } finally { context.log("[OUT]getUsers"); } } /** * U-108のテンプレートを使用したメールを送信する * @param context * @param userName ライセンス割り当てされたユーザーの名前 * @param userMail ライセンス割り当てされたユーザーのメールアドレス * @param customerAdminMails ライセンス割り当てされたユーザーの所属するアカウントの管理者(primary/secondary)のメールアドレス * @param customerAccountName ライセンス割り当てされたユーザーの所属するアカウントの名前 * @param dealerAccountName 問題発生時に問い合わせする先の上位のディーラー名(会社名) * @returns mail with u108 */ export async function sendMailWithU108( context: InvocationContext, userName: string, userMail: string, customerAdminMails: string[], customerAccountName: string, dealerAccountName: string | null, sendGrid: SendGridService ): Promise { context.log("[IN] sendMailWithU108"); try { const subject = "License Assigned Notification [U-108]"; const domain = process.env.APP_DOMAIN; if (!domain) { throw new Error("APP_DOMAIN is not defined."); } const mailFrom = process.env.MAIL_FROM; if (!mailFrom) { throw new Error("MAIL_FROM is not defined."); } const url = new URL(domain).href; let html: string; let text: string; if (dealerAccountName === null) { const templateU108NoParentHtml = readFileSync( path.resolve(__dirname, `../templates/template_U_108_no_parent.html`), "utf-8" ); const templateU108NoParentText = readFileSync( path.resolve(__dirname, `../templates/template_U_108_no_parent.txt`), "utf-8" ); html = templateU108NoParentHtml .replaceAll(CUSTOMER_NAME, customerAccountName) .replaceAll(USER_NAME, userName) .replaceAll(USER_EMAIL, userMail) .replaceAll(TOP_URL, url); text = templateU108NoParentText .replaceAll(CUSTOMER_NAME, customerAccountName) .replaceAll(USER_NAME, userName) .replaceAll(USER_EMAIL, userMail) .replaceAll(TOP_URL, url); } else { const templateU108Html = readFileSync( path.resolve(__dirname, `../templates/template_U_108.html`), "utf-8" ); const templateU108Text = readFileSync( path.resolve(__dirname, `../templates/template_U_108.txt`), "utf-8" ); html = templateU108Html .replaceAll(CUSTOMER_NAME, customerAccountName) .replaceAll(DEALER_NAME, dealerAccountName) .replaceAll(USER_NAME, userName) .replaceAll(USER_EMAIL, userMail) .replaceAll(TOP_URL, url); text = templateU108Text .replaceAll(CUSTOMER_NAME, customerAccountName) .replaceAll(DEALER_NAME, dealerAccountName) .replaceAll(USER_NAME, userName) .replaceAll(USER_EMAIL, userMail) .replaceAll(TOP_URL, url); } const ccAddress = customerAdminMails.includes(userMail) ? [] : [userMail]; // メールを送信する await sendGrid.sendMail( context, customerAdminMails, ccAddress, mailFrom, subject, text, html ); } finally { context.log(`[OUT] sendMailWithU108`); } } app.timer("licenseAutoAllocation", { schedule: "0 0 16 * * *", handler: licenseAutoAllocation, }); class autoAllocationList { accountId: number; userIds: number[]; }