const Milestone = require('./Milestone'); /** * @typedef {object} IMilestoneRepository * @prop {(milestone: Milestone) => Promise} save * @prop {(arr: number, [currency]: string|null) => Promise} getByARR * @prop {(count: number) => Promise} getByCount * @prop {(type: 'arr'|'members', [currency]: string|null) => Promise} getLatestByType * @prop {() => Promise} getLastEmailSent */ /** * @typedef {object} IQueries * @prop {() => Promise} getMembersCount * @prop {() => Promise} getARR * @prop {() => Promise} hasImportedMembersInPeriod * @prop {() => Promise} getDefaultCurrency */ /** * @typedef {object} milestonesConfig * @prop {Array} milestonesConfig.arr * @prop {string} milestonesConfig.arr.currency * @prop {number[]} milestonesConfig.arr.values * @prop {number[]} milestonesConfig.members */ module.exports = class MilestonesService { /** @type {IMilestoneRepository} */ #repository; /** * @type {milestonesConfig} */ #milestonesConfig; /** @type {IQueries} */ #queries; /** * @param {object} deps * @param {IMilestoneRepository} deps.repository * @param {milestonesConfig} deps.milestonesConfig * @param {IQueries} deps.queries */ constructor(deps) { this.#milestonesConfig = deps.milestonesConfig; this.#queries = deps.queries; this.#repository = deps.repository; } /** * @param {string} [currency] * * @returns {Promise} */ async #getLatestArrMilestone(currency = 'usd') { return this.#repository.getLatestByType('arr', currency); } /** * @returns {Promise} */ async #getLatestMembersCountMilestone() { return this.#repository.getLatestByType('members', null); } /** * @returns {Promise} */ async #getDefaultCurrency() { return await this.#queries.getDefaultCurrency(); } /** * @param {object} milestone * @param {'arr'|'members'} milestone.type * @param {number} milestone.value * @param {string} milestone.currency * * @returns {Promise} */ async #checkMilestoneExists(milestone) { let foundExistingMilestone = false; let existingMilestone = null; if (milestone.type === 'arr') { existingMilestone = await this.#repository.getByARR(milestone.value, milestone.currency) || false; } else if (milestone.type === 'members') { existingMilestone = await this.#repository.getByCount(milestone.value) || false; } foundExistingMilestone = existingMilestone ? true : false; return foundExistingMilestone; } /** * @param {object} milestone * @param {'arr'|'members'} milestone.type * @param {number} milestone.value * * @returns {Promise} */ async #createMilestone(milestone) { const newMilestone = await Milestone.create(milestone); await this.#repository.save(newMilestone); return newMilestone; } /** * * @param {number[]} goalValues * @param {number} current * * @returns {number} */ #getMatchedMilestone(goalValues, current) { // return highest suitable milestone return goalValues.filter(value => current >= value) .sort((a, b) => b - a)[0]; } /** * * @param {object} milestone * @param {number} milestone.value * @param {'arr'|'members'} milestone.type * @param {object} milestone.meta * @param {string|null} [milestone.currency] * @param {Date|null} [milestone.emailSentAt] * * @returns {Promise} */ async #saveMileStoneAndSendEmail(milestone) { const {shouldSendEmail, reason} = await this.#shouldSendEmail(); if (shouldSendEmail) { milestone.emailSentAt = new Date(); } if (reason) { milestone.meta.reason = reason; } return await this.#createMilestone(milestone); } /** * * @returns {Promise<{shouldSendEmail: boolean, reason: string}>} */ async #shouldSendEmail() { let canHaveEmail; let reason = null; // Two cases in which we don't want to send an email // 1. There has been an import of members within the last week // 2. The last email has been sent less than two weeks ago const lastMilestoneSent = await this.#repository.getLastEmailSent(); if (!lastMilestoneSent) { canHaveEmail = true; } else { const differenceInTime = new Date().getTime() - new Date(lastMilestoneSent.emailSentAt).getTime(); const differenceInDays = differenceInTime / (1000 * 3600 * 24); canHaveEmail = differenceInDays >= 14; } const hasMembersImported = await this.#queries.hasImportedMembersInPeriod(); const shouldSendEmail = canHaveEmail && !hasMembersImported; if (!shouldSendEmail) { reason = hasMembersImported ? 'import' : 'email'; } return {shouldSendEmail, reason}; } /** * @returns {Promise} */ async #runARRQueries() { // Fetch the current data from queries const currentARR = await this.#queries.getARR(); const defaultCurrency = await this.#getDefaultCurrency(); // Check the definitions in the milestonesConfig const arrMilestoneSettings = this.#milestonesConfig.arr; const supportedCurrencies = arrMilestoneSettings.map(setting => setting.currency); // First check the currency matches if (currentARR.length) { let milestone; const currentARRForCurrency = currentARR.filter(arr => arr.currency === defaultCurrency && supportedCurrencies.includes(defaultCurrency))[0]; const milestonesForCurrency = arrMilestoneSettings.filter(milestoneSetting => milestoneSetting.currency === defaultCurrency)[0]; if (milestonesForCurrency && currentARRForCurrency) { // get the closest milestone we're over now milestone = this.#getMatchedMilestone(milestonesForCurrency.values, currentARRForCurrency.arr); if (milestone && milestone > 0) { // Fetch the latest milestone for this currency const latestMilestone = await this.#getLatestArrMilestone(defaultCurrency); // Ensure the milestone doesn't already exist const milestoneExists = await this.#checkMilestoneExists({value: milestone, type: 'arr', currency: defaultCurrency}); if (!milestoneExists && (!latestMilestone || milestone > latestMilestone.value)) { const meta = { currentARR: currentARRForCurrency.arr }; return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'arr', currency: defaultCurrency, meta}); } } } } } /** * @returns {Promise} */ async #runMemberQueries() { // Fetch the current data const membersCount = await this.#queries.getMembersCount(); // Check the definitions in the milestonesConfig const membersMilestones = this.#milestonesConfig.members; // get the closest milestone we're over now let milestone = this.#getMatchedMilestone(membersMilestones, membersCount); if (milestone && milestone > 0) { // Fetch the latest achieved Members milestones const latestMembersMilestone = await this.#getLatestMembersCountMilestone(); // Ensure the milestone doesn't already exist const milestoneExists = await this.#checkMilestoneExists({value: milestone, type: 'members', currency: null}); if (!milestoneExists && (!latestMembersMilestone || milestone > latestMembersMilestone.value)) { const meta = { currentMembers: membersCount }; return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'members', meta}); } } } /** * @param {'arr'|'members'} type * * @returns {Promise} */ async checkMilestones(type) { if (type === 'arr') { return await this.#runARRQueries(); } return await this.#runMemberQueries(); } };