mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-01 02:41:39 -05:00
Added handling for initial and skipped Milestones (#16405)
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`
This commit is contained in:
parent
4c946f5145
commit
eeb7546abb
11 changed files with 364 additions and 189 deletions
|
@ -68,9 +68,9 @@ module.exports = class BookshelfMilestoneRepository {
|
|||
* @param {'arr'|'members'} type
|
||||
* @param {string} [currency]
|
||||
*
|
||||
* @returns {Promise<import('@tryghost/milestones/lib/Milestone')|null>}
|
||||
* @returns {Promise<import('@tryghost/milestones/lib/Milestone')[]>}
|
||||
*/
|
||||
async getLatestByType(type, currency = 'usd') {
|
||||
async getAllByType(type, currency = 'usd') {
|
||||
let milestone = null;
|
||||
|
||||
if (type === 'arr') {
|
||||
|
@ -80,12 +80,24 @@ module.exports = class BookshelfMilestoneRepository {
|
|||
}
|
||||
|
||||
if (!milestone || !milestone?.models?.length) {
|
||||
return null;
|
||||
} else {
|
||||
milestone = milestone.models?.[0];
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.#modelToMilestone(milestone);
|
||||
const milestones = await Promise.all(milestone.models.map(model => this.#modelToMilestone(model)));
|
||||
|
||||
// Enforce ordering by value as Bookshelf seems to ignore it
|
||||
return milestones.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'arr'|'members'} type
|
||||
* @param {string} [currency]
|
||||
*
|
||||
* @returns {Promise<import('@tryghost/milestones/lib/Milestone')|null>}
|
||||
*/
|
||||
async getLatestByType(type, currency = 'usd') {
|
||||
const allMilestonesForType = await this.getAllByType(type, currency);
|
||||
return allMilestonesForType?.[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -210,12 +210,11 @@
|
|||
"arr": [
|
||||
{
|
||||
"currency": "usd",
|
||||
"values": [100, 1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
"values": [0, 100, 1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
}
|
||||
],
|
||||
"members": [100, 1000, 10000, 25000, 50000, 100000, 250000, 500000, 1000000],
|
||||
"members": [0, 100, 1000, 10000, 25000, 50000, 100000, 250000, 500000, 1000000],
|
||||
"minDaysSinceImported": 7,
|
||||
"minDaysSinceLastEmail": 14,
|
||||
"maxPercentageFromMilestone": 0.1
|
||||
"minDaysSinceLastEmail": 14
|
||||
}
|
||||
}
|
||||
|
|
|
@ -144,11 +144,10 @@ describe('Milestones Service', function () {
|
|||
let loggingStub;
|
||||
|
||||
const milestonesConfig = {
|
||||
arr: [{currency: 'usd', values: [100, 150]}],
|
||||
members: [10, 20, 30],
|
||||
arr: [{currency: 'usd', values: [0, 100, 150]}],
|
||||
members: [0, 10, 20, 30],
|
||||
minDaysSinceImported: 7,
|
||||
minDaysSinceLastEmail: 14,
|
||||
maxPercentageFromMilestone: 0.35
|
||||
minDaysSinceLastEmail: 14
|
||||
};
|
||||
|
||||
before(async function () {
|
||||
|
@ -192,7 +191,7 @@ describe('Milestones Service', function () {
|
|||
const firstResultPromise = milestonesService.initAndRun(fifteenDays);
|
||||
await clock.tickAsync(fifteenDays);
|
||||
const firstRun = await firstResultPromise;
|
||||
assert(firstRun.members === undefined);
|
||||
assert(firstRun.members.value === 0);
|
||||
assert(firstRun.arr === undefined);
|
||||
|
||||
await createFreeMembers(7);
|
||||
|
@ -201,17 +200,19 @@ describe('Milestones Service', function () {
|
|||
const secondResultPromise = milestonesService.initAndRun(fifteenDays);
|
||||
await clock.tickAsync(fifteenDays);
|
||||
const secondRun = await secondResultPromise;
|
||||
assert(secondRun.members === undefined);
|
||||
assert(secondRun.arr === undefined);
|
||||
|
||||
assert(secondRun.members.value === 0);
|
||||
assert(secondRun.arr.value === 0);
|
||||
|
||||
// Reached the first milestone for members
|
||||
await createFreeMembers(1);
|
||||
const thirdResultPromise = milestonesService.initAndRun(fifteenDays);
|
||||
await clock.tickAsync(fifteenDays);
|
||||
const thirdRun = await thirdResultPromise;
|
||||
|
||||
assert(thirdRun.members.value === 10);
|
||||
assert(thirdRun.members.emailSentAt !== undefined);
|
||||
assert(thirdRun.arr === undefined);
|
||||
assert(thirdRun.arr.value === 0);
|
||||
|
||||
const memberMilestoneModel = await models.Milestone.findOne({value: 10, type: 'members'});
|
||||
|
||||
|
@ -230,7 +231,7 @@ describe('Milestones Service', function () {
|
|||
const fourthResultPromise = milestonesService.initAndRun(100);
|
||||
await clock.tickAsync(100);
|
||||
const fourthRun = await fourthResultPromise;
|
||||
assert(fourthRun.members === undefined);
|
||||
assert(fourthRun.members.value === 10);
|
||||
assert(fourthRun.arr.value === 100);
|
||||
assert(fourthRun.arr.emailSentAt !== undefined);
|
||||
|
||||
|
@ -280,7 +281,8 @@ describe('Milestones Service', function () {
|
|||
|
||||
assert(result.members.value === 30);
|
||||
assert(result.members.emailSentAt === null);
|
||||
assert(result.arr === undefined);
|
||||
|
||||
assert(result.arr.value === 100);
|
||||
|
||||
const memberMilestoneModel = await models.Milestone.findOne({value: 30, type: 'members'});
|
||||
|
||||
|
|
|
@ -52,21 +52,8 @@ module.exports = class InMemoryMilestoneRepository {
|
|||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async getLatestByType(type, currency = 'usd') {
|
||||
if (type === 'arr') {
|
||||
return this.#store
|
||||
.filter(item => item.type === type && item.currency === currency)
|
||||
// sort by created at desc
|
||||
.sort((a, b) => (b.createdAt.valueOf() - a.createdAt.valueOf()))
|
||||
// if we end up with more values created at the same time, pick the highest value
|
||||
.sort((a, b) => b.value - a.value)[0];
|
||||
} else {
|
||||
return this.#store
|
||||
.filter(item => item.type === type)
|
||||
// sort by created at desc
|
||||
.sort((a, b) => (b.createdAt.valueOf() - a.createdAt.valueOf()))
|
||||
// if we end up with more values created at the same time, pick the highest value
|
||||
.sort((a, b) => b.value - a.value)[0];
|
||||
}
|
||||
const allMilestonesForType = await this.getAllByType(type, currency);
|
||||
return allMilestonesForType?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -105,4 +92,28 @@ module.exports = class InMemoryMilestoneRepository {
|
|||
return item.value === value && item.type === 'members';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'arr'|'members'} type
|
||||
* @param {string} [currency]
|
||||
*
|
||||
* @returns {Promise<Milestone[]>}
|
||||
*/
|
||||
async getAllByType(type, currency = 'usd') {
|
||||
if (type === 'arr') {
|
||||
return this.#store
|
||||
.filter(item => item.type === type && item.currency === currency)
|
||||
// sort by created at desc
|
||||
.sort((a, b) => (b.createdAt.valueOf() - a.createdAt.valueOf()))
|
||||
// sort by highest value
|
||||
.sort((a, b) => b.value - a.value);
|
||||
} else {
|
||||
return this.#store
|
||||
.filter(item => item.type === type)
|
||||
// sort by created at desc
|
||||
.sort((a, b) => (b.createdAt.valueOf() - a.createdAt.valueOf()))
|
||||
// sort by highest value
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -145,7 +145,7 @@ module.exports = class Milestone {
|
|||
* @returns {number}
|
||||
*/
|
||||
function validateValue(value) {
|
||||
if (!value || typeof value !== 'number' || value === 0) {
|
||||
if (value === undefined || typeof value !== 'number') {
|
||||
throw new ValidationError({
|
||||
message: 'Invalid value'
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@ const Milestone = require('./Milestone');
|
|||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
|
@ -23,7 +24,6 @@ const Milestone = require('./Milestone');
|
|||
* @prop {string} milestonesConfig.arr.currency
|
||||
* @prop {number[]} milestonesConfig.arr.values
|
||||
* @prop {number[]} milestonesConfig.members
|
||||
* @prop {number} milestonesConfig.maxPercentageFromMilestone
|
||||
* @prop {number} milestonesConfig.minDaysSinceLastEmail
|
||||
*/
|
||||
|
||||
|
@ -115,12 +115,12 @@ module.exports = class MilestonesService {
|
|||
* @param {number[]} goalValues
|
||||
* @param {number} current
|
||||
*
|
||||
* @returns {number}
|
||||
* @returns {number[]}
|
||||
*/
|
||||
#getMatchedMilestone(goalValues, current) {
|
||||
// return highest suitable milestone
|
||||
#getMatchedMilestones(goalValues, current) {
|
||||
// return all achieved milestones and sort by value ascending
|
||||
return goalValues.filter(value => current >= value)
|
||||
.sort((a, b) => b - a)[0];
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -135,7 +135,7 @@ module.exports = class MilestonesService {
|
|||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async #saveMileStoneAndSendEmail(milestone) {
|
||||
const {shouldSendEmail, reason} = await this.#shouldSendEmail(milestone);
|
||||
const {shouldSendEmail, reason} = await this.#shouldSendEmail();
|
||||
|
||||
if (shouldSendEmail) {
|
||||
milestone.emailSentAt = new Date();
|
||||
|
@ -156,17 +156,21 @@ module.exports = class MilestonesService {
|
|||
* @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(milestone) {
|
||||
async #shouldSendEmail() {
|
||||
let emailTooSoon = false;
|
||||
let emailTooClose = false;
|
||||
let reason = null;
|
||||
// Three cases in which we don't want to send an email
|
||||
// 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
|
||||
// 3. The current members or ARR value is x% above the achieved milestone
|
||||
// as defined in default shared config for `maxPercentageFromMilestone`
|
||||
const lastMilestoneSent = await this.#repository.getLastEmailSent();
|
||||
|
||||
if (lastMilestoneSent) {
|
||||
|
@ -176,20 +180,11 @@ module.exports = class MilestonesService {
|
|||
emailTooSoon = differenceInDays <= this.#milestonesConfig.minDaysSinceLastEmail;
|
||||
}
|
||||
|
||||
if (milestone?.meta) {
|
||||
// Check how much the value currently differs from the milestone
|
||||
const difference = milestone?.meta?.currentValue - milestone.value;
|
||||
const differenceInPercentage = difference / milestone.value;
|
||||
|
||||
emailTooClose = differenceInPercentage >= this.#milestonesConfig.maxPercentageFromMilestone;
|
||||
}
|
||||
|
||||
const hasMembersImported = await this.#queries.hasImportedMembersInPeriod();
|
||||
const shouldSendEmail = !emailTooSoon && !hasMembersImported && !emailTooClose;
|
||||
const shouldSendEmail = !emailTooSoon && !hasMembersImported;
|
||||
|
||||
if (!shouldSendEmail) {
|
||||
reason = hasMembersImported ? 'import' :
|
||||
emailTooSoon ? 'email' : 'tooFar';
|
||||
reason = hasMembersImported ? 'import' : 'email';
|
||||
}
|
||||
|
||||
return {shouldSendEmail, reason};
|
||||
|
@ -209,29 +204,54 @@ module.exports = class MilestonesService {
|
|||
|
||||
// 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);
|
||||
// get all milestones that have been achieved
|
||||
const achievedMilestones = this.#getMatchedMilestones(milestonesForCurrency.values, currentARRForCurrency.arr);
|
||||
|
||||
if (milestone && milestone > 0) {
|
||||
// Fetch the latest milestone for this currency
|
||||
const latestMilestone = await this.#getLatestArrMilestone(defaultCurrency);
|
||||
// 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);
|
||||
|
||||
// Ensure the milestone doesn't already exist
|
||||
const milestoneExists = await this.#checkMilestoneExists({value: milestone, type: 'arr', currency: defaultCurrency});
|
||||
if (achievedMilestones && achievedMilestones.length) {
|
||||
for await (const milestone of achievedMilestones) {
|
||||
// Fetch the latest milestone for this currency
|
||||
const latestMilestone = await this.#getLatestArrMilestone(defaultCurrency);
|
||||
|
||||
if (!milestoneExists && (!latestMilestone || milestone > latestMilestone.value)) {
|
||||
const meta = {
|
||||
currentValue: currentARRForCurrency.arr
|
||||
};
|
||||
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'arr', currency: defaultCurrency, meta});
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -247,21 +267,48 @@ module.exports = class MilestonesService {
|
|||
const membersMilestones = this.#milestonesConfig.members;
|
||||
|
||||
// get the closest milestone we're over now
|
||||
let milestone = this.#getMatchedMilestone(membersMilestones, membersCount);
|
||||
let achievedMilestones = this.#getMatchedMilestones(membersMilestones, membersCount);
|
||||
|
||||
if (milestone && milestone > 0) {
|
||||
// Fetch the latest achieved Members milestones
|
||||
const latestMembersMilestone = await this.#getLatestMembersCountMilestone();
|
||||
// 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);
|
||||
|
||||
// Ensure the milestone doesn't already exist
|
||||
const milestoneExists = await this.#checkMilestoneExists({value: milestone, type: 'members', currency: null});
|
||||
if (achievedMilestones && achievedMilestones.length) {
|
||||
for await (const milestone of achievedMilestones) {
|
||||
// Fetch the latest achieved Members milestones
|
||||
const latestMembersMilestone = await this.#getLatestMembersCountMilestone();
|
||||
|
||||
if (!milestoneExists && (!latestMembersMilestone || milestone > latestMembersMilestone.value)) {
|
||||
const meta = {
|
||||
currentValue: membersCount
|
||||
};
|
||||
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'members', meta});
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -136,4 +136,18 @@ describe('InMemoryMilestoneRepository', function () {
|
|||
assert(membersCountForValue.value === 100);
|
||||
assert(membersCountForValue.name === 'members-100');
|
||||
});
|
||||
|
||||
it('Can return all achieved milestones by type', async function () {
|
||||
const allArrUSDMilestones = await repository.getAllByType('arr');
|
||||
|
||||
assert(allArrUSDMilestones.length === 2);
|
||||
|
||||
const allArrGBPMilestones = await repository.getAllByType('arr', 'gbp');
|
||||
|
||||
assert(allArrGBPMilestones.length === 2);
|
||||
|
||||
const allMembersMilestones = await repository.getAllByType('members');
|
||||
|
||||
assert(allMembersMilestones.length === 3);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -35,6 +35,7 @@ describe('Milestone', function () {
|
|||
const invalidInputs = [
|
||||
{id: 'Invalid ID provided for Milestone'},
|
||||
{id: 124},
|
||||
{value: undefined},
|
||||
{value: 'Invalid Value'},
|
||||
{createdAt: 'Invalid Date'},
|
||||
{emailSentAt: 'Invalid Date'}
|
||||
|
@ -66,6 +67,8 @@ describe('Milestone', function () {
|
|||
{id: new ObjectID()},
|
||||
{id: new ObjectID().toString()},
|
||||
{id: null},
|
||||
{value: 0},
|
||||
{value: 25000},
|
||||
{type: 'something'},
|
||||
{name: 'testing'},
|
||||
{name: 'members-10000000'},
|
||||
|
@ -95,12 +98,12 @@ describe('Milestone', function () {
|
|||
it('Will generate a valid name for ARR milestone', async function () {
|
||||
const milestone = await Milestone.create({
|
||||
...validInputARR,
|
||||
value: 500,
|
||||
value: 0,
|
||||
type: 'arr',
|
||||
currency: 'aud'
|
||||
});
|
||||
|
||||
assert(milestone.name === 'arr-500-aud');
|
||||
assert(milestone.name === 'arr-0-aud');
|
||||
});
|
||||
|
||||
it('Will generate a valid name for Members milestone', async function () {
|
||||
|
|
|
@ -23,29 +23,61 @@ describe('MilestonesService', function () {
|
|||
arr: [
|
||||
{
|
||||
currency: 'usd',
|
||||
values: [1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
values: [0, 1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
},
|
||||
{
|
||||
currency: 'gbp',
|
||||
values: [500, 1000, 5000, 100000, 250000, 500000, 1000000]
|
||||
values: [0, 500, 1000, 5000, 100000, 250000, 500000, 1000000]
|
||||
},
|
||||
{
|
||||
currency: 'idr',
|
||||
values: [1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
values: [0, 1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
},
|
||||
{
|
||||
currency: 'eur',
|
||||
values: [1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
values: [0, 1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
}
|
||||
],
|
||||
members: [100, 1000, 10000, 50000, 100000, 250000, 500000, 1000000],
|
||||
members: [0, 100, 1000, 10000, 50000, 100000, 250000, 500000, 1000000],
|
||||
minDaysSinceImported: 7,
|
||||
minDaysSinceLastEmail: 14,
|
||||
maxPercentageFromMilestone: 0.35
|
||||
minDaysSinceLastEmail: 14
|
||||
};
|
||||
|
||||
describe('ARR Milestones', function () {
|
||||
it('Adds first ARR milestone and sends email', async function () {
|
||||
it('Adds initial 0 ARR milestone without sending email', async function () {
|
||||
repository = new InMemoryMilestoneRepository({DomainEvents});
|
||||
|
||||
const milestoneEmailService = new MilestonesService({
|
||||
repository,
|
||||
milestonesConfig,
|
||||
queries: {
|
||||
async getARR() {
|
||||
return [{currency: 'usd', arr: 43}];
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
},
|
||||
async getDefaultCurrency() {
|
||||
return 'usd';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
assert(arrResult.type === 'arr');
|
||||
assert(arrResult.currency === 'usd');
|
||||
assert(arrResult.value === 0);
|
||||
assert(arrResult.emailSentAt === null);
|
||||
assert(arrResult.name === 'arr-0-usd');
|
||||
|
||||
const domainEventSpyResult = domainEventSpy.getCall(0).args[0];
|
||||
assert(domainEventSpy.calledOnce === true);
|
||||
assert(domainEventSpyResult.data.milestone);
|
||||
assert(domainEventSpyResult.data.meta.currentValue === 43);
|
||||
assert(domainEventSpyResult.data.meta.reason === 'initial');
|
||||
});
|
||||
|
||||
it('Adds first ARR milestones but does not send email if no previous milestones', async function () {
|
||||
repository = new InMemoryMilestoneRepository({DomainEvents});
|
||||
|
||||
const milestoneEmailService = new MilestonesService({
|
||||
|
@ -68,13 +100,18 @@ describe('MilestonesService', function () {
|
|||
assert(arrResult.type === 'arr');
|
||||
assert(arrResult.currency === 'usd');
|
||||
assert(arrResult.value === 1000);
|
||||
assert(arrResult.emailSentAt !== null);
|
||||
assert(arrResult.emailSentAt === null);
|
||||
assert(arrResult.name === 'arr-1000-usd');
|
||||
|
||||
const domainEventSpyResult = domainEventSpy.getCall(0).args[0];
|
||||
assert(domainEventSpy.calledOnce === true);
|
||||
assert(domainEventSpyResult.data.milestone);
|
||||
assert(domainEventSpyResult.data.meta.currentValue === 1298);
|
||||
assert(domainEventSpy.calledTwice === true);
|
||||
const firstDomainEventSpyCall = domainEventSpy.getCall(0).args[0];
|
||||
const secondDomainEventSpyCall = domainEventSpy.getCall(1).args[0];
|
||||
assert(firstDomainEventSpyCall.data.milestone);
|
||||
assert(firstDomainEventSpyCall.data.meta.currentValue === 1298);
|
||||
assert(firstDomainEventSpyCall.data.meta.reason === 'initial');
|
||||
assert(secondDomainEventSpyCall.data.milestone);
|
||||
assert(secondDomainEventSpyCall.data.meta.currentValue === 1298);
|
||||
assert(secondDomainEventSpyCall.data.meta.reason === 'initial');
|
||||
});
|
||||
|
||||
it('Adds next ARR milestone and sends email', async function () {
|
||||
|
@ -131,10 +168,17 @@ describe('MilestonesService', function () {
|
|||
assert(arrResult.value === 10000);
|
||||
assert(arrResult.emailSentAt !== null);
|
||||
assert(arrResult.name === 'arr-10000-usd');
|
||||
assert(domainEventSpy.callCount === 4); // we have just created a new milestone
|
||||
const domainEventSpyResult = domainEventSpy.getCall(3).args[0];
|
||||
assert(domainEventSpyResult.data.milestone);
|
||||
assert(domainEventSpyResult.data.meta.currentValue === 10001);
|
||||
assert(domainEventSpy.callCount === 6); // we have just created three new milestones, but we only sent the email for the last one
|
||||
const firstDomainEventSpyResult = domainEventSpy.getCall(3).args[0];
|
||||
assert(firstDomainEventSpyResult.data.milestone);
|
||||
assert(firstDomainEventSpyResult.data.meta.reason === 'skipped');
|
||||
const secondDomainEventSpyResult = domainEventSpy.getCall(4).args[0];
|
||||
assert(secondDomainEventSpyResult.data.milestone);
|
||||
assert(secondDomainEventSpyResult.data.meta.reason === 'skipped');
|
||||
const thirdDomainEventSpyResult = domainEventSpy.getCall(5).args[0];
|
||||
assert(thirdDomainEventSpyResult.data.milestone);
|
||||
assert(thirdDomainEventSpyResult.data.meta.currentValue === 10001);
|
||||
assert(thirdDomainEventSpyResult.data.meta.reason === undefined);
|
||||
});
|
||||
|
||||
it('Does not add ARR milestone for out of scope currency', async function () {
|
||||
|
@ -167,7 +211,8 @@ describe('MilestonesService', function () {
|
|||
const milestone = await Milestone.create({
|
||||
type: 'arr',
|
||||
value: 5000,
|
||||
currency: 'gbp'
|
||||
currency: 'gbp',
|
||||
emailSentAt: '2023-01-01T00:00:00Z'
|
||||
});
|
||||
|
||||
await repository.save(milestone);
|
||||
|
@ -191,13 +236,38 @@ describe('MilestonesService', function () {
|
|||
});
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
assert(arrResult === undefined);
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
assert(arrResult.type === 'arr');
|
||||
assert(arrResult.currency === 'gbp');
|
||||
assert(arrResult.value === 5000);
|
||||
assert(arrResult.name === 'arr-5000-gbp');
|
||||
assert(domainEventSpy.callCount === 4);
|
||||
// Filled up missing milestones, but only if they don't exist already
|
||||
const firstDomainEventSpyResult = domainEventSpy.getCall(1).args[0];
|
||||
assert(firstDomainEventSpyResult.data.milestone);
|
||||
assert(firstDomainEventSpyResult.data.meta.reason === 'skipped');
|
||||
const secondDomainEventSpyResult = domainEventSpy.getCall(2).args[0];
|
||||
assert(secondDomainEventSpyResult.data.milestone);
|
||||
assert(secondDomainEventSpyResult.data.meta.reason === 'skipped');
|
||||
const thirdDomainEventSpyResult = domainEventSpy.getCall(3).args[0];
|
||||
assert(thirdDomainEventSpyResult.data.milestone);
|
||||
assert(thirdDomainEventSpyResult.data.meta.reason === 'skipped');
|
||||
assert(thirdDomainEventSpyResult.data.meta.currentValue === 5005);
|
||||
});
|
||||
|
||||
it('Adds ARR milestone but does not send email if imported members are detected', async function () {
|
||||
repository = new InMemoryMilestoneRepository({DomainEvents});
|
||||
|
||||
const milestone = await Milestone.create({
|
||||
type: 'arr',
|
||||
value: 0,
|
||||
currency: 'usd',
|
||||
emailSentAt: '2023-01-01T00:00:00Z'
|
||||
});
|
||||
|
||||
await repository.save(milestone);
|
||||
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
|
||||
const milestoneEmailService = new MilestonesService({
|
||||
repository,
|
||||
milestonesConfig,
|
||||
|
@ -219,9 +289,11 @@ describe('MilestonesService', function () {
|
|||
assert(arrResult.currency === 'usd');
|
||||
assert(arrResult.value === 100000);
|
||||
assert(arrResult.emailSentAt === null);
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
const domainEventSpyResult = domainEventSpy.getCall(0).args[0];
|
||||
assert(domainEventSpyResult.data.meta.reason === 'import');
|
||||
assert(domainEventSpy.callCount === 5);
|
||||
const secondDomainEventSpyResult = domainEventSpy.getCall(1).args[0];
|
||||
assert(secondDomainEventSpyResult.data.meta.reason === 'skipped');
|
||||
const lastDomainEventSpyResult = domainEventSpy.getCall(4).args[0];
|
||||
assert(lastDomainEventSpyResult.data.meta.reason === 'import');
|
||||
});
|
||||
|
||||
it('Adds ARR milestone but does not send email if last email was too recent', async function () {
|
||||
|
@ -261,12 +333,14 @@ describe('MilestonesService', function () {
|
|||
assert(arrResult.currency === 'idr');
|
||||
assert(arrResult.value === 10000);
|
||||
assert(arrResult.emailSentAt === null);
|
||||
assert(domainEventSpy.callCount === 2); // new milestone created
|
||||
const domainEventSpyResult = domainEventSpy.getCall(1).args[0];
|
||||
assert(domainEventSpyResult.data.meta.reason === 'email');
|
||||
assert(domainEventSpy.callCount === 3); // two new milestones created
|
||||
const lastDomainEventSpyResult = domainEventSpy.getCall(2).args[0];
|
||||
assert(lastDomainEventSpyResult.data.meta.reason === 'email');
|
||||
});
|
||||
});
|
||||
|
||||
it('Adds members milestone but does not send email when difference to milestone is above threshold', async function () {
|
||||
describe('Members Milestones', function () {
|
||||
it('Adds initial 0 Members milestone without sending email', async function () {
|
||||
repository = new InMemoryMilestoneRepository({DomainEvents});
|
||||
|
||||
const milestoneEmailService = new MilestonesService({
|
||||
|
@ -274,29 +348,28 @@ describe('MilestonesService', function () {
|
|||
milestonesConfig,
|
||||
queries: {
|
||||
async getMembersCount() {
|
||||
return 784;
|
||||
return 6;
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
},
|
||||
async getDefaultCurrency() {
|
||||
return 'nzd';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const membersResult = await milestoneEmailService.checkMilestones('members');
|
||||
assert(membersResult.type === 'members');
|
||||
assert(membersResult.value === 100);
|
||||
assert(membersResult.value === 0);
|
||||
assert(membersResult.emailSentAt === null);
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
const domainEventSpyResult = domainEventSpy.getCall(0).args[0];
|
||||
assert(domainEventSpyResult.data.meta.reason === 'tooFar');
|
||||
});
|
||||
});
|
||||
assert(membersResult.name === 'members-0');
|
||||
|
||||
describe('Members Milestones', function () {
|
||||
it('Adds first Members milestone and sends email', async function () {
|
||||
const domainEventSpyResult = domainEventSpy.getCall(0).args[0];
|
||||
assert(domainEventSpy.calledOnce === true);
|
||||
assert(domainEventSpyResult.data.milestone);
|
||||
assert(domainEventSpyResult.data.meta.currentValue === 6);
|
||||
assert(domainEventSpyResult.data.meta.reason === 'initial');
|
||||
});
|
||||
|
||||
it('Adds first Members milestone but does not send email if no previous milestones', async function () {
|
||||
repository = new InMemoryMilestoneRepository({DomainEvents});
|
||||
|
||||
const milestoneEmailService = new MilestonesService({
|
||||
|
@ -318,8 +391,18 @@ describe('MilestonesService', function () {
|
|||
const membersResult = await milestoneEmailService.checkMilestones('members');
|
||||
assert(membersResult.type === 'members');
|
||||
assert(membersResult.value === 100);
|
||||
assert(membersResult.emailSentAt !== null);
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
assert(membersResult.emailSentAt === null);
|
||||
assert(domainEventSpy.callCount === 2);
|
||||
|
||||
assert(domainEventSpy.calledTwice === true);
|
||||
const firstDomainEventSpyCall = domainEventSpy.getCall(0).args[0];
|
||||
const secondDomainEventSpyCall = domainEventSpy.getCall(1).args[0];
|
||||
assert(firstDomainEventSpyCall.data.milestone);
|
||||
assert(firstDomainEventSpyCall.data.meta.currentValue === 110);
|
||||
assert(firstDomainEventSpyCall.data.meta.reason === 'initial');
|
||||
assert(secondDomainEventSpyCall.data.milestone);
|
||||
assert(secondDomainEventSpyCall.data.meta.currentValue === 110);
|
||||
assert(secondDomainEventSpyCall.data.meta.reason === 'initial');
|
||||
});
|
||||
|
||||
it('Adds next Members milestone and sends email', async function () {
|
||||
|
@ -374,7 +457,21 @@ describe('MilestonesService', function () {
|
|||
assert(membersResult.value === 50000);
|
||||
assert(membersResult.emailSentAt !== null);
|
||||
assert(membersResult.name === 'members-50000');
|
||||
assert(domainEventSpy.callCount === 4);
|
||||
|
||||
assert(domainEventSpy.callCount === 7); // we have just created three new milestones, but we only sent the email for the last one
|
||||
const firstDomainEventSpyResult = domainEventSpy.getCall(3).args[0];
|
||||
assert(firstDomainEventSpyResult.data.milestone);
|
||||
assert(firstDomainEventSpyResult.data.meta.reason === 'skipped');
|
||||
const secondDomainEventSpyResult = domainEventSpy.getCall(4).args[0];
|
||||
assert(secondDomainEventSpyResult.data.milestone);
|
||||
assert(secondDomainEventSpyResult.data.meta.reason === 'skipped');
|
||||
const thirdDomainEventSpyResult = domainEventSpy.getCall(5).args[0];
|
||||
assert(thirdDomainEventSpyResult.data.milestone);
|
||||
assert(thirdDomainEventSpyResult.data.meta.reason === 'skipped');
|
||||
const fourthDomainEventSpyResult = domainEventSpy.getCall(6).args[0];
|
||||
assert(fourthDomainEventSpyResult.data.milestone);
|
||||
assert(fourthDomainEventSpyResult.data.meta.currentValue === 50005);
|
||||
assert(fourthDomainEventSpyResult.data.meta.reason === undefined);
|
||||
});
|
||||
|
||||
it('Does not add new Members milestone if already achieved', async function () {
|
||||
|
@ -406,8 +503,22 @@ describe('MilestonesService', function () {
|
|||
});
|
||||
|
||||
const membersResult = await milestoneEmailService.checkMilestones('members');
|
||||
assert(membersResult === undefined);
|
||||
assert(domainEventSpy.callCount === 1);
|
||||
assert(membersResult.type === 'members');
|
||||
assert(membersResult.value === 50000);
|
||||
assert(membersResult.name === 'members-50000');
|
||||
assert(domainEventSpy.callCount === 5);
|
||||
// Filled up missing milestones, but only if they don't exist already
|
||||
const firstDomainEventSpyResult = domainEventSpy.getCall(1).args[0];
|
||||
assert(firstDomainEventSpyResult.data.milestone);
|
||||
assert(firstDomainEventSpyResult.data.meta.reason === 'skipped');
|
||||
const secondDomainEventSpyResult = domainEventSpy.getCall(2).args[0];
|
||||
assert(secondDomainEventSpyResult.data.milestone);
|
||||
assert(secondDomainEventSpyResult.data.meta.reason === 'skipped');
|
||||
const thirdDomainEventSpyResult = domainEventSpy.getCall(3).args[0];
|
||||
assert(thirdDomainEventSpyResult.data.milestone);
|
||||
assert(thirdDomainEventSpyResult.data.meta.reason === 'skipped');
|
||||
assert(thirdDomainEventSpyResult.data.meta.currentValue === 50555);
|
||||
assert(thirdDomainEventSpyResult.data.meta.reason === 'skipped');
|
||||
});
|
||||
|
||||
it('Adds Members milestone but does not send email if imported members are detected', async function () {
|
||||
|
@ -442,7 +553,9 @@ describe('MilestonesService', function () {
|
|||
assert(membersResult.type === 'members');
|
||||
assert(membersResult.value === 1000);
|
||||
assert(membersResult.emailSentAt === null);
|
||||
assert(domainEventSpy.callCount === 2);
|
||||
assert(domainEventSpy.callCount === 3);
|
||||
const lastDomainEventSpyResult = domainEventSpy.getCall(2).args[0];
|
||||
assert(lastDomainEventSpyResult.data.meta.reason === 'import');
|
||||
});
|
||||
|
||||
it('Adds Members milestone but does not send email if last email was too recent', async function () {
|
||||
|
@ -481,7 +594,9 @@ describe('MilestonesService', function () {
|
|||
assert(membersResult.type === 'members');
|
||||
assert(membersResult.value === 50000);
|
||||
assert(membersResult.emailSentAt === null);
|
||||
assert(domainEventSpy.callCount === 2);
|
||||
assert(domainEventSpy.callCount === 5);
|
||||
const lastDomainEventSpyResult = domainEventSpy.getCall(4).args[0];
|
||||
assert(lastDomainEventSpyResult.data.meta.reason === 'email');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -49,16 +49,18 @@ class SlackNotifications {
|
|||
* @param {object} eventData
|
||||
* @param {import('@tryghost/milestones/lib/InMemoryMilestoneRepository').Milestone} eventData.milestone
|
||||
* @param {object} [eventData.meta]
|
||||
* @param {'import'|'email'|'tooFar'} [eventData.meta.reason]
|
||||
* @param {'import'|'email'|'skipped'|'initial'} [eventData.meta.reason]
|
||||
* @param {number} [eventData.meta.currentValue]
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async notifyMilestoneReceived({milestone, meta}) {
|
||||
if (meta?.reason === 'skipped' || meta?.reason === 'initial') {
|
||||
return;
|
||||
}
|
||||
const hasImportedMembers = meta?.reason === 'import' ? 'has imported members' : null;
|
||||
const lastEmailTooSoon = meta?.reason === 'email' ? 'last email too recent' : null;
|
||||
const tooFarFromMilestone = meta?.reason === 'tooFar' ? 'too far from milestone' : null;
|
||||
const emailNotSentReason = hasImportedMembers || lastEmailTooSoon || tooFarFromMilestone;
|
||||
const emailNotSentReason = hasImportedMembers || lastEmailTooSoon;
|
||||
const milestoneTypePretty = milestone.type === 'arr' ? 'ARR' : 'Members';
|
||||
const valueFormatted = this.#getFormattedAmount({amount: milestone.value, currency: milestone?.currency});
|
||||
const emailSentText = milestone?.emailSentAt ? this.#getFormattedDate(milestone?.emailSentAt) : `no / ${emailNotSentReason}`;
|
||||
|
|
|
@ -241,72 +241,42 @@ describe('SlackNotifications', function () {
|
|||
assert(sendStub.calledWith(expectedResult, 'https://slack-webhook.example') === true);
|
||||
});
|
||||
|
||||
it('Shows the correct reason for email not send when last milestone is too far from actual value', async function () {
|
||||
it('Does not attempt to send notification for `skipped` milestones', async function () {
|
||||
await slackNotifications.notifyMilestoneReceived({
|
||||
milestone: {
|
||||
id: ObjectId().toHexString(),
|
||||
name: 'members-1000',
|
||||
type: 'members',
|
||||
name: 'arr-1000-eur',
|
||||
type: 'arr',
|
||||
currency: 'eur',
|
||||
createdAt: '2023-02-15T00:00:00.000Z',
|
||||
emailSentAt: null,
|
||||
value: 1000
|
||||
},
|
||||
meta: {
|
||||
currentValue: 1105,
|
||||
reason: 'tooFar'
|
||||
currentValue: 1005,
|
||||
reason: 'skipped'
|
||||
}
|
||||
});
|
||||
|
||||
const expectedResult = {
|
||||
unfurl_links: false,
|
||||
username: 'Ghost Milestone Service',
|
||||
attachments: [{
|
||||
color: '#36a64f',
|
||||
blocks: [
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: ':tada: Members Milestone 1,000 reached!',
|
||||
emoji: true
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: 'New *Members Milestone* achieved for <https://ghost.example|https://ghost.example>'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
fields: [
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Milestone:*\n1,000'
|
||||
},
|
||||
{
|
||||
type: 'mrkdwn',
|
||||
text: '*Current Members:*\n1,105'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: '*Email sent:*\nno / too far from milestone'
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
};
|
||||
assert(sendStub.callCount === 0);
|
||||
});
|
||||
|
||||
assert(sendStub.calledOnce === true);
|
||||
assert(sendStub.calledWith(expectedResult, 'https://slack-webhook.example') === true);
|
||||
it('Does not attempt to send notification for `initial` milestones', async function () {
|
||||
await slackNotifications.notifyMilestoneReceived({
|
||||
milestone: {
|
||||
id: ObjectId().toHexString(),
|
||||
name: 'arr-1000-eur',
|
||||
type: 'arr',
|
||||
currency: 'eur',
|
||||
createdAt: '2023-02-15T00:00:00.000Z',
|
||||
value: 1000
|
||||
},
|
||||
meta: {
|
||||
currentValue: 1005,
|
||||
reason: 'initial'
|
||||
}
|
||||
});
|
||||
|
||||
assert(sendStub.callCount === 0);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue