Added MilestoneCreatedEvent to staff service notifications (#16307)

refs
https://www.notion.so/ghost/Marketing-Milestone-email-campaigns-1d2c9dee3cfa4029863edb16092ad5c4?pvs=4

- Added MilestoneCreatedEvent subscription to staff-service incl. first
handling
- Logs information for now instead of actually sending an email
This commit is contained in:
Aileen Booker 2023-02-23 11:20:13 +02:00 committed by GitHub
parent fed2cb2675
commit e0331bbfcf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 122 additions and 6 deletions

View File

@ -508,6 +508,8 @@ User = ghostBookshelf.Model.extend({
filter += '+paid_subscription_canceled_notification:true'; filter += '+paid_subscription_canceled_notification:true';
} else if (type === 'mention-received') { } else if (type === 'mention-received') {
filter += '+mention_notifications:true'; filter += '+mention_notifications:true';
} else if (type === 'milestone-received') {
filter += '+milestone_notifications:true';
} }
const updatedOptions = _.merge({}, options, {filter, withRelated: ['roles']}); const updatedOptions = _.merge({}, options, {filter, withRelated: ['roles']});
return this.findAll(updatedOptions).then((users) => { return this.findAll(updatedOptions).then((users) => {

View File

@ -1,5 +1,5 @@
const sinon = require('sinon'); const sinon = require('sinon');
const assert = require('assert');
const staffService = require('../../../../../core/server/services/staff'); const staffService = require('../../../../../core/server/services/staff');
const DomainEvents = require('@tryghost/domain-events'); const DomainEvents = require('@tryghost/domain-events');
@ -7,15 +7,18 @@ const {mockManager} = require('../../../../utils/e2e-framework');
const models = require('../../../../../core/server/models'); const models = require('../../../../../core/server/models');
const {SubscriptionCancelledEvent, MemberCreatedEvent, SubscriptionActivatedEvent} = require('@tryghost/member-events'); const {SubscriptionCancelledEvent, MemberCreatedEvent, SubscriptionActivatedEvent} = require('@tryghost/member-events');
const {MilestoneCreatedEvent} = require('@tryghost/milestones');
describe('Staff Service:', function () { describe('Staff Service:', function () {
let userModelStub;
before(function () { before(function () {
models.init(); models.init();
}); });
beforeEach(function () { beforeEach(function () {
mockManager.mockMail(); mockManager.mockMail();
sinon.stub(models.User, 'getEmailAlertUsers').resolves([{ userModelStub = sinon.stub(models.User, 'getEmailAlertUsers').resolves([{
email: 'owner@ghost.org', email: 'owner@ghost.org',
slug: 'ghost' slug: 'ghost'
}]); }]);
@ -226,4 +229,37 @@ describe('Staff Service:', function () {
mockManager.assert.sentEmailCount(0); 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']);
});
});
}); });

View File

@ -194,6 +194,24 @@ class StaffServiceEmails {
} }
} }
/**
*
* @param {object} eventData
* @param {object} eventData.milestone
*
* @returns {Promise<void>}
*/
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 // Utils
/** @private */ /** @private */
@ -227,7 +245,7 @@ class StaffServiceEmails {
/** @private */ /** @private */
getFormattedAmount({amount = 0, currency}) { getFormattedAmount({amount = 0, currency}) {
if (!currency) { if (!currency) {
return ''; return amount > 0 ? Intl.NumberFormat().format(amount) : '';
} }
return Intl.NumberFormat('en', { return Intl.NumberFormat('en', {

View File

@ -1,5 +1,6 @@
const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionActivatedEvent} = require('@tryghost/member-events'); const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionActivatedEvent} = require('@tryghost/member-events');
const {MentionCreatedEvent} = require('@tryghost/webmentions'); 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. // @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 // 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')) { if (type === MentionCreatedEvent && event.data.mention && this.labs.isSet('webmentions')) {
await this.emails.notifyMentionReceived(event.data); 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)) { if (!['api', 'member'].includes(event.data.source)) {
return; return;
} }
@ -146,6 +152,15 @@ class StaffService {
this.logging.error(e, `Failed to notify webmention`); 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`);
}
});
} }
} }

View File

@ -3,9 +3,10 @@
const sinon = require('sinon'); const sinon = require('sinon');
const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionActivatedEvent} = require('@tryghost/member-events'); const {MemberCreatedEvent, SubscriptionCancelledEvent, SubscriptionActivatedEvent} = require('@tryghost/member-events');
const {MentionCreatedEvent} = require('@tryghost/webmentions'); const {MentionCreatedEvent} = require('@tryghost/webmentions');
const {MilestoneCreatedEvent} = require('@tryghost/milestones');
require('./utils'); require('./utils');
const StaffService = require('../lib/staff-service'); const StaffService = require('../index');
function testCommonMailData({mailStub, getEmailAlertUsersStub}) { function testCommonMailData({mailStub, getEmailAlertUsersStub}) {
getEmailAlertUsersStub.calledWith( getEmailAlertUsersStub.calledWith(
@ -108,6 +109,7 @@ describe('StaffService', function () {
describe('email notifications:', function () { describe('email notifications:', function () {
let mailStub; let mailStub;
let loggingInfoStub;
let subscribeStub; let subscribeStub;
let getEmailAlertUsersStub; let getEmailAlertUsersStub;
let service; let service;
@ -147,6 +149,7 @@ describe('StaffService', function () {
}; };
beforeEach(function () { beforeEach(function () {
loggingInfoStub = sinon.stub().resolves();
mailStub = sinon.stub().resolves(); mailStub = sinon.stub().resolves();
subscribeStub = sinon.stub().resolves(); subscribeStub = sinon.stub().resolves();
getEmailAlertUsersStub = sinon.stub().resolves([{ getEmailAlertUsersStub = sinon.stub().resolves([{
@ -155,6 +158,7 @@ describe('StaffService', function () {
}]); }]);
service = new StaffService({ service = new StaffService({
logging: { logging: {
info: loggingInfoStub,
warn: () => {}, warn: () => {},
error: () => {} error: () => {}
}, },
@ -182,16 +186,19 @@ describe('StaffService', function () {
describe('subscribeEvents', function () { describe('subscribeEvents', function () {
it('subscribes to events', async function () { it('subscribes to events', async function () {
service.subscribeEvents(); service.subscribeEvents();
subscribeStub.callCount.should.eql(4); subscribeStub.callCount.should.eql(5);
subscribeStub.calledWith(SubscriptionActivatedEvent).should.be.true(); subscribeStub.calledWith(SubscriptionActivatedEvent).should.be.true();
subscribeStub.calledWith(SubscriptionCancelledEvent).should.be.true(); subscribeStub.calledWith(SubscriptionCancelledEvent).should.be.true();
subscribeStub.calledWith(MemberCreatedEvent).should.be.true(); subscribeStub.calledWith(MemberCreatedEvent).should.be.true();
subscribeStub.calledWith(MentionCreatedEvent).should.be.true(); subscribeStub.calledWith(MentionCreatedEvent).should.be.true();
subscribeStub.calledWith(MilestoneCreatedEvent).should.be.true();
}); });
}); });
describe('handleEvent', function () { describe('handleEvent', function () {
beforeEach(function () { beforeEach(function () {
loggingInfoStub = sinon.stub().resolves();
const models = { const models = {
User: { User: {
getEmailAlertUsers: sinon.stub().resolves([{ getEmailAlertUsers: sinon.stub().resolves([{
@ -255,6 +262,7 @@ describe('StaffService', function () {
service = new StaffService({ service = new StaffService({
logging: { logging: {
info: loggingInfoStub,
warn: () => {}, warn: () => {},
error: () => {} error: () => {}
}, },
@ -269,7 +277,15 @@ describe('StaffService', function () {
urlUtils, urlUtils,
settingsHelpers, settingsHelpers,
labs: { 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`}) sinon.match({subject: `💌 New mention from: Exmaple`})
).should.be.true(); ).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 () { describe('notifyFreeMemberSignup', function () {
@ -635,5 +666,19 @@ describe('StaffService', function () {
).should.be.true(); ).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();
});
});
}); });
}); });