From 3b6759ca6d4d282aa4c7d0e91b961b6d5384d39a Mon Sep 17 00:00:00 2001 From: Aileen Booker Date: Tue, 7 Feb 2023 12:47:35 +0200 Subject: [PATCH] Added initial basic milestone emails in-memory repository (#16216) refs https://www.notion.so/ghost/Marketing-Milestone-email-campaigns-1d2c9dee3cfa4029863edb16092ad5c4 This adds a milestone entity and in-memory repository in a new `milestone-emails` package. This also adds a first initial definition of milestones and their types which is held in the default config to avoid DB changes when, e. g. values change. This should get everything in place to begin with the service implementation. --- ghost/core/core/shared/config/defaults.json | 12 +- ghost/milestone-emails/.eslintrc.js | 6 + ghost/milestone-emails/README.md | 23 + ghost/milestone-emails/index.js | 1 + .../lib/InMemoryMilestoneRepository.js | 93 ++++ ghost/milestone-emails/lib/Milestone.js | 226 ++++++++++ .../lib/MilestonesEmailService.js | 254 +++++++++++ .../milestone-emails/lib/milestone-emails.js | 2 + ghost/milestone-emails/package.json | 29 ++ ghost/milestone-emails/test/.eslintrc.js | 6 + .../test/InMemoryMilestoneRepository.test.js | 127 ++++++ ghost/milestone-emails/test/Milestone.test.js | 114 +++++ .../test/MilestonesEmailService.test.js | 426 ++++++++++++++++++ yarn.lock | 16 +- 14 files changed, 1326 insertions(+), 9 deletions(-) create mode 100644 ghost/milestone-emails/.eslintrc.js create mode 100644 ghost/milestone-emails/README.md create mode 100644 ghost/milestone-emails/index.js create mode 100644 ghost/milestone-emails/lib/InMemoryMilestoneRepository.js create mode 100644 ghost/milestone-emails/lib/Milestone.js create mode 100644 ghost/milestone-emails/lib/MilestonesEmailService.js create mode 100644 ghost/milestone-emails/lib/milestone-emails.js create mode 100644 ghost/milestone-emails/package.json create mode 100644 ghost/milestone-emails/test/.eslintrc.js create mode 100644 ghost/milestone-emails/test/InMemoryMilestoneRepository.test.js create mode 100644 ghost/milestone-emails/test/Milestone.test.js create mode 100644 ghost/milestone-emails/test/MilestonesEmailService.test.js diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json index 2d1770c54f..01ad634a07 100644 --- a/ghost/core/core/shared/config/defaults.json +++ b/ghost/core/core/shared/config/defaults.json @@ -198,5 +198,15 @@ }, "gravatar": { "url": "https://www.gravatar.com/avatar/{hash}?s={size}&r={rating}&d={_default}" - } + }, + "milestones": + { + "arr": [ + { + "currency": "usd", + "values": [1000, 10000, 50000, 100000, 250000, 500000, 1000000] + } + ], + "members": [100, 1000, 10000, 50000, 100000, 250000, 500000, 1000000] + } } diff --git a/ghost/milestone-emails/.eslintrc.js b/ghost/milestone-emails/.eslintrc.js new file mode 100644 index 0000000000..c9c1bcb522 --- /dev/null +++ b/ghost/milestone-emails/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node' + ] +}; diff --git a/ghost/milestone-emails/README.md b/ghost/milestone-emails/README.md new file mode 100644 index 0000000000..d390692e45 --- /dev/null +++ b/ghost/milestone-emails/README.md @@ -0,0 +1,23 @@ +# Milestone Emails + +Checking milestone goals and sending emails + + +## Usage + + +## Develop + +This is a monorepo package. + +Follow the instructions for the top-level repo. +1. `git clone` this repo & `cd` into it as usual +2. Run `yarn` to install top-level dependencies. + + + +## Test + +- `yarn lint` run just eslint +- `yarn test` run lint and tests + diff --git a/ghost/milestone-emails/index.js b/ghost/milestone-emails/index.js new file mode 100644 index 0000000000..d9806ad66d --- /dev/null +++ b/ghost/milestone-emails/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/milestone-emails'); diff --git a/ghost/milestone-emails/lib/InMemoryMilestoneRepository.js b/ghost/milestone-emails/lib/InMemoryMilestoneRepository.js new file mode 100644 index 0000000000..63ebb1d631 --- /dev/null +++ b/ghost/milestone-emails/lib/InMemoryMilestoneRepository.js @@ -0,0 +1,93 @@ +/** + * @typedef {import('./Milestone')} Milestone + * @typedef {import('./MilestonesAPI').IMilestoneRepository} IMilestoneRepository + */ + +/** + * @implements {IMilestoneRepository} + */ +module.exports = class InMemoryMilestoneRepository { + /** @type {Milestone[]} */ + #store = []; + + /** @type {Object.} */ + #ids = {}; + + /** + * @param {Milestone} milestone + * + * @returns {Promise} + */ + async save(milestone) { + if (this.#ids[milestone.id.toHexString()]) { + const existingIndex = this.#store.findIndex((item) => { + return item.id.equals(milestone.id); + }); + this.#store.splice(existingIndex, 1, milestone); + } else { + this.#store.push(milestone); + this.#ids[milestone.id.toHexString()] = true; + } + } + + /** + * @param {'arr'|'members'} type + * @param {string|null} currency + * + * @returns {Promise} + */ + 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]; + } + } + + /** + * @returns {Promise} + */ + async getLastEmailSent() { + return this.#store + .filter(item => item.emailSentAt) + // sort by emailSentAt desc + .sort((a, b) => (b.emailSentAt.valueOf() - a.emailSentAt.valueOf())) + // if we end up with more values with the same datetime, pick the highest value + .sort((a, b) => b.value - a.value)[0]; + } + + /** + * @param {number} value + * @param {string} currency + * + * @returns {Promise} + */ + async getByARR(value, currency = 'usd') { + // find a milestone of the ARR type by a given value + return this.#store.find((item) => { + return item.value === value && item.type === 'arr' && item.currency === currency; + }); + } + + /** + * @param {number} value + * + * @returns {Promise} + */ + async getByCount(value) { + // find a milestone of the members type by a given value + return this.#store.find((item) => { + return item.value === value && item.type === 'members'; + }); + } +}; diff --git a/ghost/milestone-emails/lib/Milestone.js b/ghost/milestone-emails/lib/Milestone.js new file mode 100644 index 0000000000..4617cd8e8f --- /dev/null +++ b/ghost/milestone-emails/lib/Milestone.js @@ -0,0 +1,226 @@ +const ObjectID = require('bson-objectid').default; +const {ValidationError} = require('@tryghost/errors'); + +module.exports = class Milestone { + /** + * @type {ObjectID} + */ + #id; + get id() { + return this.#id; + } + + /** + * @type {'arr'|'members'} + */ + #type; + get type() { + return this.#type; + } + + /** @type {number} */ + #value; + get value() { + return this.#value; + } + + /** @type {string} */ + #currency; + get currency() { + return this.#currency; + } + + /** @type {Date} */ + #createdAt; + get createdAt() { + return this.#createdAt; + } + + /** @type {Date|null} */ + #emailSentAt; + get emailSentAt() { + return this.#emailSentAt; + } + + toJSON() { + return { + id: this.id, + name: this.name, + type: this.type, + value: this.value, + currency: this.currency, + createdAt: this.createdAt, + emailSentAt: this.emailSentAt + }; + } + + /** @private */ + constructor(data) { + this.#id = data.id; + this.#type = data.type; + this.#value = data.value; + this.#currency = data.currency; + this.#createdAt = data.createdAt; + this.#emailSentAt = data.emailSentAt; + } + + /** + * @returns {string} + */ + get name() { + if (this.type === 'arr') { + return `arr-${this.value}-${this.currency}`; + } + return `members-${this.value}`; + } + + /** + * @param {any} data + * @returns {Promise} + */ + static async create(data) { + // order of validation matters! + const id = validateId(data.id); + const type = validateType(data.type); + const currency = validateCurrency(type, data?.currency); + const value = validateValue(data.value); + const name = validateName(data.name, value, type, currency); + const emailSentAt = validateEmailSentAt(data); + + /** @type Date */ + let createdAt; + if (data.createdAt instanceof Date) { + createdAt = data.createdAt; + } else if (data.createdAt) { + createdAt = new Date(data.createdAt); + if (isNaN(createdAt.valueOf())) { + throw new ValidationError({ + message: 'Invalid Date' + }); + } + } else { + createdAt = new Date(); + } + + return new Milestone({ + id, + name, + type, + value, + currency, + createdAt, + emailSentAt + }); + } +}; + +/** + * + * @param {ObjectID|string|null} id + * + * @returns {ObjectID} + */ +function validateId(id) { + if (!id) { + return new ObjectID(); + } + if (typeof id === 'string') { + return ObjectID.createFromHexString(id); + } + if (id instanceof ObjectID) { + return id; + } + return new ObjectID(); +} + +/** + * + * @param {number|null} value + * + * @returns {number} + */ +function validateValue(value) { + if (!value || typeof value !== 'number' || value === 0) { + throw new ValidationError({ + message: 'Invalid value' + }); + } + + return value; +} + +/** + * + * @param {'arr'|'members'} type + * + * @returns {string} + */ +function validateType(type) { + if (type === 'arr') { + return 'arr'; + } + + return 'members'; +} + +/** + * + * @param {'arr'|'members'} type + * @param {string|null} currency + * + * @returns {string} + */ +function validateCurrency(type, currency) { + if (type === 'members') { + return null; + } + + if (!currency || (currency && typeof currency !== 'string' || currency.length > 3)) { + return 'usd'; + } + + return currency; +} + +/** + * + * @param {string} name + * @param {number} value + * @param {'arr'|'members'} type + * @param {string|null} currency + * + * @returns {string} + */ +function validateName(name, value, type, currency) { + if (!name || !name.match(/(arr|members)-\d*/i)) { + return type === 'arr' ? `${type}-${value}-${currency}` : `${type}-${value}`; + } + + return name; +} + +/** + * + * @param {Object} data + * @param {Date|null} data.emailSentAt + * + * @returns {Date|null} + */ +function validateEmailSentAt(data) { + let emailSentAt; + if (data.emailSentAt instanceof Date) { + emailSentAt = data.emailSentAt; + } else if (data.emailSentAt) { + emailSentAt = new Date(data.emailSentAt); + if (isNaN(emailSentAt.valueOf())) { + throw new ValidationError({ + message: 'Invalid Date' + }); + } + } else { + emailSentAt = null; + } + + return emailSentAt; +} + diff --git a/ghost/milestone-emails/lib/MilestonesEmailService.js b/ghost/milestone-emails/lib/MilestonesEmailService.js new file mode 100644 index 0000000000..1e5f24356d --- /dev/null +++ b/ghost/milestone-emails/lib/MilestonesEmailService.js @@ -0,0 +1,254 @@ +const Milestone = require('./Milestone'); + +/** + * @template Model + * @typedef {object} Mention + * @prop {Model[]} data + */ + +/** + * @typedef {object} IMilestoneRepository + * @prop {(milestone: Milestone) => Promise} save + * @prop {(arr: number) => Promise} getByARR + * @prop {(count: number) => Promise} getByCount + * @prop {(type: 'arr'|'members') => Promise} getLatestByType + * @prop {() => Promise} getLastEmailSent + */ + +/** + * @template Model + * @typedef {import('./MilestonesAPI')} + */ + +/** + * @typedef {Object} IQueries + * @prop {() => Promise} getMembersCount + * @prop {() => Promise} getARR + * @prop {() => Promise} hasImportedMembersInPeriod + */ + +module.exports = class MilestonesEmailService { + /** @type {IMilestoneRepository} */ + #repository; + + /** @type {Function} */ + #mailer; + + /** @type {Object} */ + #config; + + /** @type {IQueries} */ + #queries; + + /** @type {string} */ + #defaultCurrency; + + /** + * @param {object} deps + * @param {Function} deps.mailer + * @param {MilestonesAPI} deps.api + * @param {Object} deps.config + * @param {IQueries} deps.queries + * @param {string} deps.defaultCurrency + */ + constructor(deps) { + this.#mailer = deps.mailer; + this.#config = deps.config; + this.#queries = deps.queries; + this.#defaultCurrency = deps.defaultCurrency; + this.#repository = deps.repository; + } + + /** + * @param {string|null} currency + * + * @returns {Promise} + */ + async #getLatestArrMilestone(currency = 'usd') { + return this.#repository.getLatestByType('arr', currency); + } + + /** + * @returns {Promise} + */ + async #getLatestMembersCountMilestone() { + return this.#repository.getLatestByType('members'); + } + + /** + * @param {object} milestone + * @param {'arr'|'members'} milestone.type + * @param {number} milestone.value + * @param {string} milestone.currency + * + * @returns {Promise} + */ + async #checkMilestoneExists(milestone) { + let foundExistingMilestone = false; + let existingMilestone = null; + + if (milestone.type === 'arr') { + existingMilestone = await this.#repository.getByARR(milestone.value, milestone?.currency) || false; + } else if (milestone.type === 'members') { + existingMilestone = await this.#repository.getByCount(milestone.value) || false; + } + + foundExistingMilestone = existingMilestone ? true : false; + + return foundExistingMilestone; + } + + /** + * @param {object} milestone + * @param {'arr'|'members'} milestone.type + * @param {number} milestone.value + * + * @returns {Promise} + */ + async #createMilestone(milestone) { + const newMilestone = await Milestone.create(milestone); + + await this.#repository.save(newMilestone); + + return newMilestone; + } + + /** + * + * @param {Array} goalValues + * @param {number} current + * + * @returns {Array} + */ + #getMatchedMilestone(goalValues, current) { + // return highest suitable milestone + return goalValues.filter(value => current >= value) + .sort((a, b) => b - a)[0]; + } + + /** + * + * @param {Object} milestone + * @param {number} milestone.value + * @param {'arr'|'members'} milestone.type + * @param {boolean} hasMembersImported + * + * @returns {Promise} + */ + 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 + // await this.#mailer.send({ + // subject: 'Test', + // html: '
Milestone achieved
', + // to: 'test@example.com' + // }); + + milestone.emailSentAt = new Date(); + } + + return await this.#createMilestone(milestone); + } + + /** + * + * @returns {Promise} + */ + async #shouldSendEmail() { + let shouldSendEmail; + // Two cases in which we don't want to send an email + // 1. There has been an import of members within the last week + // 2. The last email has been sent less than two weeks ago + const lastMilestoneSent = await this.#repository.getLastEmailSent(); + + if (!lastMilestoneSent) { + shouldSendEmail = true; + } else { + const differenceInTime = new Date().getTime() - new Date(lastMilestoneSent.emailSentAt).getTime(); + const differenceInDays = differenceInTime / (1000 * 3600 * 24); + + shouldSendEmail = differenceInDays >= 14; + } + + const hasMembersImported = await this.#queries.hasImportedMembersInPeriod(); + + return shouldSendEmail && !hasMembersImported; + } + + /** + * @returns {Promise} + */ + async #runARRQueries() { + // Fetch the current data from queries + const currentARR = await this.#queries.getARR(); + + // Check the definitions in the config + const arrMilestoneSettings = this.#config.milestones.arr; + + // 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]; + + 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); + + // Ensure the milestone doesn't already exist + const milestoneExists = await this.#checkMilestoneExists({value: milestone, type: 'arr', currency: this.#defaultCurrency}); + + if ((!milestoneExists && !latestMilestone || milestone > latestMilestone.value)) { + return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'arr'}); + } + } + } + } + + /** + * @returns {Promise} + */ + async #runMemberQueries() { + // Fetch the current data + const membersCount = await this.#queries.getMembersCount(); + + // Check the definitions in the config + const membersMilestones = this.#config.milestones.members; + + // get the closest milestone we're over now + const 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'}); + + if ((!milestoneExists && !latestMembersMilestone || milestone > latestMembersMilestone.value)) { + return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'members'}); + } + } + + /** + * @param {'arr'|'members'} type + * + * @returns {Promise} + */ + async checkMilestones(type) { + if (type === 'arr') { + return await this.#runARRQueries(); + } + + return await this.#runMemberQueries(); + } +}; diff --git a/ghost/milestone-emails/lib/milestone-emails.js b/ghost/milestone-emails/lib/milestone-emails.js new file mode 100644 index 0000000000..eb85b9bc48 --- /dev/null +++ b/ghost/milestone-emails/lib/milestone-emails.js @@ -0,0 +1,2 @@ +module.exports.InMemoryMilestoneRepository = require('./InMemoryMilestoneRepository'); +module.exports.MilestonesEmailService = require('./MilestonesEmailService'); diff --git a/ghost/milestone-emails/package.json b/ghost/milestone-emails/package.json new file mode 100644 index 0000000000..284455549b --- /dev/null +++ b/ghost/milestone-emails/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tryghost/milestone-emails", + "version": "0.0.0", + "repository": "https://github.com/TryGhost/Ghost/tree/main/packages/milestone-emails", + "author": "Ghost Foundation", + "private": true, + "main": "index.js", + "scripts": { + "dev": "echo \"Implement me!\"", + "test:unit": "NODE_ENV=testing c8 --all --check-coverage --100 --reporter text --reporter cobertura mocha './test/**/*.test.js'", + "test": "yarn test:unit", + "lint:code": "eslint *.js lib/ --ext .js --cache", + "lint": "yarn lint:code && yarn lint:test", + "lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache" + }, + "files": [ + "index.js", + "lib" + ], + "devDependencies": { + "c8": "7.12.0", + "mocha": "10.2.0", + "sinon": "15.0.1" + }, + "dependencies": { + "@tryghost/errors": "1.2.20", + "bson-objectid": "2.0.4" + } +} diff --git a/ghost/milestone-emails/test/.eslintrc.js b/ghost/milestone-emails/test/.eslintrc.js new file mode 100644 index 0000000000..829b601eb0 --- /dev/null +++ b/ghost/milestone-emails/test/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ] +}; diff --git a/ghost/milestone-emails/test/InMemoryMilestoneRepository.test.js b/ghost/milestone-emails/test/InMemoryMilestoneRepository.test.js new file mode 100644 index 0000000000..b7eae86c01 --- /dev/null +++ b/ghost/milestone-emails/test/InMemoryMilestoneRepository.test.js @@ -0,0 +1,127 @@ +const assert = require('assert'); +const ObjectID = require('bson-objectid'); +const InMemoryMilestoneRepository = require('../lib/InMemoryMilestoneRepository'); +const Milestone = require('../lib/Milestone'); + +describe('InMemoryMilestoneRepository', function () { + let repository; + + before(async function () { + const resourceId = new ObjectID(); + repository = new InMemoryMilestoneRepository(); + const milestoneCreatePromises = []; + + const validInputs = [ + { + type: 'arr', + value: 20000, + createdAt: '2023-01-01T00:00:00Z', + id: resourceId + }, + { + type: 'arr', + value: 1000, + createdAt: '2023-01-01T00:00:00Z', + currency: 'gbp' + }, + { + type: 'arr', + value: 2000, + createdAt: '2023-01-30T00:00:00Z', + currency: 'gbp' + }, + { + type: 'arr', + value: 50000, + createdAt: '2023-02-01T01:00:00Z', + emailSentAt: '2023-02-01T01:00:00Z', + currency: 'usd' + }, + { + type: 'arr', + value: 60000, + createdAt: '2023-02-01T01:00:00Z', + emailSentAt: '2023-02-01T01:00:00Z', + currency: 'usd' + }, + { + type: 'members', + value: 100, + createdAt: '2023-01-01T00:00:00Z', + emailSentAt: '2023-01-01T00:00:00Z', + id: resourceId + }, + { + type: 'members', + value: 500, + createdAt: '2023-02-01T00:00:00Z', + emailSentAt: '2023-02-01T00:00:00Z' + }, + { + type: 'members', + value: 600, + createdAt: '2023-02-01T00:00:00Z', + emailSentAt: '2023-02-01T00:00:00Z' + } + ]; + + validInputs.forEach(validInput => milestoneCreatePromises.push(Milestone.create(validInput))); + + const milestones = await Promise.all(milestoneCreatePromises); + + for (const milestone of milestones) { + await repository.save(milestone); + } + }); + + it('Can return the latest milestone for members', async function () { + const latestMemberCountMilestone = await repository.getLatestByType('members'); + const timeDiff = new Date(latestMemberCountMilestone.createdAt) - new Date('2023-02-01T00:00:00.000Z'); + assert(timeDiff === 0); + assert(latestMemberCountMilestone.type === 'members'); + assert(latestMemberCountMilestone.value === 600); + }); + + it('Can return the latest milestone for ARR', async function () { + const latestArrMilestone = await repository.getLatestByType('arr'); + const timeDiff = new Date(latestArrMilestone.createdAt) - new Date('2023-02-01T01:00:00Z'); + assert(timeDiff === 0); + assert(latestArrMilestone.value === 60000); + assert(latestArrMilestone.type = 'arr'); + assert(latestArrMilestone.currency === 'usd'); + }); + + it('Can return the latest milestone for ARR for a specific currency', async function () { + const latestArrMilestone = await repository.getLatestByType('arr', 'gbp'); + const timeDiff = new Date(latestArrMilestone.createdAt) - new Date('2023-01-30T00:00:00Z'); + assert(timeDiff === 0); + assert(latestArrMilestone.value === 2000); + assert(latestArrMilestone.type = 'arr'); + assert(latestArrMilestone.currency === 'gbp'); + }); + + it('Can return the last sent email', async function () { + const lastEmailSentMilestone = await repository.getLastEmailSent(); + const timeDiff = new Date(lastEmailSentMilestone.emailSentAt) - new Date('2023-02-01T01:00:00Z'); + assert(timeDiff === 0); + }); + + it('Can return the ARR milestone for a given value', async function () { + const arrMilestoneForValue = await repository.getByARR(50000, 'usd'); + const timeDiff = new Date(arrMilestoneForValue.createdAt) - new Date('2023-02-01T01:00:00Z'); + assert(timeDiff === 0); + assert(arrMilestoneForValue.type === 'arr'); + assert(arrMilestoneForValue.value === 50000); + assert(arrMilestoneForValue.currency === 'usd'); + assert(arrMilestoneForValue.name === 'arr-50000-usd'); + }); + + it('Can return the Members count milestone for a given value', async function () { + const membersCountForValue = await repository.getByCount(100); + const timeDiff = new Date(membersCountForValue.createdAt) - new Date('2023-01-01T00:00:00Z'); + assert(timeDiff === 0); + assert(membersCountForValue.type === 'members'); + assert(membersCountForValue.value === 100); + assert(membersCountForValue.name === 'members-100'); + }); +}); diff --git a/ghost/milestone-emails/test/Milestone.test.js b/ghost/milestone-emails/test/Milestone.test.js new file mode 100644 index 0000000000..98bb739e33 --- /dev/null +++ b/ghost/milestone-emails/test/Milestone.test.js @@ -0,0 +1,114 @@ +const assert = require('assert'); +const ObjectID = require('bson-objectid'); +const Milestone = require('../lib/Milestone'); + +const validInputARR = { + type: 'arr', + value: 100 +}; + +const validInputMembers = { + type: 'members', + value: 300 +}; + +describe('Milestone', function () { + describe('toJSON', function () { + it('Returns an object with the expected properties', async function () { + const milestone = await Milestone.create(validInputARR); + const actual = Object.keys(milestone.toJSON()); + const expected = [ + 'id', + 'name', + 'type', + 'value', + 'currency', + 'createdAt', + 'emailSentAt' + ]; + assert.deepEqual(actual, expected); + }); + }); + + describe('create', function () { + it('Will error with invalid inputs', async function () { + const invalidInputs = [ + {id: 'Not valid ID'}, + {value: 'Invalid Value'}, + {createdAt: 'Invalid Date'}, + {emailSentAt: 'Invalid Date'} + ]; + + for (const invalidInput of invalidInputs) { + let errored = false; + try { + await Milestone.create({ + ...validInputARR, + ...invalidInput + }); + await Milestone.create({ + ...validInputMembers, + ...invalidInput + }); + } catch (err) { + errored = true; + } finally { + if (!errored) { + assert.fail(`Should have errored with invalid input ${JSON.stringify(invalidInput)}`); + } + } + } + }); + + it('Will not error with valid inputs', async function () { + const validInputs = [ + {id: new ObjectID()}, + {id: 123}, + {type: 'something'}, + {name: 'testing'}, + {name: 'members-10000000'}, + {createdAt: new Date()}, + {createdAt: '2023-01-01T00:00:00Z'}, + {emailSentAt: new Date()}, + {emailSentAt: '2023-01-01T00:00:00Z'}, + {emailSentAt: null}, + {currency: 'usd'}, + {currency: null}, + {currency: 1234}, + {currency: 'not-a-currency'} + ]; + + for (const localValidInput of validInputs) { + await Milestone.create({ + ...validInputARR, + ...localValidInput + }); + await Milestone.create({ + ...validInputMembers, + ...localValidInput + }); + } + }); + + it('Will generate a valid name for ARR milestone', async function () { + const milestone = await Milestone.create({ + ...validInputARR, + value: 500, + type: 'arr', + currency: 'aud' + }); + + assert(milestone.name === 'arr-500-aud'); + }); + + it('Will generate a valid name for Members milestone', async function () { + const milestone = await Milestone.create({ + ...validInputMembers, + value: 100, + type: 'members' + }); + + assert(milestone.name === 'members-100'); + }); + }); +}); diff --git a/ghost/milestone-emails/test/MilestonesEmailService.test.js b/ghost/milestone-emails/test/MilestonesEmailService.test.js new file mode 100644 index 0000000000..1095c1d16a --- /dev/null +++ b/ghost/milestone-emails/test/MilestonesEmailService.test.js @@ -0,0 +1,426 @@ +const assert = require('assert'); +const { + MilestonesEmailService, + InMemoryMilestoneRepository +} = require('../index'); +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] + } + }; + + describe('ARR Milestones', function () { + it('Adds first ARR milestone and sends email', async function () { + repository = new InMemoryMilestoneRepository(); + + const milestoneEmailService = new MilestonesEmailService({ + repository, + mailer: { + // TODO: make this a stub + send: async () => {} + }, + config: milestoneConfig, + queries: { + async getARR() { + return [{currency: 'usd', arr: 1298}, {currency: 'gbp', arr: 2600}]; + }, + async hasImportedMembersInPeriod() { + return false; + } + }, + defaultCurrency: 'usd' + }); + + const arrResult = await milestoneEmailService.checkMilestones('arr'); + assert(arrResult.type === 'arr'); + assert(arrResult.currency === 'usd'); + assert(arrResult.value === 1000); + assert(arrResult.emailSentAt !== null); + assert(arrResult.name === 'arr-1000-usd'); + }); + + it('Adds next ARR milestone and sends email', async function () { + repository = new InMemoryMilestoneRepository(); + + const milestoneOne = await Milestone.create({ + type: 'arr', + value: 1000, + createdAt: '2023-01-01T00:00:00Z', + emailSentAt: '2023-01-01T00:00:00Z' + }); + + const milestoneTwo = await Milestone.create({ + type: 'arr', + value: 500, + createdAt: '2023-01-02T00:00:00Z', + emailSentAt: '2023-01-02T00:00:00Z' + }); + + const milestoneThree = await Milestone.create({ + type: 'arr', + value: 1000, + currency: 'aud', + createdAt: '2023-01-15T00:00:00Z', + emailSentAt: '2023-01-15T00:00:00Z' + }); + + await repository.save(milestoneOne); + await repository.save(milestoneTwo); + await repository.save(milestoneThree); + + const milestoneEmailService = new MilestonesEmailService({ + repository, + mailer: { + // TODO: make this a stub + send: async () => {} + }, + config: milestoneConfig, + queries: { + async getARR() { + return [{currency: 'usd', arr: 50005}]; + }, + async hasImportedMembersInPeriod() { + return false; + } + }, + defaultCurrency: 'usd' + }); + + const arrResult = await milestoneEmailService.checkMilestones('arr'); + assert(arrResult.type === 'arr'); + assert(arrResult.currency === 'usd'); + assert(arrResult.value === 50000); + assert(arrResult.emailSentAt !== null); + assert(arrResult.name === 'arr-50000-usd'); + }); + + it('Does not add ARR milestone for out of scope currency', async function () { + repository = new InMemoryMilestoneRepository(); + + const milestoneEmailService = new MilestonesEmailService({ + repository, + // TODO: make this a stub + mailer: { + send: async () => {} + }, + config: milestoneConfig, + queries: { + async getARR() { + return [{currency: 'nzd', arr: 1005}]; + }, + async hasImportedMembersInPeriod() { + return false; + } + }, + defaultCurrency: 'nzd' + }); + + const arrResult = await milestoneEmailService.checkMilestones('arr'); + assert(arrResult === undefined); + }); + + it('Does not add new ARR milestone if already achieved', async function () { + repository = new InMemoryMilestoneRepository(); + + const milestone = await Milestone.create({ + type: 'arr', + value: 5000, + currency: 'gbp' + }); + + await repository.save(milestone); + + const milestoneEmailService = new MilestonesEmailService({ + repository, + mailer: { + // TODO: make this a stub + send: async () => {} + }, + config: milestoneConfig, + queries: { + async getARR() { + return [{currency: 'gbp', arr: 5005}]; + }, + async hasImportedMembersInPeriod() { + return false; + } + }, + defaultCurrency: 'gbp' + }); + + const arrResult = await milestoneEmailService.checkMilestones('arr'); + assert(arrResult === undefined); + }); + + it('Adds ARR milestone but does not send email if imported members are detected', async function () { + repository = new InMemoryMilestoneRepository(); + + const milestoneEmailService = new MilestonesEmailService({ + repository, + mailer: { + // TODO: make this a stub + send: async () => {} + }, + config: milestoneConfig, + queries: { + async getARR() { + return [{currency: 'usd', arr: 100000}, {currency: 'idr', arr: 2600}]; + }, + async hasImportedMembersInPeriod() { + return true; + } + }, + defaultCurrency: 'usd' + }); + + const arrResult = await milestoneEmailService.checkMilestones('arr'); + assert(arrResult.type === 'arr'); + assert(arrResult.currency === 'usd'); + assert(arrResult.value === 100000); + assert(arrResult.emailSentAt === null); + }); + + it('Adds ARR milestone but does not send email if last email was too recent', async function () { + repository = new InMemoryMilestoneRepository(); + + const lessThanTwoWeeksAgo = new Date(); + lessThanTwoWeeksAgo.setDate(lessThanTwoWeeksAgo.getDate() - 12); + + const milestone = await Milestone.create({ + type: 'arr', + value: 1000, + currency: 'idr', + emailSentAt: lessThanTwoWeeksAgo + }); + + await repository.save(milestone); + + const milestoneEmailService = new MilestonesEmailService({ + repository, + mailer: { + // TODO: make this a stub + send: async () => {} + }, + config: milestoneConfig, + queries: { + async getARR() { + return [{currency: 'idr', arr: 10000}]; + }, + async hasImportedMembersInPeriod() { + return true; + } + }, + defaultCurrency: 'idr' + }); + + const arrResult = await milestoneEmailService.checkMilestones('arr'); + assert(arrResult.type === 'arr'); + assert(arrResult.currency === 'idr'); + assert(arrResult.value === 10000); + assert(arrResult.emailSentAt === null); + }); + }); + + describe('Members Milestones', function () { + it('Adds first Members milestone and sends email', async function () { + repository = new InMemoryMilestoneRepository(); + + const milestoneEmailService = new MilestonesEmailService({ + repository, + mailer: { + // TODO: make this a stub + send: async () => {} + }, + config: milestoneConfig, + queries: { + async getMembersCount() { + return 110; + }, + async hasImportedMembersInPeriod() { + return false; + } + } + }); + + const membersResult = await milestoneEmailService.checkMilestones('members'); + assert(membersResult.type === 'members'); + assert(membersResult.value === 100); + assert(membersResult.emailSentAt !== null); + }); + + it('Adds next Members milestone and sends email', async function () { + repository = new InMemoryMilestoneRepository(); + + const milestoneOne = await Milestone.create({ + type: 'members', + value: 1000, + createdAt: '2023-01-01T00:00:00Z', + emailSentAt: '2023-01-01T00:00:00Z' + }); + + const milestoneTwo = await Milestone.create({ + type: 'members', + value: 500, + createdAt: '2023-01-02T00:00:00Z', + emailSentAt: '2023-01-02T00:00:00Z' + }); + + const milestoneThree = await Milestone.create({ + type: 'members', + value: 1000, + createdAt: '2023-01-15T00:00:00Z', + emailSentAt: '2023-01-15T00:00:00Z' + }); + + await repository.save(milestoneOne); + await repository.save(milestoneTwo); + await repository.save(milestoneThree); + + const milestoneEmailService = new MilestonesEmailService({ + repository, + mailer: { + // TODO: make this a stub + send: async () => {} + }, + config: milestoneConfig, + queries: { + async getMembersCount() { + return 50005; + }, + async hasImportedMembersInPeriod() { + return false; + } + }, + defaultCurrency: 'usd' + }); + + const membersResult = await milestoneEmailService.checkMilestones('members'); + assert(membersResult.type === 'members'); + assert(membersResult.currency === null); + assert(membersResult.value === 50000); + assert(membersResult.emailSentAt !== null); + assert(membersResult.name === 'members-50000'); + }); + + it('Does not add new Members milestone if already achieved', async function () { + repository = new InMemoryMilestoneRepository(); + + const milestone = await Milestone.create({ + type: 'members', + value: 50000 + }); + + await repository.save(milestone); + + const milestoneEmailService = new MilestonesEmailService({ + repository, + mailer: { + // TODO: make this a stub + send: async () => {} + }, + config: milestoneConfig, + queries: { + async getMembersCount() { + return 50555; + }, + async hasImportedMembersInPeriod() { + return false; + } + } + }); + + const membersResult = await milestoneEmailService.checkMilestones('members'); + assert(membersResult === undefined); + }); + + it('Adds Members milestone but does not send email if imported members are detected', async function () { + repository = new InMemoryMilestoneRepository(); + + const milestone = await Milestone.create({ + type: 'members', + value: 100 + }); + + await repository.save(milestone); + + const milestoneEmailService = new MilestonesEmailService({ + repository, + mailer: { + // TODO: make this a stub + send: async () => {} + }, + config: milestoneConfig, + queries: { + async getMembersCount() { + return 1001; + }, + async hasImportedMembersInPeriod() { + return true; + } + } + }); + + const membersResult = await milestoneEmailService.checkMilestones('members'); + assert(membersResult.type === 'members'); + assert(membersResult.value === 1000); + assert(membersResult.emailSentAt === null); + }); + + it('Adds Members milestone but does not send email if last email was too recent', async function () { + repository = new InMemoryMilestoneRepository(); + + const lessThanTwoWeeksAgo = new Date(); + lessThanTwoWeeksAgo.setDate(lessThanTwoWeeksAgo.getDate() - 8); + + const milestone = await Milestone.create({ + type: 'members', + value: 100, + emailSentAt: lessThanTwoWeeksAgo + }); + + await repository.save(milestone); + + const milestoneEmailService = new MilestonesEmailService({ + repository, + mailer: { + // TODO: make this a stub + send: async () => {} + }, + config: milestoneConfig, + queries: { + async getMembersCount() { + return 50010; + }, + async hasImportedMembersInPeriod() { + return false; + } + } + }); + + const membersResult = await milestoneEmailService.checkMilestones('members'); + assert(membersResult.type === 'members'); + assert(membersResult.value === 50000); + assert(membersResult.emailSentAt === null); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 4e1e3de8d9..036188f2d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1960,16 +1960,16 @@ tslib "^2.4.0" "@elastic/transport@^8.2.0": - version "8.2.0" - resolved "https://registry.yarnpkg.com/@elastic/transport/-/transport-8.2.0.tgz#f292cb79c918a36268dd853431e41f13544814ad" - integrity sha512-H/HmefMNQfLiBSVTmNExu2lYs5EzwipUnQB53WLr17RCTDaQX0oOLHcWpDsbKQSRhDAMPPzp5YZsZMJxuxPh7A== + version "8.3.1" + resolved "https://registry.yarnpkg.com/@elastic/transport/-/transport-8.3.1.tgz#e7569d7df35b03108ea7aa886113800245faa17f" + integrity sha512-jv/Yp2VLvv5tSMEOF8iGrtL2YsYHbpf4s+nDsItxUTLFTzuJGpnsB/xBlfsoT2kAYEnWHiSJuqrbRcpXEI/SEQ== dependencies: debug "^4.3.4" hpagent "^1.0.0" ms "^2.1.3" secure-json-parse "^2.4.0" tslib "^2.4.0" - undici "^5.1.1" + undici "^5.5.1" "@ember-data/adapter@3.24.0": version "3.24.0" @@ -26023,10 +26023,10 @@ underscore@~1.8.3: resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022" integrity sha512-5WsVTFcH1ut/kkhAaHf4PVgI8c7++GiVcpCGxPouI6ZVjsqPnSDf8h/8HtVqc0t4fzRXwnMK70EcZeAs3PIddg== -undici@^5.1.1: - version "5.11.0" - resolved "https://registry.yarnpkg.com/undici/-/undici-5.11.0.tgz#1db25f285821828fc09d3804b9e2e934ae86fc13" - integrity sha512-oWjWJHzFet0Ow4YZBkyiJwiK5vWqEYoH7BINzJAJOLedZ++JpAlCbUktW2GQ2DS2FpKmxD/JMtWUUWl1BtghGw== +undici@^5.5.1: + version "5.16.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.16.0.tgz#6b64f9b890de85489ac6332bd45ca67e4f7d9943" + integrity sha512-KWBOXNv6VX+oJQhchXieUznEmnJMqgXMbs0xxH2t8q/FUAWSJvOSr/rMaZKnX5RIVq7JDn0JbP4BOnKG2SGXLQ== dependencies: busboy "^1.6.0"