mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
Added milestone email service behind alpha flag (#16241)
refs https://www.notion.so/ghost/Marketing-Milestone-email-campaigns-1d2c9dee3cfa4029863edb16092ad5c4 Added milestone email service behind a flag. The service will currently run on boot and is meant to be scheduled soon, which should happen in the next step. For now it's protected behind the alpha flag.
This commit is contained in:
parent
48f9485f46
commit
6f0d1b0ff9
12 changed files with 547 additions and 109 deletions
|
@ -370,6 +370,9 @@ async function initBackgroundServices({config}) {
|
|||
const updateCheck = require('./server/update-check');
|
||||
updateCheck.scheduleRecurringJobs();
|
||||
|
||||
const milestoneEmails = require('./server/services/milestone-emails');
|
||||
milestoneEmails.initAndRun();
|
||||
|
||||
debug('End: initBackgroundServices');
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
const MIN_DAYS_SINCE_IMPORTED = 7;
|
||||
|
||||
module.exports = class MilestoneQueries {
|
||||
#db;
|
||||
|
||||
constructor(deps) {
|
||||
this.#db = deps.db;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async getMembersCount() {
|
||||
const [membersCount] = await this.#db.knex('members').count('id as count');
|
||||
|
||||
return membersCount?.count || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async getARR() {
|
||||
const currentARR = await this.#db.knex('members_paid_subscription_events as stripe')
|
||||
.select(this.#db.knex.raw('ROUND(SUM(stripe.mrr_delta) * 12) / 100 AS arr, stripe.currency as currency'))
|
||||
.groupBy('stripe.currency');
|
||||
|
||||
return currentARR;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async hasImportedMembersInPeriod() {
|
||||
const [hasImportedMembers] = await this.#db.knex('members_subscribe_events')
|
||||
.count('id as count')
|
||||
.where('source', '=', 'import')
|
||||
.where('created_at', '>=', MIN_DAYS_SINCE_IMPORTED);
|
||||
|
||||
return hasImportedMembers?.count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async getDefaultCurrency() {
|
||||
const currentARR = await this.getARR();
|
||||
|
||||
// Set the default currency as the one with the highest value
|
||||
if (currentARR.length > 1) {
|
||||
const highestValues = currentARR.sort((a, b) => b.arr - a.arr);
|
||||
return highestValues?.[0]?.currency;
|
||||
} else if (currentARR?.[0]?.currency) {
|
||||
return currentARR[0].currency;
|
||||
} else {
|
||||
return 'usd';
|
||||
}
|
||||
}
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
module.exports = require('./service');
|
58
ghost/core/core/server/services/milestone-emails/service.js
Normal file
58
ghost/core/core/server/services/milestone-emails/service.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
// Stubbing stripe in test was causing issues. Moved it
|
||||
// into this function to be able to rewire and stub the
|
||||
// expected return value.
|
||||
const getStripeLiveEnabled = () => {
|
||||
const stripeService = require('../stripe');
|
||||
// This seems to be the only true way to check if Stripe is configured in live mode
|
||||
// settingsCache only cares if Stripe is enabled
|
||||
return stripeService.api.configured && stripeService.api.mode === 'live';
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
module.exports = {
|
||||
async initAndRun() {
|
||||
const labs = require('../../../shared/labs');
|
||||
|
||||
if (labs.isSet('milestoneEmails')) {
|
||||
const db = require('../../data/db');
|
||||
const MilestoneQueries = require('./MilestoneQueries');
|
||||
|
||||
const {
|
||||
MilestonesEmailService,
|
||||
InMemoryMilestoneRepository
|
||||
} = require('@tryghost/milestone-emails');
|
||||
const config = require('../../../shared/config');
|
||||
const milestonesConfig = config.get('milestones');
|
||||
const {GhostMailer} = require('../mail');
|
||||
|
||||
const mailer = new GhostMailer();
|
||||
const repository = new InMemoryMilestoneRepository();
|
||||
const queries = new MilestoneQueries({db});
|
||||
|
||||
const milestonesEmailService = new MilestonesEmailService({
|
||||
mailer,
|
||||
repository,
|
||||
milestonesConfig, // avoid using getters and pass as JSON
|
||||
queries
|
||||
});
|
||||
|
||||
let arrResult;
|
||||
|
||||
// @TODO: schedule recurring jobs instead
|
||||
const membersResult = await milestonesEmailService.checkMilestones('members');
|
||||
const stripeLiveEnabled = getStripeLiveEnabled();
|
||||
|
||||
if (stripeLiveEnabled) {
|
||||
arrResult = await milestonesEmailService.checkMilestones('arr');
|
||||
}
|
||||
|
||||
return {
|
||||
members: membersResult,
|
||||
arr: arrResult
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
|
@ -113,6 +113,7 @@
|
|||
"@tryghost/members-stripe-service": "0.0.0",
|
||||
"@tryghost/metrics": "1.0.20",
|
||||
"@tryghost/minifier": "0.0.0",
|
||||
"@tryghost/milestone-emails": "0.0.0",
|
||||
"@tryghost/mw-api-version-mismatch": "0.0.0",
|
||||
"@tryghost/mw-cache-control": "0.0.0",
|
||||
"@tryghost/mw-error-handler": "0.0.0",
|
||||
|
|
228
ghost/core/test/e2e-server/services/milestone-emails.test.js
Normal file
228
ghost/core/test/e2e-server/services/milestone-emails.test.js
Normal file
|
@ -0,0 +1,228 @@
|
|||
const {agentProvider, fixtureManager, mockManager, configUtils} = require('../../utils/e2e-framework');
|
||||
const assert = require('assert');
|
||||
const nock = require('nock');
|
||||
const sinon = require('sinon');
|
||||
const models = require('../../../core/server/models');
|
||||
const moment = require('moment');
|
||||
|
||||
const milestoneEmailsService = require('../../../core/server/services/milestone-emails/service');
|
||||
|
||||
let agent;
|
||||
let counter = 0;
|
||||
let membersCounter = 0;
|
||||
|
||||
async function createMemberWithSubscription(interval, amount, currency, date) {
|
||||
counter += 1;
|
||||
membersCounter += 1;
|
||||
|
||||
const fakePrice = {
|
||||
id: 'price_' + counter,
|
||||
product: '',
|
||||
active: true,
|
||||
nickname: 'Paid',
|
||||
unit_amount: amount,
|
||||
currency,
|
||||
type: 'recurring',
|
||||
recurring: {
|
||||
interval
|
||||
}
|
||||
};
|
||||
|
||||
const fakeSubscription = {
|
||||
id: 'sub_' + counter,
|
||||
customer: 'cus_' + counter,
|
||||
status: 'active',
|
||||
cancel_at_period_end: false,
|
||||
metadata: {},
|
||||
current_period_end: Date.now() / 1000 + 1000,
|
||||
start_date: moment(date).unix(),
|
||||
plan: fakePrice,
|
||||
items: {
|
||||
data: [{
|
||||
price: fakePrice
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
const fakeCustomer = {
|
||||
id: 'cus_' + counter,
|
||||
name: 'Test Member',
|
||||
email: 'create-member-subscription-' + counter + '@email.com',
|
||||
subscriptions: {
|
||||
type: 'list',
|
||||
data: [fakeSubscription]
|
||||
}
|
||||
};
|
||||
nock('https://api.stripe.com')
|
||||
.persist()
|
||||
.get(/v1\/.*/)
|
||||
.reply((uri, body) => {
|
||||
const [match, resource, id] = uri.match(/\/?v1\/(\w+)\/?(\w+)/) || [null];
|
||||
|
||||
if (!match) {
|
||||
return [500];
|
||||
}
|
||||
|
||||
if (resource === 'customers') {
|
||||
return [200, fakeCustomer];
|
||||
}
|
||||
|
||||
if (resource === 'subscriptions') {
|
||||
return [200, fakeSubscription];
|
||||
}
|
||||
});
|
||||
|
||||
const initialMember = {
|
||||
name: fakeCustomer.name,
|
||||
email: fakeCustomer.email,
|
||||
subscribed: true,
|
||||
stripe_customer_id: fakeCustomer.id
|
||||
};
|
||||
|
||||
await agent
|
||||
.post(`/members/`)
|
||||
.body({members: [initialMember]})
|
||||
.expectStatus(201);
|
||||
|
||||
nock.cleanAll();
|
||||
}
|
||||
|
||||
async function createFreeMembers(amount, amountImported = 0) {
|
||||
const members = [];
|
||||
|
||||
const newsletters = await agent.get(`/newsletters/`);
|
||||
const newsletter = newsletters.body?.newsletters?.[0];
|
||||
|
||||
for (let index = 0; index < amount; index++) {
|
||||
let membersAddRequest;
|
||||
membersCounter += 1;
|
||||
|
||||
const member = {
|
||||
name: 'Test Member',
|
||||
email: 'free-member-' + membersCounter + '@email.com',
|
||||
status: 'free',
|
||||
uuid: `f6f91461-d7d8-4a3f-aa5d-8e582c40b99${membersCounter}`
|
||||
};
|
||||
|
||||
if (amountImported > 0) {
|
||||
member.subscribed = true;
|
||||
member.newsletters = [newsletter];
|
||||
|
||||
const createMemberEvent = await agent
|
||||
.post(`/members/`)
|
||||
.body({members: [member]})
|
||||
.expectStatus(201);
|
||||
|
||||
const id = createMemberEvent.body.members[0].id;
|
||||
|
||||
// Manually add the members_subscribe_event so we can test imported members
|
||||
const editedEvent = await models.MemberSubscribeEvent.add({
|
||||
newsletter_id: newsletter.id,
|
||||
member_id: id,
|
||||
subscribed: true,
|
||||
source: index < amountImported ? 'import' : 'member'
|
||||
});
|
||||
|
||||
membersAddRequest = Promise.all([createMemberEvent, editedEvent]);
|
||||
} else {
|
||||
membersAddRequest = await agent
|
||||
.post(`/members/`)
|
||||
.body({members: [member]})
|
||||
.expectStatus(201);
|
||||
}
|
||||
|
||||
members.push(membersAddRequest);
|
||||
}
|
||||
|
||||
await Promise.all(members);
|
||||
}
|
||||
|
||||
describe('Milestone Emails Service', function () {
|
||||
// let stripeModeStub;
|
||||
|
||||
const milestonesConfig = {
|
||||
arr: [{currency: 'usd', values: [100]}],
|
||||
members: [10, 100]
|
||||
};
|
||||
|
||||
before(async function () {
|
||||
agent = await agentProvider.getAdminAPIAgent();
|
||||
await fixtureManager.init('newsletters');
|
||||
await agent.loginAsOwner();
|
||||
});
|
||||
|
||||
beforeEach(async function () {
|
||||
sinon.createSandbox();
|
||||
// TODO: stub out stripe mode
|
||||
// stripeModeStub = sinon.stub().returns(true);
|
||||
// milestoneEmailsService.__set__('getStripeLiveEnabled', stripeModeStub);
|
||||
configUtils.set('milestones', milestonesConfig);
|
||||
mockManager.mockLabsEnabled('milestoneEmails');
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
await configUtils.restore();
|
||||
mockManager.restore();
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('Runs ARR and Members milestone jobs', async function () {
|
||||
// No ARR and no members
|
||||
const firstRun = await milestoneEmailsService.initAndRun();
|
||||
assert(firstRun.members === undefined);
|
||||
// assert(firstRun.arr === undefined);
|
||||
|
||||
await createFreeMembers(7);
|
||||
await createMemberWithSubscription('year', 5000, 'usd', '2000-01-10');
|
||||
await createMemberWithSubscription('month', 100, 'usd', '2000-01-10');
|
||||
const secondRun = await milestoneEmailsService.initAndRun();
|
||||
assert(secondRun.members === undefined);
|
||||
// assert(secondRun.arr === undefined);
|
||||
|
||||
// Reached the first milestone for members
|
||||
await createFreeMembers(1);
|
||||
const thirdRun = await milestoneEmailsService.initAndRun();
|
||||
assert(thirdRun.members.value === 10);
|
||||
assert(thirdRun.members.emailSentAt !== undefined);
|
||||
// assert(thirdRun.arr === undefined);
|
||||
|
||||
// Reached the first milestone for ARR
|
||||
await createMemberWithSubscription('month', 500, 'usd', '2000-01-10');
|
||||
await createMemberWithSubscription('month', 500, 'eur', '2000-01-10');
|
||||
const fourthRun = await milestoneEmailsService.initAndRun();
|
||||
// This will be false once we hook up to the DB
|
||||
assert(fourthRun.members.value === 10);
|
||||
assert(fourthRun.members.emailSentAt !== undefined);
|
||||
// assert(fourthRun.arr.value === 100);
|
||||
// assert(fourthRun.arr.emailSentAt !== undefined);
|
||||
});
|
||||
|
||||
it('Does not send emails for milestones when imported members present', async function () {
|
||||
await createFreeMembers(10, 1);
|
||||
await createMemberWithSubscription('month', 1000, 'usd', '2023-01-10');
|
||||
const result = await milestoneEmailsService.initAndRun();
|
||||
|
||||
assert(result.members.value === 10);
|
||||
assert(result.members.emailSentAt === null);
|
||||
// assert(result.arr.value === 100);
|
||||
// assert(result.arr.emailSentAt === null);
|
||||
});
|
||||
|
||||
it('Does not run when milestoneEmails labs flag is not set', async function () {
|
||||
mockManager.mockLabsDisabled('milestoneEmails');
|
||||
|
||||
const result = await milestoneEmailsService.initAndRun();
|
||||
assert(result === undefined);
|
||||
});
|
||||
|
||||
// it('Does not run ARR milestones when Stripe is not live enabled', async function () {
|
||||
// stripeModeStub = sinon.stub().returns(false);
|
||||
// milestoneEmailsService.__set__('getStripeLiveEnabled', stripeModeStub);
|
||||
// await createFreeMembers(10);
|
||||
|
||||
// const result = await milestoneEmailsService.initAndRun();
|
||||
// assert(result.members.value === 10);
|
||||
// assert(result.members.emailSentAt !== undefined);
|
||||
// assert(result.arr === undefined);
|
||||
// });
|
||||
});
|
|
@ -0,0 +1,37 @@
|
|||
const db = require('../../../../../core/server/data/db');
|
||||
const assert = require('assert');
|
||||
const sinon = require('sinon');
|
||||
|
||||
describe('MilestoneQueries', function () {
|
||||
let milestoneQueries;
|
||||
let queryMock;
|
||||
let knexMock;
|
||||
|
||||
before(function () {
|
||||
queryMock = {
|
||||
groupBy: sinon.stub(),
|
||||
select: sinon.stub(),
|
||||
raw: sinon.stub(),
|
||||
count: sinon.stub(),
|
||||
where: sinon.stub()
|
||||
};
|
||||
|
||||
knexMock = sinon.stub().returns(queryMock);
|
||||
|
||||
sinon.stub(db, 'knex').get(function () {
|
||||
return knexMock;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Milestone Emails Service', function () {
|
||||
it('Provides expected public API', async function () {
|
||||
const MilestoneQueries = require('../../../../../core/server/services/milestone-emails/MilestoneQueries');
|
||||
milestoneQueries = new MilestoneQueries({db: knexMock});
|
||||
|
||||
assert.ok(milestoneQueries.getMembersCount);
|
||||
assert.ok(milestoneQueries.getARR);
|
||||
assert.ok(milestoneQueries.hasImportedMembersInPeriod);
|
||||
assert.ok(milestoneQueries.getDefaultCurrency);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,13 @@
|
|||
const assert = require('assert');
|
||||
|
||||
describe('Milestone Emails Service', function () {
|
||||
let milestoneEmails;
|
||||
|
||||
describe('Milestone Emails Service', function () {
|
||||
it('Provides expected public API', async function () {
|
||||
milestoneEmails = require('../../../../../core/server/services/milestone-emails');
|
||||
|
||||
assert.ok(milestoneEmails.initAndRun);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* @typedef {import('./Milestone')} Milestone
|
||||
* @typedef {import('./MilestonesAPI').IMilestoneRepository} IMilestoneRepository
|
||||
* @typedef {import('./MilestonesEmailService').IMilestoneRepository} IMilestoneRepository
|
||||
*/
|
||||
|
||||
/**
|
||||
|
@ -32,7 +32,7 @@ module.exports = class InMemoryMilestoneRepository {
|
|||
|
||||
/**
|
||||
* @param {'arr'|'members'} type
|
||||
* @param {string|null} currency
|
||||
* @param {string} [currency]
|
||||
*
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
|
@ -68,7 +68,7 @@ module.exports = class InMemoryMilestoneRepository {
|
|||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @param {string} currency
|
||||
* @param {string} [currency]
|
||||
*
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
|
|
|
@ -151,9 +151,9 @@ function validateValue(value) {
|
|||
|
||||
/**
|
||||
*
|
||||
* @param {'arr'|'members'} type
|
||||
* @param {unknown} type
|
||||
*
|
||||
* @returns {string}
|
||||
* @returns {'arr'|'members'}
|
||||
*/
|
||||
function validateType(type) {
|
||||
if (type === 'arr') {
|
||||
|
@ -201,7 +201,7 @@ function validateName(name, value, type, currency) {
|
|||
|
||||
/**
|
||||
*
|
||||
* @param {Object} data
|
||||
* @param {object} data
|
||||
* @param {Date|null} data.emailSentAt
|
||||
*
|
||||
* @returns {Date|null}
|
||||
|
|
|
@ -1,66 +1,66 @@
|
|||
const Milestone = require('./Milestone');
|
||||
|
||||
/**
|
||||
* @template Model
|
||||
* @typedef {object} Mention<Model>
|
||||
* @prop {Model[]} data
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} IMilestoneRepository
|
||||
* @prop {(milestone: Milestone) => Promise<void>} save
|
||||
* @prop {(arr: number) => Promise<Milestone>} getByARR
|
||||
* @prop {(arr: number, [currency]: string|null) => Promise<Milestone>} getByARR
|
||||
* @prop {(count: number) => Promise<Milestone>} getByCount
|
||||
* @prop {(type: 'arr'|'members') => Promise<Milestone>} getLatestByType
|
||||
* @prop {(type: 'arr'|'members', [currency]: string|null) => Promise<Milestone>} getLatestByType
|
||||
* @prop {() => Promise<Milestone>} getLastEmailSent
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template Model
|
||||
* @typedef {import('./MilestonesAPI')} <Model>
|
||||
* @typedef {object} IQueries
|
||||
* @prop {() => Promise<number>} getMembersCount
|
||||
* @prop {() => Promise<object>} getARR
|
||||
* @prop {() => Promise<boolean>} hasImportedMembersInPeriod
|
||||
* @prop {() => Promise<string>} getDefaultCurrency
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} IQueries
|
||||
* @prop {() => Promise<number>} getMembersCount
|
||||
* @prop {() => Promise<Object>} getARR
|
||||
* @prop {() => Promise<boolean>} hasImportedMembersInPeriod
|
||||
* @typedef {object} ghostMailer
|
||||
* @property {Function} send
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} milestonesConfig
|
||||
* @prop {Array<object>} milestonesConfig.arr
|
||||
* @prop {string} milestonesConfig.arr.currency
|
||||
* @prop {number[]} milestonesConfig.arr.values
|
||||
* @prop {number[]} milestonesConfig.members
|
||||
*/
|
||||
|
||||
module.exports = class MilestonesEmailService {
|
||||
/** @type {IMilestoneRepository} */
|
||||
#repository;
|
||||
|
||||
/** @type {Function} */
|
||||
/**
|
||||
* @type {ghostMailer} */
|
||||
#mailer;
|
||||
|
||||
/** @type {Object} */
|
||||
#config;
|
||||
/**
|
||||
* @type {milestonesConfig} */
|
||||
#milestonesConfig;
|
||||
|
||||
/** @type {IQueries} */
|
||||
#queries;
|
||||
|
||||
/** @type {string} */
|
||||
#defaultCurrency;
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {Function} deps.mailer
|
||||
* @param {MilestonesAPI} deps.api
|
||||
* @param {Object} deps.config
|
||||
* @param {ghostMailer} deps.mailer
|
||||
* @param {IMilestoneRepository} deps.repository
|
||||
* @param {milestonesConfig} deps.milestonesConfig
|
||||
* @param {IQueries} deps.queries
|
||||
* @param {string} deps.defaultCurrency
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.#mailer = deps.mailer;
|
||||
this.#config = deps.config;
|
||||
this.#milestonesConfig = deps.milestonesConfig;
|
||||
this.#queries = deps.queries;
|
||||
this.#defaultCurrency = deps.defaultCurrency;
|
||||
this.#repository = deps.repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|null} currency
|
||||
* @param {string} [currency]
|
||||
*
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
|
@ -72,7 +72,14 @@ module.exports = class MilestonesEmailService {
|
|||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async #getLatestMembersCountMilestone() {
|
||||
return this.#repository.getLatestByType('members');
|
||||
return this.#repository.getLatestByType('members', null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async #getDefaultCurrency() {
|
||||
return await this.#queries.getDefaultCurrency();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -88,7 +95,7 @@ module.exports = class MilestonesEmailService {
|
|||
let existingMilestone = null;
|
||||
|
||||
if (milestone.type === 'arr') {
|
||||
existingMilestone = await this.#repository.getByARR(milestone.value, milestone?.currency) || false;
|
||||
existingMilestone = await this.#repository.getByARR(milestone.value, milestone.currency) || false;
|
||||
} else if (milestone.type === 'members') {
|
||||
existingMilestone = await this.#repository.getByCount(milestone.value) || false;
|
||||
}
|
||||
|
@ -115,10 +122,10 @@ module.exports = class MilestonesEmailService {
|
|||
|
||||
/**
|
||||
*
|
||||
* @param {Array} goalValues
|
||||
* @param {number[]} goalValues
|
||||
* @param {number} current
|
||||
*
|
||||
* @returns {Array}
|
||||
* @returns {number}
|
||||
*/
|
||||
#getMatchedMilestone(goalValues, current) {
|
||||
// return highest suitable milestone
|
||||
|
@ -128,22 +135,19 @@ module.exports = class MilestonesEmailService {
|
|||
|
||||
/**
|
||||
*
|
||||
* @param {Object} milestone
|
||||
* @param {object} milestone
|
||||
* @param {number} milestone.value
|
||||
* @param {'arr'|'members'} milestone.type
|
||||
* @param {boolean} hasMembersImported
|
||||
* @param {string|null} [milestone.currency]
|
||||
* @param {Date|null} [milestone.emailSentAt]
|
||||
*
|
||||
* @returns {Promise<Milestone>}
|
||||
*/
|
||||
async #saveMileStoneAndSendEmail(milestone) {
|
||||
if (milestone.type === 'arr') {
|
||||
milestone.currency = this.#defaultCurrency;
|
||||
}
|
||||
|
||||
const shouldSendEmail = await this.#shouldSendEmail();
|
||||
|
||||
if (shouldSendEmail) {
|
||||
// TODO: hook up GhostMailer or use StaffService and trigger event to send email
|
||||
// TODO: hook up Ghostmailer or use StaffService and trigger event to send email
|
||||
// await this.#mailer.send({
|
||||
// subject: 'Test',
|
||||
// html: '<div>Milestone achieved</div>',
|
||||
|
@ -187,29 +191,33 @@ module.exports = class MilestonesEmailService {
|
|||
async #runARRQueries() {
|
||||
// Fetch the current data from queries
|
||||
const currentARR = await this.#queries.getARR();
|
||||
const defaultCurrency = await this.#getDefaultCurrency();
|
||||
|
||||
// Check the definitions in the config
|
||||
const arrMilestoneSettings = this.#config.milestones.arr;
|
||||
// Check the definitions in the milestonesConfig
|
||||
const arrMilestoneSettings = this.#milestonesConfig.arr;
|
||||
const supportedCurrencies = arrMilestoneSettings.map(setting => setting.currency);
|
||||
|
||||
// First check the currency matches
|
||||
if (currentARR.length) {
|
||||
let milestone;
|
||||
|
||||
const currentARRForCurrency = currentARR.filter(arr => arr.currency === this.#defaultCurrency)[0];
|
||||
const milestonesForCurrency = arrMilestoneSettings.filter(milestoneSetting => milestoneSetting.currency === this.#defaultCurrency)[0];
|
||||
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);
|
||||
|
||||
// Fetch the latest milestone for this currency
|
||||
const latestMilestone = await this.#getLatestArrMilestone(this.#defaultCurrency);
|
||||
const latestMilestone = await this.#getLatestArrMilestone(defaultCurrency);
|
||||
|
||||
// Ensure the milestone doesn't already exist
|
||||
const milestoneExists = await this.#checkMilestoneExists({value: milestone, type: 'arr', currency: this.#defaultCurrency});
|
||||
const milestoneExists = await this.#checkMilestoneExists({value: milestone, type: 'arr', currency: defaultCurrency});
|
||||
|
||||
if ((!milestoneExists && !latestMilestone || milestone > latestMilestone.value)) {
|
||||
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'arr'});
|
||||
if (milestone && milestone > 0) {
|
||||
if (!milestoneExists && (!latestMilestone || milestone > latestMilestone.value)) {
|
||||
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'arr', currency: defaultCurrency});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -222,20 +230,22 @@ module.exports = class MilestonesEmailService {
|
|||
// Fetch the current data
|
||||
const membersCount = await this.#queries.getMembersCount();
|
||||
|
||||
// Check the definitions in the config
|
||||
const membersMilestones = this.#config.milestones.members;
|
||||
// Check the definitions in the milestonesConfig
|
||||
const membersMilestones = this.#milestonesConfig.members;
|
||||
|
||||
// get the closest milestone we're over now
|
||||
const milestone = this.#getMatchedMilestone(membersMilestones, membersCount);
|
||||
let milestone = this.#getMatchedMilestone(membersMilestones, membersCount);
|
||||
|
||||
// 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'});
|
||||
const milestoneExists = await this.#checkMilestoneExists({value: milestone, type: 'members', currency: null});
|
||||
|
||||
if ((!milestoneExists && !latestMembersMilestone || milestone > latestMembersMilestone.value)) {
|
||||
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'members'});
|
||||
if (milestone && milestone > 0) {
|
||||
if (!milestoneExists && (!latestMembersMilestone || milestone > latestMembersMilestone.value)) {
|
||||
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'members'});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,25 +8,27 @@ const Milestone = require('../lib/Milestone');
|
|||
describe('MilestonesEmailService', function () {
|
||||
let repository;
|
||||
|
||||
const milestoneConfig = {
|
||||
milestones:
|
||||
{
|
||||
arr: [
|
||||
{
|
||||
currency: 'usd',
|
||||
values: [1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
},
|
||||
{
|
||||
currency: 'gbp',
|
||||
values: [500, 1000, 5000, 100000, 250000, 500000, 1000000]
|
||||
},
|
||||
{
|
||||
currency: 'idr',
|
||||
values: [1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
}
|
||||
],
|
||||
members: [100, 1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
}
|
||||
const milestonesConfig = {
|
||||
arr: [
|
||||
{
|
||||
currency: 'usd',
|
||||
values: [1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
},
|
||||
{
|
||||
currency: 'gbp',
|
||||
values: [500, 1000, 5000, 100000, 250000, 500000, 1000000]
|
||||
},
|
||||
{
|
||||
currency: 'idr',
|
||||
values: [1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
},
|
||||
{
|
||||
currency: 'eur',
|
||||
values: [1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
}
|
||||
],
|
||||
members: [100, 1000, 10000, 50000, 100000, 250000, 500000, 1000000]
|
||||
|
||||
};
|
||||
|
||||
describe('ARR Milestones', function () {
|
||||
|
@ -39,16 +41,18 @@ describe('MilestonesEmailService', function () {
|
|||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
milestonesConfig,
|
||||
queries: {
|
||||
async getARR() {
|
||||
return [{currency: 'usd', arr: 1298}, {currency: 'gbp', arr: 2600}];
|
||||
return [{currency: 'usd', arr: 1298}, {currency: 'nzd', arr: 600}];
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
},
|
||||
async getDefaultCurrency() {
|
||||
return 'usd';
|
||||
}
|
||||
},
|
||||
defaultCurrency: 'usd'
|
||||
}
|
||||
});
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
|
@ -64,7 +68,7 @@ describe('MilestonesEmailService', function () {
|
|||
|
||||
const milestoneOne = await Milestone.create({
|
||||
type: 'arr',
|
||||
value: 1000,
|
||||
value: 100,
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
emailSentAt: '2023-01-01T00:00:00Z'
|
||||
});
|
||||
|
@ -79,7 +83,7 @@ describe('MilestonesEmailService', function () {
|
|||
const milestoneThree = await Milestone.create({
|
||||
type: 'arr',
|
||||
value: 1000,
|
||||
currency: 'aud',
|
||||
currency: 'eur',
|
||||
createdAt: '2023-01-15T00:00:00Z',
|
||||
emailSentAt: '2023-01-15T00:00:00Z'
|
||||
});
|
||||
|
@ -94,24 +98,27 @@ describe('MilestonesEmailService', function () {
|
|||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
milestonesConfig,
|
||||
queries: {
|
||||
async getARR() {
|
||||
return [{currency: 'usd', arr: 50005}];
|
||||
// Same ARR values for both supported currencies
|
||||
return [{currency: 'usd', arr: 10001}, {currency: 'eur', arr: 10001}];
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
},
|
||||
async getDefaultCurrency() {
|
||||
return 'usd';
|
||||
}
|
||||
},
|
||||
defaultCurrency: 'usd'
|
||||
}
|
||||
});
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
assert(arrResult.type === 'arr');
|
||||
assert(arrResult.currency === 'usd');
|
||||
assert(arrResult.value === 50000);
|
||||
assert(arrResult.value === 10000);
|
||||
assert(arrResult.emailSentAt !== null);
|
||||
assert(arrResult.name === 'arr-50000-usd');
|
||||
assert(arrResult.name === 'arr-10000-usd');
|
||||
});
|
||||
|
||||
it('Does not add ARR milestone for out of scope currency', async function () {
|
||||
|
@ -123,16 +130,18 @@ describe('MilestonesEmailService', function () {
|
|||
mailer: {
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
milestonesConfig,
|
||||
queries: {
|
||||
async getARR() {
|
||||
return [{currency: 'nzd', arr: 1005}];
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
},
|
||||
async getDefaultCurrency() {
|
||||
return 'nzd';
|
||||
}
|
||||
},
|
||||
defaultCurrency: 'nzd'
|
||||
}
|
||||
});
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
|
@ -156,16 +165,18 @@ describe('MilestonesEmailService', function () {
|
|||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
milestonesConfig,
|
||||
queries: {
|
||||
async getARR() {
|
||||
return [{currency: 'gbp', arr: 5005}];
|
||||
return [{currency: 'gbp', arr: 5005}, {currency: 'usd', arr: 100}];
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
},
|
||||
async getDefaultCurrency() {
|
||||
return 'gbp';
|
||||
}
|
||||
},
|
||||
defaultCurrency: 'gbp'
|
||||
}
|
||||
});
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
|
@ -181,16 +192,18 @@ describe('MilestonesEmailService', function () {
|
|||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
milestonesConfig,
|
||||
queries: {
|
||||
async getARR() {
|
||||
return [{currency: 'usd', arr: 100000}, {currency: 'idr', arr: 2600}];
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return true;
|
||||
},
|
||||
async getDefaultCurrency() {
|
||||
return 'usd';
|
||||
}
|
||||
},
|
||||
defaultCurrency: 'usd'
|
||||
}
|
||||
});
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
|
@ -221,16 +234,18 @@ describe('MilestonesEmailService', function () {
|
|||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
milestonesConfig,
|
||||
queries: {
|
||||
async getARR() {
|
||||
return [{currency: 'idr', arr: 10000}];
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return true;
|
||||
},
|
||||
async getDefaultCurrency() {
|
||||
return 'idr';
|
||||
}
|
||||
},
|
||||
defaultCurrency: 'idr'
|
||||
}
|
||||
});
|
||||
|
||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||
|
@ -251,13 +266,16 @@ describe('MilestonesEmailService', function () {
|
|||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
milestonesConfig,
|
||||
queries: {
|
||||
async getMembersCount() {
|
||||
return 110;
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
},
|
||||
async getDefaultCurrency() {
|
||||
return 'usd';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -302,16 +320,18 @@ describe('MilestonesEmailService', function () {
|
|||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
milestonesConfig,
|
||||
queries: {
|
||||
async getMembersCount() {
|
||||
return 50005;
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
},
|
||||
async getDefaultCurrency() {
|
||||
return 'usd';
|
||||
}
|
||||
},
|
||||
defaultCurrency: 'usd'
|
||||
}
|
||||
});
|
||||
|
||||
const membersResult = await milestoneEmailService.checkMilestones('members');
|
||||
|
@ -338,13 +358,16 @@ describe('MilestonesEmailService', function () {
|
|||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
milestonesConfig,
|
||||
queries: {
|
||||
async getMembersCount() {
|
||||
return 50555;
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
},
|
||||
async getDefaultCurrency() {
|
||||
return 'usd';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -369,13 +392,16 @@ describe('MilestonesEmailService', function () {
|
|||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
milestonesConfig,
|
||||
queries: {
|
||||
async getMembersCount() {
|
||||
return 1001;
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return true;
|
||||
},
|
||||
async getDefaultCurrency() {
|
||||
return 'usd';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -406,13 +432,16 @@ describe('MilestonesEmailService', function () {
|
|||
// TODO: make this a stub
|
||||
send: async () => {}
|
||||
},
|
||||
config: milestoneConfig,
|
||||
milestonesConfig,
|
||||
queries: {
|
||||
async getMembersCount() {
|
||||
return 50010;
|
||||
},
|
||||
async hasImportedMembersInPeriod() {
|
||||
return false;
|
||||
},
|
||||
async getDefaultCurrency() {
|
||||
return 'usd';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue