2023-02-07 13:47:35 +03:00
|
|
|
const Milestone = require('./Milestone');
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef {object} IMilestoneRepository
|
|
|
|
* @prop {(milestone: Milestone) => Promise<void>} save
|
2023-02-13 17:29:01 +03:00
|
|
|
* @prop {(arr: number, [currency]: string|null) => Promise<Milestone>} getByARR
|
2023-02-07 13:47:35 +03:00
|
|
|
* @prop {(count: number) => Promise<Milestone>} getByCount
|
2023-02-13 17:29:01 +03:00
|
|
|
* @prop {(type: 'arr'|'members', [currency]: string|null) => Promise<Milestone>} getLatestByType
|
2023-02-07 13:47:35 +03:00
|
|
|
* @prop {() => Promise<Milestone>} getLastEmailSent
|
2023-03-13 20:01:11 +03:00
|
|
|
* @prop {(type: 'arr'|'members', [currency]: string|null) => Promise<Milestone[]>} getAllByType
|
2023-02-07 13:47:35 +03:00
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
2023-02-13 17:29:01 +03:00
|
|
|
* @typedef {object} IQueries
|
|
|
|
* @prop {() => Promise<number>} getMembersCount
|
|
|
|
* @prop {() => Promise<object>} getARR
|
|
|
|
* @prop {() => Promise<boolean>} hasImportedMembersInPeriod
|
|
|
|
* @prop {() => Promise<string>} getDefaultCurrency
|
2023-02-07 13:47:35 +03:00
|
|
|
*/
|
|
|
|
|
2023-02-13 17:29:01 +03:00
|
|
|
/**
|
|
|
|
* @typedef {object} milestonesConfig
|
|
|
|
* @prop {Array<object>} milestonesConfig.arr
|
|
|
|
* @prop {string} milestonesConfig.arr.currency
|
|
|
|
* @prop {number[]} milestonesConfig.arr.values
|
|
|
|
* @prop {number[]} milestonesConfig.members
|
2023-02-23 17:01:13 +03:00
|
|
|
* @prop {number} milestonesConfig.minDaysSinceLastEmail
|
2023-02-07 13:47:35 +03:00
|
|
|
*/
|
|
|
|
|
2023-02-15 10:23:31 +03:00
|
|
|
module.exports = class MilestonesService {
|
2023-02-07 13:47:35 +03:00
|
|
|
/** @type {IMilestoneRepository} */
|
|
|
|
#repository;
|
|
|
|
|
2023-02-13 17:29:01 +03:00
|
|
|
/**
|
|
|
|
* @type {milestonesConfig} */
|
|
|
|
#milestonesConfig;
|
2023-02-07 13:47:35 +03:00
|
|
|
|
|
|
|
/** @type {IQueries} */
|
|
|
|
#queries;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {object} deps
|
2023-02-13 17:29:01 +03:00
|
|
|
* @param {IMilestoneRepository} deps.repository
|
|
|
|
* @param {milestonesConfig} deps.milestonesConfig
|
2023-02-07 13:47:35 +03:00
|
|
|
* @param {IQueries} deps.queries
|
|
|
|
*/
|
|
|
|
constructor(deps) {
|
2023-02-13 17:29:01 +03:00
|
|
|
this.#milestonesConfig = deps.milestonesConfig;
|
2023-02-07 13:47:35 +03:00
|
|
|
this.#queries = deps.queries;
|
|
|
|
this.#repository = deps.repository;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2023-02-13 17:29:01 +03:00
|
|
|
* @param {string} [currency]
|
2023-02-07 13:47:35 +03:00
|
|
|
*
|
|
|
|
* @returns {Promise<Milestone>}
|
|
|
|
*/
|
|
|
|
async #getLatestArrMilestone(currency = 'usd') {
|
|
|
|
return this.#repository.getLatestByType('arr', currency);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @returns {Promise<Milestone>}
|
|
|
|
*/
|
|
|
|
async #getLatestMembersCountMilestone() {
|
2023-02-13 17:29:01 +03:00
|
|
|
return this.#repository.getLatestByType('members', null);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @returns {Promise<string>}
|
|
|
|
*/
|
|
|
|
async #getDefaultCurrency() {
|
|
|
|
return await this.#queries.getDefaultCurrency();
|
2023-02-07 13:47:35 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {object} milestone
|
|
|
|
* @param {'arr'|'members'} milestone.type
|
|
|
|
* @param {number} milestone.value
|
|
|
|
* @param {string} milestone.currency
|
|
|
|
*
|
|
|
|
* @returns {Promise<boolean>}
|
|
|
|
*/
|
|
|
|
async #checkMilestoneExists(milestone) {
|
|
|
|
let foundExistingMilestone = false;
|
|
|
|
let existingMilestone = null;
|
|
|
|
|
|
|
|
if (milestone.type === 'arr') {
|
2023-02-13 17:29:01 +03:00
|
|
|
existingMilestone = await this.#repository.getByARR(milestone.value, milestone.currency) || false;
|
2023-02-07 13:47:35 +03:00
|
|
|
} 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<Milestone>}
|
|
|
|
*/
|
|
|
|
async #createMilestone(milestone) {
|
|
|
|
const newMilestone = await Milestone.create(milestone);
|
|
|
|
|
|
|
|
await this.#repository.save(newMilestone);
|
|
|
|
return newMilestone;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
2023-02-13 17:29:01 +03:00
|
|
|
* @param {number[]} goalValues
|
2023-02-07 13:47:35 +03:00
|
|
|
* @param {number} current
|
|
|
|
*
|
2023-03-13 20:01:11 +03:00
|
|
|
* @returns {number[]}
|
2023-02-07 13:47:35 +03:00
|
|
|
*/
|
2023-03-13 20:01:11 +03:00
|
|
|
#getMatchedMilestones(goalValues, current) {
|
|
|
|
// return all achieved milestones and sort by value ascending
|
2023-02-07 13:47:35 +03:00
|
|
|
return goalValues.filter(value => current >= value)
|
2023-03-13 20:01:11 +03:00
|
|
|
.sort((a, b) => a - b);
|
2023-02-07 13:47:35 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
2023-02-13 17:29:01 +03:00
|
|
|
* @param {object} milestone
|
2023-02-07 13:47:35 +03:00
|
|
|
* @param {number} milestone.value
|
|
|
|
* @param {'arr'|'members'} milestone.type
|
2023-02-17 13:59:18 +03:00
|
|
|
* @param {object} milestone.meta
|
2023-02-13 17:29:01 +03:00
|
|
|
* @param {string|null} [milestone.currency]
|
|
|
|
* @param {Date|null} [milestone.emailSentAt]
|
2023-02-07 13:47:35 +03:00
|
|
|
*
|
|
|
|
* @returns {Promise<Milestone>}
|
|
|
|
*/
|
|
|
|
async #saveMileStoneAndSendEmail(milestone) {
|
2023-03-13 20:01:11 +03:00
|
|
|
const {shouldSendEmail, reason} = await this.#shouldSendEmail();
|
2023-02-07 13:47:35 +03:00
|
|
|
|
|
|
|
if (shouldSendEmail) {
|
|
|
|
milestone.emailSentAt = new Date();
|
|
|
|
}
|
|
|
|
|
2023-02-17 13:59:18 +03:00
|
|
|
if (reason) {
|
|
|
|
milestone.meta.reason = reason;
|
|
|
|
}
|
|
|
|
|
2023-02-07 13:47:35 +03:00
|
|
|
return await this.#createMilestone(milestone);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2023-02-23 17:01:13 +03:00
|
|
|
* @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]
|
2023-02-07 13:47:35 +03:00
|
|
|
*
|
2023-03-13 20:01:11 +03:00
|
|
|
* @returns {Promise<Milestone>}
|
|
|
|
*/
|
|
|
|
async #saveMileStoneWithoutEmail(milestone) {
|
|
|
|
return await this.#createMilestone(milestone);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2023-02-17 13:59:18 +03:00
|
|
|
* @returns {Promise<{shouldSendEmail: boolean, reason: string}>}
|
2023-02-07 13:47:35 +03:00
|
|
|
*/
|
2023-03-13 20:01:11 +03:00
|
|
|
async #shouldSendEmail() {
|
2023-02-23 17:01:13 +03:00
|
|
|
let emailTooSoon = false;
|
2023-02-17 13:59:18 +03:00
|
|
|
let reason = null;
|
2023-03-13 20:01:11 +03:00
|
|
|
// Two cases in which we don't want to send an email
|
2023-02-07 13:47:35 +03:00
|
|
|
// 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();
|
|
|
|
|
2023-02-23 17:01:13 +03:00
|
|
|
if (lastMilestoneSent) {
|
2023-02-07 13:47:35 +03:00
|
|
|
const differenceInTime = new Date().getTime() - new Date(lastMilestoneSent.emailSentAt).getTime();
|
|
|
|
const differenceInDays = differenceInTime / (1000 * 3600 * 24);
|
|
|
|
|
2023-02-23 17:01:13 +03:00
|
|
|
emailTooSoon = differenceInDays <= this.#milestonesConfig.minDaysSinceLastEmail;
|
|
|
|
}
|
|
|
|
|
2023-02-07 13:47:35 +03:00
|
|
|
const hasMembersImported = await this.#queries.hasImportedMembersInPeriod();
|
2023-03-13 20:01:11 +03:00
|
|
|
const shouldSendEmail = !emailTooSoon && !hasMembersImported;
|
2023-02-17 13:59:18 +03:00
|
|
|
|
|
|
|
if (!shouldSendEmail) {
|
2023-03-13 20:01:11 +03:00
|
|
|
reason = hasMembersImported ? 'import' : 'email';
|
2023-02-17 13:59:18 +03:00
|
|
|
}
|
2023-02-07 13:47:35 +03:00
|
|
|
|
2023-02-17 13:59:18 +03:00
|
|
|
return {shouldSendEmail, reason};
|
2023-02-07 13:47:35 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @returns {Promise<Milestone>}
|
|
|
|
*/
|
|
|
|
async #runARRQueries() {
|
|
|
|
// Fetch the current data from queries
|
|
|
|
const currentARR = await this.#queries.getARR();
|
2023-02-13 17:29:01 +03:00
|
|
|
const defaultCurrency = await this.#getDefaultCurrency();
|
2023-02-07 13:47:35 +03:00
|
|
|
|
2023-02-13 17:29:01 +03:00
|
|
|
// Check the definitions in the milestonesConfig
|
|
|
|
const arrMilestoneSettings = this.#milestonesConfig.arr;
|
|
|
|
const supportedCurrencies = arrMilestoneSettings.map(setting => setting.currency);
|
2023-02-07 13:47:35 +03:00
|
|
|
|
|
|
|
// First check the currency matches
|
|
|
|
if (currentARR.length) {
|
2023-02-13 17:29:01 +03:00
|
|
|
const currentARRForCurrency = currentARR.filter(arr => arr.currency === defaultCurrency && supportedCurrencies.includes(defaultCurrency))[0];
|
|
|
|
const milestonesForCurrency = arrMilestoneSettings.filter(milestoneSetting => milestoneSetting.currency === defaultCurrency)[0];
|
2023-02-07 13:47:35 +03:00
|
|
|
|
|
|
|
if (milestonesForCurrency && currentARRForCurrency) {
|
2023-03-13 20:01:11 +03:00
|
|
|
// get all milestones that have been achieved
|
|
|
|
const achievedMilestones = this.#getMatchedMilestones(milestonesForCurrency.values, currentARRForCurrency.arr);
|
|
|
|
|
|
|
|
// check for previously achieved milestones. We do not send an email when no
|
|
|
|
// previous milestones exist
|
|
|
|
const allMilestonesForCurrency = await this.#repository.getAllByType('arr', defaultCurrency);
|
|
|
|
const isInitialRun = !allMilestonesForCurrency || allMilestonesForCurrency?.length === 0;
|
|
|
|
const highestAchievedMilestone = Math.max(...achievedMilestones);
|
|
|
|
|
|
|
|
if (achievedMilestones && achievedMilestones.length) {
|
|
|
|
for await (const milestone of achievedMilestones) {
|
|
|
|
// 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) {
|
|
|
|
if (isInitialRun) {
|
|
|
|
// No milestones have been saved yet, don't send an email
|
|
|
|
// for the first initial run
|
|
|
|
const meta = {
|
|
|
|
currentValue: currentARRForCurrency.arr,
|
|
|
|
reason: 'initial'
|
|
|
|
};
|
|
|
|
await this.#saveMileStoneWithoutEmail({value: milestone, type: 'arr', currency: defaultCurrency, meta});
|
|
|
|
} else if ((latestMilestone && milestone <= latestMilestone?.value) || milestone < highestAchievedMilestone) {
|
|
|
|
// The highest achieved milestone is higher than the current on hand.
|
|
|
|
// Do not send an email, but save it.
|
|
|
|
const meta = {
|
|
|
|
currentValue: currentARRForCurrency.arr,
|
|
|
|
reason: 'skipped'
|
|
|
|
};
|
|
|
|
await this.#saveMileStoneWithoutEmail({value: milestone, type: 'arr', currency: defaultCurrency, meta});
|
|
|
|
} else if ((!latestMilestone || milestone > latestMilestone.value)) {
|
|
|
|
const meta = {
|
|
|
|
currentValue: currentARRForCurrency.arr
|
|
|
|
};
|
|
|
|
await this.#saveMileStoneAndSendEmail({value: milestone, type: 'arr', currency: defaultCurrency, meta});
|
|
|
|
}
|
|
|
|
}
|
2023-02-13 17:29:01 +03:00
|
|
|
}
|
2023-02-07 13:47:35 +03:00
|
|
|
}
|
2023-03-13 20:01:11 +03:00
|
|
|
return await this.#getLatestArrMilestone(defaultCurrency);
|
2023-02-07 13:47:35 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @returns {Promise<Milestone>}
|
|
|
|
*/
|
|
|
|
async #runMemberQueries() {
|
|
|
|
// Fetch the current data
|
|
|
|
const membersCount = await this.#queries.getMembersCount();
|
|
|
|
|
2023-02-13 17:29:01 +03:00
|
|
|
// Check the definitions in the milestonesConfig
|
|
|
|
const membersMilestones = this.#milestonesConfig.members;
|
2023-02-07 13:47:35 +03:00
|
|
|
|
|
|
|
// get the closest milestone we're over now
|
2023-03-13 20:01:11 +03:00
|
|
|
let achievedMilestones = this.#getMatchedMilestones(membersMilestones, membersCount);
|
|
|
|
|
|
|
|
// check for previously achieved milestones. We do not send an email when no
|
|
|
|
// previous milestones exist
|
|
|
|
const allMembersMilestones = await this.#repository.getAllByType('members', null);
|
|
|
|
const isInitialRun = !allMembersMilestones || allMembersMilestones?.length === 0;
|
|
|
|
const highestAchievedMilestone = Math.max(...achievedMilestones);
|
|
|
|
|
|
|
|
if (achievedMilestones && achievedMilestones.length) {
|
|
|
|
for await (const milestone of achievedMilestones) {
|
|
|
|
// 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) {
|
|
|
|
if (isInitialRun) {
|
|
|
|
// No milestones have been saved yet, don't send an email
|
|
|
|
// for the first initial run
|
|
|
|
const meta = {
|
|
|
|
currentValue: membersCount,
|
|
|
|
reason: 'initial'
|
|
|
|
};
|
|
|
|
await this.#saveMileStoneWithoutEmail({value: milestone, type: 'members', meta});
|
|
|
|
} else if ((latestMembersMilestone && milestone <= latestMembersMilestone?.value) || milestone < highestAchievedMilestone) {
|
|
|
|
// The highest achieved milestone is higher than the current on hand.
|
|
|
|
// Do not send an email, but save it.
|
|
|
|
const meta = {
|
|
|
|
currentValue: membersCount,
|
|
|
|
reason: 'skipped'
|
|
|
|
};
|
|
|
|
await this.#saveMileStoneWithoutEmail({value: milestone, type: 'members', meta});
|
|
|
|
} else if ((!latestMembersMilestone || milestone > latestMembersMilestone.value)) {
|
|
|
|
const meta = {
|
|
|
|
currentValue: membersCount
|
|
|
|
};
|
|
|
|
await this.#saveMileStoneAndSendEmail({value: milestone, type: 'members', meta});
|
|
|
|
}
|
|
|
|
}
|
2023-02-13 17:29:01 +03:00
|
|
|
}
|
2023-03-13 20:01:11 +03:00
|
|
|
return await this.#getLatestMembersCountMilestone();
|
2023-02-07 13:47:35 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {'arr'|'members'} type
|
|
|
|
*
|
|
|
|
* @returns {Promise<Milestone>}
|
|
|
|
*/
|
|
|
|
async checkMilestones(type) {
|
|
|
|
if (type === 'arr') {
|
|
|
|
return await this.#runARRQueries();
|
|
|
|
}
|
|
|
|
|
|
|
|
return await this.#runMemberQueries();
|
|
|
|
}
|
|
|
|
};
|