diff --git a/ghost/core/core/server/models/user.js b/ghost/core/core/server/models/user.js index 68e080329a..3589363b87 100644 --- a/ghost/core/core/server/models/user.js +++ b/ghost/core/core/server/models/user.js @@ -508,6 +508,8 @@ User = ghostBookshelf.Model.extend({ filter += '+paid_subscription_canceled_notification:true'; } else if (type === 'mention-received') { filter += '+mention_notifications:true'; + } else if (type === 'milestone-received') { + filter += '+milestone_notifications:true'; } const updatedOptions = _.merge({}, options, {filter, withRelated: ['roles']}); return this.findAll(updatedOptions).then((users) => { diff --git a/ghost/core/test/unit/server/services/staff/index.test.js b/ghost/core/test/unit/server/services/staff/index.test.js index c52ed93168..45161df33c 100644 --- a/ghost/core/test/unit/server/services/staff/index.test.js +++ b/ghost/core/test/unit/server/services/staff/index.test.js @@ -1,5 +1,5 @@ const sinon = require('sinon'); - +const assert = require('assert'); const staffService = require('../../../../../core/server/services/staff'); const DomainEvents = require('@tryghost/domain-events'); @@ -7,15 +7,18 @@ const {mockManager} = require('../../../../utils/e2e-framework'); const models = require('../../../../../core/server/models'); const {SubscriptionCancelledEvent, MemberCreatedEvent, SubscriptionActivatedEvent} = require('@tryghost/member-events'); +const {MilestoneCreatedEvent} = require('@tryghost/milestones'); describe('Staff Service:', function () { + let userModelStub; + before(function () { models.init(); }); beforeEach(function () { mockManager.mockMail(); - sinon.stub(models.User, 'getEmailAlertUsers').resolves([{ + userModelStub = sinon.stub(models.User, 'getEmailAlertUsers').resolves([{ email: 'owner@ghost.org', slug: 'ghost' }]); @@ -226,4 +229,37 @@ describe('Staff Service:', function () { mockManager.assert.sentEmailCount(0); }); }); + + describe('milestone created event:', function () { + beforeEach(function () { + mockManager.mockLabsEnabled('milestoneEmails'); + }); + + afterEach(async function () { + sinon.restore(); + mockManager.restore(); + }); + + it('logs when milestone event is handled', async function () { + await staffService.init(); + DomainEvents.dispatch(MilestoneCreatedEvent.create({ + milestone: { + type: 'arr', + currency: 'usd', + name: 'arr-100-usd', + value: 100, + createdAt: new Date(), + emailSentAt: new Date() + }, + meta: { + currentARR: 105 + } + })); + + // Wait for the dispatched events (because this happens async) + await DomainEvents.allSettled(); + const [userCalls] = userModelStub.args[0]; + assert.equal(userCalls, ['milestone-received']); + }); + }); }); diff --git a/ghost/staff-service/lib/emails.js b/ghost/staff-service/lib/emails.js index 114eb5ee5a..d36ca56c45 100644 --- a/ghost/staff-service/lib/emails.js +++ b/ghost/staff-service/lib/emails.js @@ -194,6 +194,24 @@ class StaffServiceEmails { } } + /** + * + * @param {object} eventData + * @param {object} eventData.milestone + * + * @returns {Promise} + */ + async notifyMilestoneReceived({milestone}) { + const users = await this.models.User.getEmailAlertUsers('milestone-received'); + + // TODO: send email with correct templates + for (const user of users) { + const to = user.email; + + this.logging.info(`Will send email to ${to} for ${milestone.type} / ${milestone.value} milestone.`); + } + } + // Utils /** @private */ @@ -227,7 +245,7 @@ class StaffServiceEmails { /** @private */ getFormattedAmount({amount = 0, currency}) { if (!currency) { - return ''; + return amount > 0 ? Intl.NumberFormat().format(amount) : ''; } return Intl.NumberFormat('en', { diff --git a/ghost/staff-service/lib/staff-service.js b/ghost/staff-service/lib/staff-service.js index 3bba8f5e84..731ad5166d 100644 --- a/ghost/staff-service/lib/staff-service.js +++ b/ghost/staff-service/lib/staff-service.js @@ -1,5 +1,6 @@ const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionActivatedEvent} = require('@tryghost/member-events'); const {MentionCreatedEvent} = require('@tryghost/webmentions'); +const {MilestoneCreatedEvent} = require('@tryghost/milestones'); // @NOTE: 'StaffService' is a vague name that does not describe what it's actually doing. // Possibly, "StaffNotificationService" or "StaffEventNotificationService" would be a more accurate name @@ -80,6 +81,11 @@ class StaffService { if (type === MentionCreatedEvent && event.data.mention && this.labs.isSet('webmentions')) { await this.emails.notifyMentionReceived(event.data); } + + if (type === MilestoneCreatedEvent && event.data.milestone && this.labs.isSet('milestoneEmails')) { + await this.emails.notifyMilestoneReceived(event.data); + } + if (!['api', 'member'].includes(event.data.source)) { return; } @@ -146,6 +152,15 @@ class StaffService { this.logging.error(e, `Failed to notify webmention`); } }); + + // Trigger email when a new milestone is reached + this.DomainEvents.subscribe(MilestoneCreatedEvent, async (event) => { + try { + await this.handleEvent(MilestoneCreatedEvent, event); + } catch (e) { + this.logging.error(e, `Failed to notify milestone`); + } + }); } } diff --git a/ghost/staff-service/test/staff-service.test.js b/ghost/staff-service/test/staff-service.test.js index c3756c0c10..4de4b7e8bb 100644 --- a/ghost/staff-service/test/staff-service.test.js +++ b/ghost/staff-service/test/staff-service.test.js @@ -3,9 +3,10 @@ const sinon = require('sinon'); const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionActivatedEvent} = require('@tryghost/member-events'); const {MentionCreatedEvent} = require('@tryghost/webmentions'); +const {MilestoneCreatedEvent} = require('@tryghost/milestones'); require('./utils'); -const StaffService = require('../lib/staff-service'); +const StaffService = require('../index'); function testCommonMailData({mailStub, getEmailAlertUsersStub}) { getEmailAlertUsersStub.calledWith( @@ -108,6 +109,7 @@ describe('StaffService', function () { describe('email notifications:', function () { let mailStub; + let loggingInfoStub; let subscribeStub; let getEmailAlertUsersStub; let service; @@ -147,6 +149,7 @@ describe('StaffService', function () { }; beforeEach(function () { + loggingInfoStub = sinon.stub().resolves(); mailStub = sinon.stub().resolves(); subscribeStub = sinon.stub().resolves(); getEmailAlertUsersStub = sinon.stub().resolves([{ @@ -155,6 +158,7 @@ describe('StaffService', function () { }]); service = new StaffService({ logging: { + info: loggingInfoStub, warn: () => {}, error: () => {} }, @@ -182,16 +186,19 @@ describe('StaffService', function () { describe('subscribeEvents', function () { it('subscribes to events', async function () { service.subscribeEvents(); - subscribeStub.callCount.should.eql(4); + subscribeStub.callCount.should.eql(5); subscribeStub.calledWith(SubscriptionActivatedEvent).should.be.true(); subscribeStub.calledWith(SubscriptionCancelledEvent).should.be.true(); subscribeStub.calledWith(MemberCreatedEvent).should.be.true(); subscribeStub.calledWith(MentionCreatedEvent).should.be.true(); + subscribeStub.calledWith(MilestoneCreatedEvent).should.be.true(); }); }); describe('handleEvent', function () { beforeEach(function () { + loggingInfoStub = sinon.stub().resolves(); + const models = { User: { getEmailAlertUsers: sinon.stub().resolves([{ @@ -255,6 +262,7 @@ describe('StaffService', function () { service = new StaffService({ logging: { + info: loggingInfoStub, warn: () => {}, error: () => {} }, @@ -269,7 +277,15 @@ describe('StaffService', function () { urlUtils, settingsHelpers, labs: { - isSet: () => 'webmentions' + isSet: (flag) => { + if (flag === 'webmentions') { + return true; + } + if (flag === 'milestoneEmails') { + return true; + } + return false; + } } }); }); @@ -331,6 +347,21 @@ describe('StaffService', function () { sinon.match({subject: `💌 New mention from: Exmaple`}) ).should.be.true(); }); + + it('handles milestone created event', async function () { + await service.handleEvent(MilestoneCreatedEvent, { + data: { + milestone: { + type: 'arr', + value: '100', + currency: 'usd' + } + } + }); + mailStub.called.should.be.false(); + loggingInfoStub.calledOnce.should.be.true(); + loggingInfoStub.calledWith('Will send email to owner@ghost.org for arr / 100 milestone.').should.be.true(); + }); }); describe('notifyFreeMemberSignup', function () { @@ -635,5 +666,19 @@ describe('StaffService', function () { ).should.be.true(); }); }); + + describe('notifyMilestoneReceived', function () { + it('prepares to send email when user setting available', async function () { + const milestone = { + type: 'members', + value: 25000 + }; + + await service.emails.notifyMilestoneReceived({milestone}); + + mailStub.called.should.be.false(); + getEmailAlertUsersStub.calledWith('milestone-received').should.be.true(); + }); + }); }); });