0
Fork 0
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:
Aileen Booker 2023-03-13 19:01:11 +02:00 committed by GitHub
parent 4c946f5145
commit eeb7546abb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 364 additions and 189 deletions

View file

@ -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;
}
/**

View file

@ -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
}
}

View file

@ -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'});

View file

@ -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);
}
}
};

View file

@ -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'
});

View file

@ -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();
}
}

View file

@ -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);
});
});

View file

@ -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 () {

View file

@ -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');
});
});
});

View file

@ -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}`;

View file

@ -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);
});
});