diff --git a/ghost/core/core/server/models/milestone.js b/ghost/core/core/server/models/milestone.js new file mode 100644 index 0000000000..94218b3815 --- /dev/null +++ b/ghost/core/core/server/models/milestone.js @@ -0,0 +1,9 @@ +const ghostBookshelf = require('./base'); + +const Milestone = ghostBookshelf.Model.extend({ + tableName: 'milestones' +}); + +module.exports = { + Milestone: ghostBookshelf.model('Milestone', Milestone) +}; diff --git a/ghost/core/core/server/services/milestones/BookshelfMilestoneRepository.js b/ghost/core/core/server/services/milestones/BookshelfMilestoneRepository.js new file mode 100644 index 0000000000..4ae14c8173 --- /dev/null +++ b/ghost/core/core/server/services/milestones/BookshelfMilestoneRepository.js @@ -0,0 +1,136 @@ +const {Milestone} = require('@tryghost/milestones'); + +/** + * @typedef {import('@tryghost/milestones/lib/MilestonesService').IMilestoneRepository} IMilestoneRepository + * @typedef {import('@tryghost/milestones/lib/MilestonesService')} Milestone + */ + +/** + * @implements {IMilestoneRepository} + */ +module.exports = class BookshelfMilestoneRepository { + /** @type {Object} */ + #MilestoneModel; + + /** @type {import('@tryghost/domain-events')} */ + #DomainEvents; + + /** + * @param {object} deps + * @param {object} deps.MilestoneModel Bookshelf Model + * @param {import('@tryghost/domain-events')} deps.DomainEvents + */ + constructor(deps) { + this.#MilestoneModel = deps.MilestoneModel; + this.#DomainEvents = deps.DomainEvents; + } + + #modelToMilestone(model) { + return Milestone.create({ + id: model.get('id'), + type: model.get('type'), + value: model.get('value'), + currency: model.get('currency'), + createdAt: model.get('created_at'), + emailSentAt: model.get('email_sent_at') + }); + } + + /** + * @param {import('@tryghost/milestones/lib/Milestone')} milestone + * @returns {Promise} + */ + async save(milestone) { + const data = { + id: milestone.id.toHexString(), + type: milestone.type, + value: milestone.value, + currency: milestone?.currency, + created_at: milestone?.createdAt, + email_sent_at: milestone?.emailSentAt + }; + + const existing = await this.#MilestoneModel.findOne({id: data.id}, {require: false}); + + if (!existing) { + await this.#MilestoneModel.add(data); + } else { + await this.#MilestoneModel.edit(data, { + id: data.id + }); + } + for (const event of milestone.events) { + this.#DomainEvents.dispatch(event); + } + } + + /** + * @param {'arr'|'members'} type + * @param {string} [currency] + * + * @returns {Promise} + */ + async getLatestByType(type, currency = 'usd') { + let milestone = null; + + if (type === 'arr') { + milestone = await this.#MilestoneModel.findAll({filter: `currency:${currency}+type:arr`, order: 'created_at ASC, value DESC'}, {require: false}); + } else { + milestone = await this.#MilestoneModel.findAll({filter: 'type:members', order: 'created_at ASC, value DESC'}, {require: false}); + } + + if (!milestone || !milestone?.models?.length) { + return null; + } else { + milestone = milestone.models?.[0]; + } + + return this.#modelToMilestone(milestone); + } + + /** + * @returns {Promise} + */ + async getLastEmailSent() { + let milestone = await this.#MilestoneModel.findAll({filter: 'email_sent_at:-null', order: 'email_sent_at ASC'}, {require: false}); + + if (!milestone || !milestone?.models?.length) { + return null; + } else { + milestone = milestone.models?.[0]; + } + + return this.#modelToMilestone(milestone); + } + + /** + * @param {number} value + * @param {string} [currency] + * + * @returns {Promise} + */ + async getByARR(value, currency = 'usd') { + // find a milestone of the ARR type by a given value + const milestone = await this.#MilestoneModel.findOne({type: 'arr', currency: currency, value: value}, {require: false}); + + if (!milestone) { + return null; + } + return this.#modelToMilestone(milestone); + } + + /** + * @param {number} value + * + * @returns {Promise} + */ + async getByCount(value) { + // find a milestone of the members type by a given value + const milestone = await this.#MilestoneModel.findOne({type: 'members', value: value}, {require: false}); + + if (!milestone) { + return null; + } + return this.#modelToMilestone(milestone); + } +}; diff --git a/ghost/core/core/server/services/milestones/service.js b/ghost/core/core/server/services/milestones/service.js index 7ae37d0fc9..bfe676ddcc 100644 --- a/ghost/core/core/server/services/milestones/service.js +++ b/ghost/core/core/server/services/milestones/service.js @@ -1,5 +1,7 @@ const DomainEvents = require('@tryghost/domain-events'); const logging = require('@tryghost/logging'); +const models = require('../../models'); +const BookshelfMilestoneRepository = require('./BookshelfMilestoneRepository'); const JOB_TIMEOUT = 1000 * 60 * 60 * 24 * (Math.floor(Math.random() * 4)); // 0 - 4 days; @@ -31,14 +33,15 @@ module.exports = { const db = require('../../data/db'); const MilestoneQueries = require('./MilestoneQueries'); - const { - MilestonesService, - InMemoryMilestoneRepository - } = require('@tryghost/milestones'); + const {MilestonesService} = require('@tryghost/milestones'); const config = require('../../../shared/config'); const milestonesConfig = config.get('milestones'); - const repository = new InMemoryMilestoneRepository({DomainEvents}); + const repository = new BookshelfMilestoneRepository({ + DomainEvents, + MilestoneModel: models.Milestone + }); + const queries = new MilestoneQueries({db}); this.api = new MilestonesService({ diff --git a/ghost/core/test/unit/server/models/milestone.test.js b/ghost/core/test/unit/server/models/milestone.test.js new file mode 100644 index 0000000000..a6df26f9d7 --- /dev/null +++ b/ghost/core/test/unit/server/models/milestone.test.js @@ -0,0 +1,27 @@ +const models = require('../../../../core/server/models'); +const assert = require('assert'); +const errors = require('@tryghost/errors'); + +describe('Unit: models/milestone', function () { + before(function () { + models.init(); + }); + + describe('validation', function () { + describe('blank', function () { + it('throws validation error for mandatory fields', function () { + return models.Milestone.add({}) + .then(function () { + throw new Error('expected ValidationError'); + }) + .catch(function (err) { + assert.equal(err.length, 2); + assert.equal((err[0] instanceof errors.ValidationError), true); + assert.equal((err[1] instanceof errors.ValidationError), true); + assert.match(err[0].message,/milestones\.type/); + assert.match(err[1].message,/milestones\.value/); + }); + }); + }); + }); +}); diff --git a/ghost/core/test/unit/server/services/milestones/BookshelfMilestoneRepository.test.js b/ghost/core/test/unit/server/services/milestones/BookshelfMilestoneRepository.test.js new file mode 100644 index 0000000000..01a3b24a40 --- /dev/null +++ b/ghost/core/test/unit/server/services/milestones/BookshelfMilestoneRepository.test.js @@ -0,0 +1,22 @@ +const assert = require('assert'); + +const models = require('../../../../../core/server/models'); +const DomainEvents = require('@tryghost/domain-events'); + +describe('BookshelfMilestoneRepository', function () { + let repository; + + it('Provides expected public API', async function () { + const BookshelfMilestoneRepository = require('../../../../../core/server/services/milestones/BookshelfMilestoneRepository'); + repository = new BookshelfMilestoneRepository({ + DomainEvents, + MilestoneModel: models.Milestone + }); + + assert.ok(repository.save); + assert.ok(repository.getLatestByType); + assert.ok(repository.getLastEmailSent); + assert.ok(repository.getByARR); + assert.ok(repository.getByCount); + }); +}); diff --git a/ghost/milestones/lib/MilestonesService.js b/ghost/milestones/lib/MilestonesService.js index 9fe378f366..f08cbc1e4c 100644 --- a/ghost/milestones/lib/MilestonesService.js +++ b/ghost/milestones/lib/MilestonesService.js @@ -105,7 +105,6 @@ module.exports = class MilestonesService { const newMilestone = await Milestone.create(milestone); await this.#repository.save(newMilestone); - return newMilestone; } @@ -201,13 +200,13 @@ module.exports = class MilestonesService { // 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(defaultCurrency); - - // Ensure the milestone doesn't already exist - const milestoneExists = await this.#checkMilestoneExists({value: milestone, type: 'arr', currency: defaultCurrency}); - if (milestone && milestone > 0) { + // Fetch the latest milestone for this currency + const latestMilestone = await this.#getLatestArrMilestone(defaultCurrency); + + // Ensure the milestone doesn't already exist + const milestoneExists = await this.#checkMilestoneExists({value: milestone, type: 'arr', currency: defaultCurrency}); + if (!milestoneExists && (!latestMilestone || milestone > latestMilestone.value)) { const meta = { currentARR: currentARRForCurrency.arr @@ -232,13 +231,13 @@ module.exports = class MilestonesService { // get the closest milestone we're over now 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', currency: null}); - if (milestone && milestone > 0) { + // 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', currency: null}); + if (!milestoneExists && (!latestMembersMilestone || milestone > latestMembersMilestone.value)) { const meta = { currentMembers: membersCount diff --git a/ghost/milestones/lib/milestones.js b/ghost/milestones/lib/milestones.js index 9107e32aea..f9d72f2e9a 100644 --- a/ghost/milestones/lib/milestones.js +++ b/ghost/milestones/lib/milestones.js @@ -1,3 +1,4 @@ module.exports.InMemoryMilestoneRepository = require('./InMemoryMilestoneRepository'); module.exports.MilestonesService = require('./MilestonesService'); module.exports.MilestoneCreatedEvent = require('./MilestoneCreatedEvent'); +module.exports.Milestone = require('./Milestone');