import { app, InvocationContext, Timer } from "@azure/functions"; import { Between, DataSource, In, IsNull, MoreThan, Not } from "typeorm"; import { User } from "../entity/user.entity"; import { Account } from "../entity/account.entity"; import { ADB2C_SIGN_IN_TYPE, LICENSE_ALLOCATED_STATUS, TIERS, } from "../constants"; import * as dotenv from "dotenv"; import { License } from "../entity/license.entity"; import { DateWithDayEndTime, DateWithZeroTime, ExpirationThresholdDate, } from "../common/types/types"; import { createMailContentOfLicenseShortage } from "../sendgrid/mailContents/U103ShortageAlert"; import { createMailContentOfLicenseExpiringSoon } from "../sendgrid/mailContents/U104ExpiringSoonAlert"; import { AdB2cService } from "../adb2c/adb2c"; import { SendGridService } from "../sendgrid/sendgrid"; import { getMailFrom } from "../common/getEnv/getEnv"; import { createRedisClient } from "../redis/redis"; import { RedisClient } from "redis"; import { promisify } from "util"; import { makeSendCompKey } from "../common/cache"; import { MAIL_U103, MAIL_U104, SEND_COMPLETE_PREFIX, DONE, } from "../common/cache/constants"; export async function licenseAlertProcessing( context: InvocationContext, datasource: DataSource, redisClient: RedisClient, sendgrid: SendGridService, adb2c: AdB2cService ) { try { context.log("[IN]licenseAlertProcessing"); // redisのキー用 const currentDate = new DateWithZeroTime(); const formattedDate = `${currentDate.getFullYear()}-${( currentDate.getMonth() + 1 ).toString()}-${currentDate.getDate().toString()}`; const keysAsync = promisify(redisClient.keys).bind(redisClient); // メール送信対象のアカウント情報を取得 const sendTargetAccounts = await getAlertMailTargetAccount( context, datasource ); // adb2cからメールアドレスを取得し、上記で取得したアカウントにマージする const sendTargetAccountsMargedAdb2c = await createAccountInfo( context, redisClient, adb2c, sendTargetAccounts ); // メール送信 await sendAlertMail( context, redisClient, sendgrid, sendTargetAccountsMargedAdb2c, formattedDate ); // 最後まで処理が正常に通ったら、redisに保存した送信情報を削除する try { const delAsync = promisify(redisClient.del).bind(redisClient); const keys = await keysAsync(`${SEND_COMPLETE_PREFIX}${formattedDate}*`); console.log(`delete terget:${keys}`); if (keys.length > 0) { const delResult = await delAsync(keys); console.log(`delete number:${delResult}`); } } catch (e) { context.log("redis delete failed"); throw e; } } catch (e) { throw e; } finally { context.log("[OUT]licenseAlertProcessing"); } } export async function licenseAlert( myTimer: Timer, context: InvocationContext ): Promise { context.log("[IN]licenseAlert"); dotenv.config({ path: ".env" }); dotenv.config({ path: ".env.local", override: true }); let datasource: DataSource; try { datasource = new DataSource({ type: "mysql", host: process.env.DB_HOST, port: Number(process.env.DB_PORT), username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, entities: [User, Account, License], }); await datasource.initialize(); } catch (e) { context.log("database initialize failed."); context.error(e); throw e; } let redisClient: RedisClient; try { // redis接続 redisClient = createRedisClient(); } catch (e) { context.log("redis client create failed."); context.error(e); throw e; } try { const adb2c = new AdB2cService(); const sendgrid = new SendGridService(); await licenseAlertProcessing( context, datasource, redisClient, sendgrid, adb2c ); } catch (e) { context.log("licenseAlertProcessing failed."); context.error(e); throw e; } finally { await datasource.destroy(); redisClient.quit; context.log("[OUT]licenseAlert"); } } /** * アラートメールを送信する対象のアカウントを取得する * @param context * @param datasource * @returns accountInfo[] メール送信対象のアカウント情報 */ async function getAlertMailTargetAccount( context: InvocationContext, datasource: DataSource ): Promise { try { context.log("[IN]getAlertMailTargetAccount"); const currentDate = new DateWithZeroTime(); const expiringSoonDate = new ExpirationThresholdDate(currentDate.getTime()); const currentDateWithZeroTime = new DateWithZeroTime(); const currentDateWithDayEndTime = new DateWithDayEndTime(); // 第五のアカウントを取得 const accountRepository = datasource.getRepository(Account); const accounts = await accountRepository.find({ where: { tier: TIERS.TIER5, }, relations: { primaryAdminUser: true, secondaryAdminUser: true, }, }); const sendTargetAccounts = [] as accountInfo[]; const licenseRepository = datasource.getRepository(License); for (const account of accounts) { // 有効期限がしきい値より未来または未設定で、割り当て可能なライセンス数の取得を行う const allocatableLicenseWithMargin = await licenseRepository.count({ where: [ { account_id: account.id, status: In([ LICENSE_ALLOCATED_STATUS.UNALLOCATED, LICENSE_ALLOCATED_STATUS.REUSABLE, ]), expiry_date: MoreThan(expiringSoonDate), }, { account_id: account.id, status: In([ LICENSE_ALLOCATED_STATUS.UNALLOCATED, LICENSE_ALLOCATED_STATUS.REUSABLE, ]), expiry_date: IsNull(), }, ], }); // 有効期限が現在日付からしきい値以内のライセンス数を取得する const expiringSoonLicense = await licenseRepository.count({ where: { account_id: account.id, expiry_date: Between(currentDate, expiringSoonDate), status: Not(LICENSE_ALLOCATED_STATUS.DELETED), }, }); // shortage算出 let shortage = allocatableLicenseWithMargin - expiringSoonLicense; shortage = shortage >= 0 ? 0 : Math.abs(shortage); // AutoRenewが未チェックかつ、有効期限当日のライセンスが割り当てられているユーザー数を取得 const userCount = await licenseRepository.count({ where: [ { account_id: account.id, expiry_date: Between( currentDateWithZeroTime, currentDateWithDayEndTime ), status: LICENSE_ALLOCATED_STATUS.ALLOCATED, user: { auto_renew: false, }, }, ], relations: { user: true, }, }); // 上で取得したshortageとユーザー数のどちらかが1以上ならプライマリ、セカンダリ管理者、親企業名を保持する // (shortageとユーザー数のどちらかが1以上 = アラートメールを送る必要がある) let primaryAdminExternalId: string | undefined; let secondaryAdminExternalId: string | undefined; let parentCompanyName: string | undefined; if (shortage !== 0 || userCount !== 0) { primaryAdminExternalId = account.primaryAdminUser ? account.primaryAdminUser.external_id : undefined; secondaryAdminExternalId = account.secondaryAdminUser ? account.secondaryAdminUser.external_id : undefined; // 第五のアカウントを取得 // strictNullChecks対応 if (account.parent_account_id) { const parent = await accountRepository.findOne({ where: { id: account.parent_account_id, }, }); parentCompanyName = parent?.company_name; } } else { primaryAdminExternalId = undefined; secondaryAdminExternalId = undefined; parentCompanyName = undefined; } sendTargetAccounts.push({ accountId: account.id, companyName: account.company_name, parentCompanyName: parentCompanyName, shortage: shortage, userCountOfLicenseExpiringSoon: userCount, primaryAdminExternalId: primaryAdminExternalId, secondaryAdminExternalId: secondaryAdminExternalId, primaryAdminEmail: undefined, secondaryAdminEmail: undefined, }); } return sendTargetAccounts; } catch (e) { context.error(e); context.log("getAlertMailTargetAccount failed."); throw e; } finally { context.log("[OUT]getAlertMailTargetAccount"); } } /** * Azure AD B2Cからユーザ情報を取得し、アカウント情報を作成する * @param context * @param redisClient * @param adb2c * @param sendTargetAccounts RDBから取得したアカウント情報 * @returns accountInfo[] メール送信対象のアカウント情報 */ async function createAccountInfo( context: InvocationContext, redisClient: RedisClient, adb2c: AdB2cService, sendTargetAccounts: accountInfo[] ): Promise { // ADB2Cからユーザーを取得する用の外部ID配列を作成 const externalIds = [] as string[]; sendTargetAccounts.forEach((x) => { if (x.primaryAdminExternalId) { externalIds.push(x.primaryAdminExternalId); } if (x.secondaryAdminExternalId) { externalIds.push(x.secondaryAdminExternalId); } }); const adb2cUsers = await adb2c.getUsers(context, redisClient, externalIds); if (adb2cUsers.length === 0) { context.log("Target user not found"); return []; } // ADB2Cから取得したメールアドレスをRDBから取得した情報にマージ sendTargetAccounts.map((info) => { const primaryAdminUser = adb2cUsers.find( (adb2c) => info.primaryAdminExternalId === adb2c.id ); if (primaryAdminUser) { const primaryAdminMail = primaryAdminUser.identities?.find( (identity) => identity.signInType === ADB2C_SIGN_IN_TYPE.EMAILADDRESS )?.issuerAssignedId; if (primaryAdminMail) { info.primaryAdminEmail = primaryAdminMail; } const secondaryAdminUser = adb2cUsers.find( (adb2c) => info.secondaryAdminExternalId === adb2c.id ); if (secondaryAdminUser) { const secondaryAdminMail = secondaryAdminUser.identities?.find( (identity) => identity.signInType === ADB2C_SIGN_IN_TYPE.EMAILADDRESS )?.issuerAssignedId; if (secondaryAdminMail) { info.secondaryAdminEmail = secondaryAdminMail; } } } }); return sendTargetAccounts; } /** * アラートメールを送信する * @param context * @param redisClient * @param sendgrid * @param sendTargetAccounts メール送信対象のアカウント情報 * @param formattedDate redisのキーに使用する日付 * @returns ユーザ情報 */ async function sendAlertMail( context: InvocationContext, redisClient: RedisClient, sendgrid: SendGridService, sendTargetAccounts: accountInfo[], formattedDate: string ): Promise { try { context.log("[IN]sendAlertMail"); // redis用 const getAsync = promisify(redisClient.get).bind(redisClient); const setexAsync = promisify(redisClient.setex).bind(redisClient); const ttl = process.env.ADB2C_CACHE_TTL; const mailFrom = getMailFrom(); for (const targetAccount of sendTargetAccounts) { // プライマリ管理者が入っているかチェック // 入っていない場合は、アラートメールを送信する必要が無いため、何も処理をせず次のループへ if (targetAccount.primaryAdminExternalId) { // メール送信 // strictNullChecks対応 if (!targetAccount.primaryAdminEmail) { continue; } // ライセンス不足メール if (targetAccount.shortage !== 0) { // redisに送信履歴がない場合のみ送信する const mailResult = await getAsync( makeSendCompKey( formattedDate, targetAccount.primaryAdminExternalId, MAIL_U103 ) ); if (mailResult !== DONE) { const { subject, text, html } = await createMailContentOfLicenseShortage( targetAccount.companyName, targetAccount.shortage, targetAccount.parentCompanyName ); // メールを送信 try { await sendgrid.sendMail( targetAccount.primaryAdminEmail, mailFrom, subject, text, html ); context.log( `Shortage mail send success. mail to :${targetAccount.primaryAdminEmail}` ); // 送信成功時、成功履歴をredisに保存 try { const key = makeSendCompKey( formattedDate, targetAccount.primaryAdminExternalId, MAIL_U103 ); await setexAsync(key, ttl, DONE); context.log( "setex Result:", `key:${key},ttl:${ttl},value:Done` ); } catch (e) { context.error(e); context.log( "setex failed.", `target: ${targetAccount.primaryAdminEmail}` ); } } catch (e) { context.error(e); context.log( `Shortage mail send failed. mail to :${targetAccount.primaryAdminEmail}` ); throw e; } } // セカンダリ管理者が存在する場合、セカンダリ管理者にも送信 if ( targetAccount.secondaryAdminEmail && targetAccount.secondaryAdminExternalId ) { // redisに送信履歴がない場合のみ送信する const mailResult = await getAsync( makeSendCompKey( formattedDate, targetAccount.secondaryAdminExternalId, MAIL_U103 ) ); if (mailResult !== DONE) { const { subject, text, html } = await createMailContentOfLicenseShortage( targetAccount.companyName, targetAccount.shortage, targetAccount.parentCompanyName ); // メールを送信 try { await sendgrid.sendMail( targetAccount.secondaryAdminEmail, mailFrom, subject, text, html ); context.log( `Shortage mail send success. mail to :${targetAccount.secondaryAdminEmail}` ); // 送信成功時、成功履歴をredisに保存 try { const key = makeSendCompKey( formattedDate, targetAccount.secondaryAdminExternalId, MAIL_U103 ); await setexAsync(key, ttl, DONE); context.log( "setex Result:", `key:${key},ttl:${ttl},value:Done` ); } catch (e) { context.error(e); context.log( "setex failed.", `target: ${targetAccount.secondaryAdminEmail}` ); } } catch (e) { context.error(e); context.log( `Shortage mail send failed. mail to :${targetAccount.secondaryAdminEmail}` ); throw e; } } } } // ライセンス失効警告メール if (targetAccount.userCountOfLicenseExpiringSoon !== 0) { // redisに送信履歴がない場合のみ送信する const mailResult = await getAsync( makeSendCompKey( formattedDate, targetAccount.primaryAdminExternalId, MAIL_U104 ) ); if (mailResult !== DONE) { const { subject, text, html } = await createMailContentOfLicenseExpiringSoon( targetAccount.companyName, targetAccount.userCountOfLicenseExpiringSoon, targetAccount.parentCompanyName ); // メールを送信 try { await sendgrid.sendMail( targetAccount.primaryAdminEmail, mailFrom, subject, text, html ); context.log( `Expiring soon mail send success. mail to :${targetAccount.primaryAdminEmail}` ); // 送信成功時、成功履歴をredisに保存 try { const key = makeSendCompKey( formattedDate, targetAccount.primaryAdminExternalId, MAIL_U104 ); await setexAsync(key, ttl, DONE); context.log( "setex Result:", `key:${key},ttl:${ttl},value:Done` ); } catch (e) { context.error(e); context.log( "setex failed.", `target: ${targetAccount.primaryAdminEmail}` ); } } catch (e) { context.error(e); context.log( `Expiring soon mail send failed. mail to :${targetAccount.primaryAdminEmail}` ); throw e; } } // セカンダリ管理者が存在する場合、セカンダリ管理者にも送信 if ( targetAccount.secondaryAdminEmail && targetAccount.secondaryAdminExternalId ) { // redisに送信履歴がない場合のみ送信する const mailResult = makeSendCompKey( formattedDate, targetAccount.secondaryAdminExternalId, MAIL_U104 ); if (mailResult !== DONE) { const { subject, text, html } = await createMailContentOfLicenseExpiringSoon( targetAccount.companyName, targetAccount.userCountOfLicenseExpiringSoon, targetAccount.parentCompanyName ); // メールを送信 try { await sendgrid.sendMail( targetAccount.secondaryAdminEmail, mailFrom, subject, text, html ); context.log( `Expiring soon mail send success. mail to :${targetAccount.secondaryAdminEmail}` ); try { const key = makeSendCompKey( formattedDate, targetAccount.secondaryAdminExternalId, MAIL_U104 ); await setexAsync(key, ttl, DONE); context.log( "setex Result:", `key:${key},ttl:${ttl},value:Done` ); } catch (e) { context.error(e); context.log( "setex failed.", `target: ${targetAccount.secondaryAdminEmail}` ); } } catch (e) { context.error(e); context.log( `Expiring soon mail send failed. mail to :${targetAccount.secondaryAdminEmail}` ); throw e; } } } } } } } catch (e) { context.log("sendAlertMail failed."); throw e; } finally { context.log("[OUT]sendAlertMail"); } } app.timer("licenseAlert", { schedule: "0 0 1 * * *", handler: licenseAlert, }); class accountInfo { accountId: number; companyName: string; parentCompanyName: string | undefined; shortage: number; userCountOfLicenseExpiringSoon: number; primaryAdminExternalId: string | undefined; secondaryAdminExternalId: string | undefined; primaryAdminEmail: string | undefined; secondaryAdminEmail: string | undefined; }