diff --git a/openapi.json b/openapi.json index 8fc14949..a6413f67 100644 --- a/openapi.json +++ b/openapi.json @@ -1,7 +1,7 @@ { "swagger": "2.0", "info": { - "version": "1.26.16", + "version": "1.26.18", "title": "THX API Specification", "description": "User guides are available at https://docs.thx.network." }, @@ -576,6 +576,11 @@ ], "description": "", "parameters": [ + { + "name": "forceSync", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", @@ -752,6 +757,11 @@ ], "description": "", "parameters": [ + { + "name": "forceSync", + "in": "query", + "type": "string" + }, { "name": "body", "in": "body", diff --git a/src/jobs/reminders/01_accountCreatedNoTokens.ts b/src/jobs/reminders/01_accountCreatedNoTokens.ts new file mode 100644 index 00000000..42fc8971 --- /dev/null +++ b/src/jobs/reminders/01_accountCreatedNoTokens.ts @@ -0,0 +1,59 @@ +import { TransactionalEmail } from '@/models/TransactionalEmail'; +import { EVENT_SEND_REMINDER_ACCOUNT_CREATED_NO_TOKENS, MAX_REMINDERS } from '@/util/agenda'; +import ERC20 from '../../models/ERC20'; +import { ERC721 } from '../../models/ERC721'; +import AccountProxy from '../../proxies/AccountProxy'; +import { logger } from '../../util/logger'; +import { sendEmail } from './utils'; + +export const reminderJobAccountCreatedNoTokens = async () => { + const reminderType = EVENT_SEND_REMINDER_ACCOUNT_CREATED_NO_TOKENS; + try { + logger.info(`START REMINDER JOB - ${reminderType}`); + + // Account created but no Tokens or Collectibles + const accounts = await AccountProxy.getActiveAccountsEmail(); + const promises = accounts.map(async (account) => { + const now = Date.now(); + const maxInactivityDays = 7; + + if ((now - new Date(account.createdAt).getTime()) / (24 * 60 * 60 * 1000) < maxInactivityDays) { + logger.info('ACCOUNT REGISTERED LESS THAN 7 DAYS AGO'); + return; + } + + const numERC20s = await ERC20.count({ sub: account.id }); + const numERC721s = await ERC721.count({ sub: account.id }); + + if (numERC20s + numERC721s == 0) { + const numEmailSent = await TransactionalEmail.count({ sub: account.id, type: reminderType }); + + if (numEmailSent >= MAX_REMINDERS) { + logger.info('MAXIMUM NUMBER OF EMAILS REACHED.'); + return; + } + + const subject = 'THX Reminder'; + const title = 'Account created but no Tokens or Collectibles'; + const message = 'Email with tips on how to deploy assets'; + + await sendEmail({ to: account.email, subject, title, message }); + + await TransactionalEmail.create({ + sub: account.id, + type: reminderType, + }); + logger.info('EMAIL SENT'); + } + }); + if (promises.length) { + await Promise.all(promises); + } else { + logger.info('NO ACCOUNTS TO PROCESS.'); + } + + logger.info('END REMINDER JOB'); + } catch (err) { + logger.error(`ERROR on Reminders Job - ${reminderType}`, err); + } +}; diff --git a/src/jobs/reminders/02_tokensCreatedNoPools.ts b/src/jobs/reminders/02_tokensCreatedNoPools.ts new file mode 100644 index 00000000..02fec4cd --- /dev/null +++ b/src/jobs/reminders/02_tokensCreatedNoPools.ts @@ -0,0 +1,52 @@ +import ERC20 from '../../models/ERC20'; +import { ERC721 } from '../../models/ERC721'; +import AccountProxy from '../../proxies/AccountProxy'; +import { AssetPool } from '../../models/AssetPool'; +import { logger } from '../../util/logger'; +import { sendEmail } from './utils'; +import { TransactionalEmail } from '@/models/TransactionalEmail'; +import { EVENT_SEND_REMINDER_TOKENS_CREATED_NO_POOLS, MAX_REMINDERS } from '@/util/agenda'; + +export const reminderJobTokensCreatedNoPools = async () => { + const reminderType = EVENT_SEND_REMINDER_TOKENS_CREATED_NO_POOLS; + try { + logger.info(`START REMINDER JOB - ${reminderType}`); + + // Account and tokens / collectibles created but no pools + const accounts = await AccountProxy.getActiveAccountsEmail(); + const promises = accounts.map(async (account) => { + const numERC20s = await ERC20.count({ sub: account.id }); + const numERC721s = await ERC721.count({ sub: account.id }); + + if (numERC20s + numERC721s == 0) { + return; + } + + const pools = await AssetPool.find({ sub: account.id }); + + if (pools.length == 0) { + const numEmailSent = await TransactionalEmail.count({ sub: account.id, type: reminderType }); + + if (numEmailSent >= MAX_REMINDERS) { + logger.info('MAXIMUM NUMBER OF EMAILS REACHED.'); + return; + } + + const subject = 'THX Reminder'; + const title = 'Account and tokens / collectibles created but no pools to start using features'; + const message = 'Email with tips on how to config pool'; + + await sendEmail({ to: account.email, subject, title, message }); + await TransactionalEmail.create({ sub: account.id, type: reminderType }); + logger.info('EMAIL SENT'); + } + }); + if (promises.length) { + await Promise.all(promises); + } else { + logger.info('NO ACCOUNTS TO PROCESS.'); + } + } catch (err) { + logger.error(`ERROR on Reminders Job - ${reminderType}`, err); + } +}; diff --git a/src/jobs/reminders/03_poolsCreatedNoWithdrawals.ts b/src/jobs/reminders/03_poolsCreatedNoWithdrawals.ts new file mode 100644 index 00000000..45944e53 --- /dev/null +++ b/src/jobs/reminders/03_poolsCreatedNoWithdrawals.ts @@ -0,0 +1,61 @@ +import AccountProxy from '../../proxies/AccountProxy'; +import { AssetPool } from '../../models/AssetPool'; +import { Withdrawal } from '../../models/Withdrawal'; +import { logger } from '../../util/logger'; +import { sendEmail } from './utils'; +import { TransactionalEmail } from '@/models/TransactionalEmail'; +import { EVENT_SEND_REMINDER_POOLS_CREATED_NO_WITHDRAWALS, MAX_REMINDERS } from '@/util/agenda'; + +export const reminderJobPoolsCreatedNoWithdrawals = async () => { + const reminderType = EVENT_SEND_REMINDER_POOLS_CREATED_NO_WITHDRAWALS; + try { + logger.info(`START REMINDER JOB - ${reminderType}`); + + // Pool created for tokens / collectibles but no withdrawals + const pools = await AssetPool.find(); + + const promises = pools.map(async (pool) => { + const numWithdrawals = await Withdrawal.count({ poolId: pool._id }); + if (numWithdrawals > 0) { + return; + } + const account = await AccountProxy.getById(pool.sub); + if (!account) { + logger.error('POOL ACCOUNT NOT FOUND', { poolId: pool._id }); + return; + } + if (!account.active) { + logger.info('POOL ACCOUNT NOT ACTIVE'); + return; + } + if (!account.email) { + logger.error('ACCOUNT EMAIL NOT SET', { accountId: account._id }); + return; + } + + const numEmailSent = await TransactionalEmail.count({ sub: account.id, type: reminderType }); + + if (numEmailSent >= MAX_REMINDERS) { + logger.info('MAXIMUM NUMBER OF EMAILS REACHED.'); + return; + } + + const subject = 'THX Reminder'; + const title = 'Pool created for tokens / collectibles but no withdrawals for that pool'; + const message = 'Email with tips on how to config rewards etc'; + + await sendEmail({ to: account.email, subject, title, message }); + await TransactionalEmail.create({ sub: account.id, type: reminderType }); + logger.info('EMAIL SENT'); + }); + + if (promises.length) { + await Promise.all(promises); + } else { + logger.info('NO POOLS TO PROCESS.'); + } + logger.info('END REMINDER JOB'); + } catch (err) { + logger.error(`ERROR on Reminders Job - ${reminderType}`, err); + } +}; diff --git a/src/jobs/reminders/utils.ts b/src/jobs/reminders/utils.ts new file mode 100644 index 00000000..09c7fe35 --- /dev/null +++ b/src/jobs/reminders/utils.ts @@ -0,0 +1,18 @@ +import MailService from '../../services/MailService'; +import ejs from 'ejs'; +import { API_URL, DASHBOARD_URL } from '@/config/secrets'; + +export async function sendEmail(data: { to: string; subject: string; title: string; message: string }) { + const html = await ejs.renderFile( + './src/templates/email/reminder.ejs', + { + title: data.title, + message: data.message, + baseUrl: API_URL, + dashboardUrl: DASHBOARD_URL, + }, + { async: true }, + ); + + return await MailService.send(data.to, data.subject, html); +} diff --git a/src/models/Account.ts b/src/models/Account.ts index fdc59c67..a53949c1 100644 --- a/src/models/Account.ts +++ b/src/models/Account.ts @@ -9,6 +9,7 @@ export interface IAccount { twitter?: any; plan: AccountPlanType; email: string; + createdAt?: Date; } export interface ERC20Token { chainId: ChainId; diff --git a/src/models/TransactionalEmail.ts b/src/models/TransactionalEmail.ts new file mode 100644 index 00000000..36ee5336 --- /dev/null +++ b/src/models/TransactionalEmail.ts @@ -0,0 +1,17 @@ +import { TTransactionalEmail } from '@/types/TTransactionalEmail'; +import mongoose from 'mongoose'; + +export type TransactionalEmailDocument = mongoose.Document & TTransactionalEmail; + +const transactionalEmailSchema = new mongoose.Schema( + { + type: String, + sub: String, + }, + { timestamps: true }, +); + +export const TransactionalEmail = mongoose.model( + 'TransactionalEmail', + transactionalEmailSchema, +); diff --git a/src/proxies/AccountProxy.ts b/src/proxies/AccountProxy.ts index 82238a95..b17e5f6f 100644 --- a/src/proxies/AccountProxy.ts +++ b/src/proxies/AccountProxy.ts @@ -51,6 +51,17 @@ export default class AccountProxy { return data; } + static async getActiveAccountsEmail(): Promise { + const { data } = await authClient({ + method: 'GET', + url: 'account/emails', + headers: { + Authorization: await getAuthAccessToken(), + }, + }); + return data; + } + static async isEmailDuplicate(email: string) { try { await authClient({ diff --git a/src/templates/email/reminder.ejs b/src/templates/email/reminder.ejs new file mode 100644 index 00000000..0b72f3bc --- /dev/null +++ b/src/templates/email/reminder.ejs @@ -0,0 +1,413 @@ + + + + + + + Reset your Password + + + + + This is preheader text. Some clients will show this text as a preview. + + + + + + + + + + \ No newline at end of file diff --git a/src/types/TTransactionalEmail.ts b/src/types/TTransactionalEmail.ts new file mode 100644 index 00000000..6bacb1a8 --- /dev/null +++ b/src/types/TTransactionalEmail.ts @@ -0,0 +1,4 @@ +export type TTransactionalEmail = { + type: string; + sub: string; +}; diff --git a/src/util/agenda.ts b/src/util/agenda.ts index 95999109..668ee4bc 100644 --- a/src/util/agenda.ts +++ b/src/util/agenda.ts @@ -4,7 +4,9 @@ import { logger } from './logger'; import { updatePendingTransactions } from '@/jobs/updatePendingTransactions'; import { generateRewardQRCodesJob } from '@/jobs/rewardQRcodesJob'; import { generateMetadataRewardQRCodesJob } from '@/jobs/metadataRewardQRcodesJob'; - +import { reminderJobAccountCreatedNoTokens } from '@/jobs/reminders/01_accountCreatedNoTokens'; +import { reminderJobTokensCreatedNoPools } from '@/jobs/reminders/02_tokensCreatedNoPools'; +import { reminderJobPoolsCreatedNoWithdrawals } from '@/jobs/reminders/03_poolsCreatedNoWithdrawals'; const agenda = new Agenda({ name: 'jobs', maxConcurrency: 1, @@ -16,10 +18,19 @@ const EVENT_UPDATE_PENDING_TRANSACTIONS = 'updatePendingTransactions'; const EVENT_SEND_DOWNLOAD_QR_EMAIL = 'sendDownloadQrEmail'; const EVENT_SEND_DOWNLOAD_METADATA_QR_EMAIL = 'sendDownloadMetadataQrEmail'; +const EVENT_SEND_REMINDER_ACCOUNT_CREATED_NO_TOKENS = 'reminder-job-account-created-no-tokens'; +const EVENT_SEND_REMINDER_TOKENS_CREATED_NO_POOLS = 'reminder-job-tokens-created-no-pools'; +const EVENT_SEND_REMINDER_POOLS_CREATED_NO_WITHDRAWALS = 'reminder-job-pools-created-no-withdrawals'; +const MAX_REMINDERS = 3; + agenda.define(EVENT_UPDATE_PENDING_TRANSACTIONS, updatePendingTransactions); agenda.define(EVENT_SEND_DOWNLOAD_QR_EMAIL, generateRewardQRCodesJob); agenda.define(EVENT_SEND_DOWNLOAD_METADATA_QR_EMAIL, generateMetadataRewardQRCodesJob); +agenda.define(EVENT_SEND_REMINDER_ACCOUNT_CREATED_NO_TOKENS, reminderJobAccountCreatedNoTokens); +agenda.define(EVENT_SEND_REMINDER_TOKENS_CREATED_NO_POOLS, reminderJobTokensCreatedNoPools); +agenda.define(EVENT_SEND_REMINDER_POOLS_CREATED_NO_WITHDRAWALS, reminderJobPoolsCreatedNoWithdrawals); + db.connection.once('open', async () => { agenda.mongo(db.connection.getClient().db(), 'jobs'); @@ -28,6 +39,9 @@ db.connection.once('open', async () => { agenda.every('30 seconds', EVENT_UPDATE_PENDING_TRANSACTIONS); agenda.every('5 seconds', EVENT_SEND_DOWNLOAD_QR_EMAIL); agenda.every('5 seconds', EVENT_SEND_DOWNLOAD_METADATA_QR_EMAIL); + agenda.every('0 9 * * *', EVENT_SEND_REMINDER_ACCOUNT_CREATED_NO_TOKENS); // EVERY DAY AT 9 AM + agenda.every('5 9 * * *', EVENT_SEND_REMINDER_TOKENS_CREATED_NO_POOLS); // EVERY DAY AT 9.05 AM + agenda.every('10 9 * * *', EVENT_SEND_REMINDER_POOLS_CREATED_NO_WITHDRAWALS); // EVERY DAY AT 9.10 AM logger.info('AgendaJS successfully started job processor'); }); @@ -37,4 +51,8 @@ export { EVENT_UPDATE_PENDING_TRANSACTIONS, EVENT_SEND_DOWNLOAD_QR_EMAIL, EVENT_SEND_DOWNLOAD_METADATA_QR_EMAIL, + EVENT_SEND_REMINDER_ACCOUNT_CREATED_NO_TOKENS, + EVENT_SEND_REMINDER_TOKENS_CREATED_NO_POOLS, + EVENT_SEND_REMINDER_POOLS_CREATED_NO_WITHDRAWALS, + MAX_REMINDERS, };