mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-24 03:14:03 +03:00
eeb7546abb
refs https://www.notion.so/ghost/Marketing-Milestone-email-campaigns-1d2c9dee3cfa4029863edb16092ad5c4?pvs=4 - When milestones will be activated we would send out emails to users that are way above the achieved milestone, as we didn't record milestones before - The plan is to implement a 0 milestone and don't send an email for achieving those and also add all achieved milestones in the first run until a first milestone is stored in the DB, then increment from there. - This change takes care of two cases: 1. Milestones gets enabled and runs initially. We don't want to send emails unless there's already at least one milestone achieved. For that we add a 0 milestone helper and add a `initial` reason to the meta object for the milestone event, so we can choose not to ping Slack and also disable email sending for all milestones achieved in this initial run. 2. All achieved milestones will be stored in the DB, even when that means we skip some. This introduces the `skipped` reason which also doesn't send emails for the skipped milestones, but will do for correctly achieved milestones (always the highest one). - Added handling for slack notifications to not attempt sending when reason is `skipped` or `initial`
328 lines
13 KiB
JavaScript
328 lines
13 KiB
JavaScript
const Milestone = require('./Milestone');
|
|
|
|
/**
|
|
* @typedef {object} IMilestoneRepository
|
|
* @prop {(milestone: Milestone) => Promise<void>} save
|
|
* @prop {(arr: number, [currency]: string|null) => Promise<Milestone>} getByARR
|
|
* @prop {(count: number) => Promise<Milestone>} getByCount
|
|
* @prop {(type: 'arr'|'members', [currency]: string|null) => Promise<Milestone>} getLatestByType
|
|
* @prop {() => Promise<Milestone>} getLastEmailSent
|
|
* @prop {(type: 'arr'|'members', [currency]: string|null) => Promise<Milestone[]>} getAllByType
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} IQueries
|
|
* @prop {() => Promise<number>} getMembersCount
|
|
* @prop {() => Promise<object>} getARR
|
|
* @prop {() => Promise<boolean>} hasImportedMembersInPeriod
|
|
* @prop {() => Promise<string>} getDefaultCurrency
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} milestonesConfig
|
|
* @prop {Array<object>} milestonesConfig.arr
|
|
* @prop {string} milestonesConfig.arr.currency
|
|
* @prop {number[]} milestonesConfig.arr.values
|
|
* @prop {number[]} milestonesConfig.members
|
|
* @prop {number} milestonesConfig.minDaysSinceLastEmail
|
|
*/
|
|
|
|
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<Milestone>}
|
|
*/
|
|
async #getLatestArrMilestone(currency = 'usd') {
|
|
return this.#repository.getLatestByType('arr', currency);
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<Milestone>}
|
|
*/
|
|
async #getLatestMembersCountMilestone() {
|
|
return this.#repository.getLatestByType('members', null);
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<string>}
|
|
*/
|
|
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<boolean>}
|
|
*/
|
|
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<Milestone>}
|
|
*/
|
|
async #createMilestone(milestone) {
|
|
const newMilestone = await Milestone.create(milestone);
|
|
|
|
await this.#repository.save(newMilestone);
|
|
return newMilestone;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {number[]} goalValues
|
|
* @param {number} current
|
|
*
|
|
* @returns {number[]}
|
|
*/
|
|
#getMatchedMilestones(goalValues, current) {
|
|
// return all achieved milestones and sort by value ascending
|
|
return goalValues.filter(value => current >= value)
|
|
.sort((a, b) => a - b);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @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<Milestone>}
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* @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<Milestone>}
|
|
*/
|
|
async #saveMileStoneWithoutEmail(milestone) {
|
|
return await this.#createMilestone(milestone);
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<{shouldSendEmail: boolean, reason: string}>}
|
|
*/
|
|
async #shouldSendEmail() {
|
|
let emailTooSoon = false;
|
|
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) {
|
|
const differenceInTime = new Date().getTime() - new Date(lastMilestoneSent.emailSentAt).getTime();
|
|
const differenceInDays = differenceInTime / (1000 * 3600 * 24);
|
|
|
|
emailTooSoon = differenceInDays <= this.#milestonesConfig.minDaysSinceLastEmail;
|
|
}
|
|
|
|
const hasMembersImported = await this.#queries.hasImportedMembersInPeriod();
|
|
const shouldSendEmail = !emailTooSoon && !hasMembersImported;
|
|
|
|
if (!shouldSendEmail) {
|
|
reason = hasMembersImported ? 'import' : 'email';
|
|
}
|
|
|
|
return {shouldSendEmail, reason};
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<Milestone>}
|
|
*/
|
|
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) {
|
|
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 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});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return await this.#getLatestArrMilestone(defaultCurrency);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise<Milestone>}
|
|
*/
|
|
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 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});
|
|
}
|
|
}
|
|
}
|
|
return await this.#getLatestMembersCountMilestone();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {'arr'|'members'} type
|
|
*
|
|
* @returns {Promise<Milestone>}
|
|
*/
|
|
async checkMilestones(type) {
|
|
if (type === 'arr') {
|
|
return await this.#runARRQueries();
|
|
}
|
|
|
|
return await this.#runMemberQueries();
|
|
}
|
|
};
|