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"