mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-03 23:00:14 -05:00
Slack notifications service for Milestones behind flag (#16281)
refs https://www.notion.so/ghost/Marketing-Milestone-email-campaigns-1d2c9dee3cfa4029863edb16092ad5c4?pvs=4 - Added a `slack-notifications` repository which handles sending Slack messages to a URL as defined in our Ghost(Pro) config (also includes a global switch to disable the feature if needed) and listens to `MilestoneCreatedEvents`. - Added a `slack-notification` service which listens to the events on boot. - In order to have access to further information such as the reason why a Milestone email hasn't been sent, or the current ARR or Member value as comparison to the achieved milestone, I added a `meta` object to the `MilestoneCreatedEvent` which then gets accessible by the event subscriber. This avoid doing further requests to the DB as we need to have this information in relation to the event occurred. --------- Co-authored-by: Fabien "egg" O'Carroll <fabien@allou.is>
This commit is contained in:
parent
034a230365
commit
2f57e95a5d
20 changed files with 1011 additions and 35 deletions
|
@ -296,6 +296,7 @@ async function initServices({config}) {
|
||||||
const mentionsService = require('./server/services/mentions');
|
const mentionsService = require('./server/services/mentions');
|
||||||
const tagsPublic = require('./server/services/tags-public');
|
const tagsPublic = require('./server/services/tags-public');
|
||||||
const postsPublic = require('./server/services/posts-public');
|
const postsPublic = require('./server/services/posts-public');
|
||||||
|
const slackNotifications = require('./server/services/slack-notifications');
|
||||||
|
|
||||||
const urlUtils = require('./shared/url-utils');
|
const urlUtils = require('./shared/url-utils');
|
||||||
|
|
||||||
|
@ -331,7 +332,8 @@ async function initServices({config}) {
|
||||||
}),
|
}),
|
||||||
comments.init(),
|
comments.init(),
|
||||||
linkTracking.init(),
|
linkTracking.init(),
|
||||||
emailSuppressionList.init()
|
emailSuppressionList.init(),
|
||||||
|
slackNotifications.init()
|
||||||
]);
|
]);
|
||||||
debug('End: Services');
|
debug('End: Services');
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
module.exports = require('./service');
|
|
@ -0,0 +1,60 @@
|
||||||
|
const DomainEvents = require('@tryghost/domain-events');
|
||||||
|
const config = require('../../../shared/config');
|
||||||
|
const labs = require('../../../shared/labs');
|
||||||
|
const logging = require('@tryghost/logging');
|
||||||
|
|
||||||
|
class SlackNotificationsServiceWrapper {
|
||||||
|
/** @type {import('@tryghost/slack-notifications/lib/SlackNotificationsService')} */
|
||||||
|
#api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {object} deps
|
||||||
|
* @param {string} deps.siteUrl
|
||||||
|
* @param {boolean} deps.isEnabled
|
||||||
|
* @param {URL} deps.webhookUrl
|
||||||
|
*
|
||||||
|
* @returns {import('@tryghost/slack-notifications/lib/SlackNotificationsService')}
|
||||||
|
*/
|
||||||
|
static create({siteUrl, isEnabled, webhookUrl}) {
|
||||||
|
const {
|
||||||
|
SlackNotificationsService,
|
||||||
|
SlackNotifications
|
||||||
|
} = require('@tryghost/slack-notifications');
|
||||||
|
|
||||||
|
const slackNotifications = new SlackNotifications({
|
||||||
|
webhookUrl,
|
||||||
|
siteUrl,
|
||||||
|
logging
|
||||||
|
});
|
||||||
|
|
||||||
|
return new SlackNotificationsService({
|
||||||
|
DomainEvents,
|
||||||
|
logging,
|
||||||
|
config: {
|
||||||
|
isEnabled,
|
||||||
|
webhookUrl
|
||||||
|
},
|
||||||
|
slackNotifications
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (this.#api) {
|
||||||
|
// Prevent creating duplicate DomainEvents subscribers
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostSettings = config.get('hostSettings');
|
||||||
|
const urlUtils = require('../../../shared/url-utils');
|
||||||
|
const siteUrl = urlUtils.getSiteUrl();
|
||||||
|
const isEnabled = labs.isSet('milestoneEmails') && hostSettings?.milestones?.enabled && hostSettings?.milestones?.url;
|
||||||
|
const webhookUrl = hostSettings?.milestones?.url;
|
||||||
|
|
||||||
|
this.#api = SlackNotificationsServiceWrapper.create({siteUrl, isEnabled, webhookUrl});
|
||||||
|
|
||||||
|
this.#api.subscribeEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new SlackNotificationsServiceWrapper();
|
|
@ -137,6 +137,7 @@
|
||||||
"@tryghost/tiers": "0.0.0",
|
"@tryghost/tiers": "0.0.0",
|
||||||
"@tryghost/tpl": "0.1.21",
|
"@tryghost/tpl": "0.1.21",
|
||||||
"@tryghost/update-check-service": "0.0.0",
|
"@tryghost/update-check-service": "0.0.0",
|
||||||
|
"@tryghost/slack-notifications": "0.0.0",
|
||||||
"@tryghost/url-utils": "4.3.0",
|
"@tryghost/url-utils": "4.3.0",
|
||||||
"@tryghost/validator": "0.1.31",
|
"@tryghost/validator": "0.1.31",
|
||||||
"@tryghost/verification-trigger": "0.0.0",
|
"@tryghost/verification-trigger": "0.0.0",
|
||||||
|
|
|
@ -151,9 +151,6 @@ describe('Milestones Service', function () {
|
||||||
|
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
sinon.createSandbox();
|
sinon.createSandbox();
|
||||||
// TODO: stub out stripe mode
|
|
||||||
// stripeModeStub = sinon.stub().returns(true);
|
|
||||||
// milestonesService.__set__('getStripeLiveEnabled', stripeModeStub);
|
|
||||||
configUtils.set('milestones', milestonesConfig);
|
configUtils.set('milestones', milestonesConfig);
|
||||||
mockManager.mockLabsEnabled('milestoneEmails');
|
mockManager.mockLabsEnabled('milestoneEmails');
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
const {mockManager, configUtils} = require('../../../../utils/e2e-framework');
|
||||||
|
const assert = require('assert');
|
||||||
|
const nock = require('nock');
|
||||||
|
const DomainEvents = require('@tryghost/domain-events');
|
||||||
|
const {MilestoneCreatedEvent} = require('@tryghost/milestones');
|
||||||
|
const slackNotifications = require('../../../../../core/server/services/slack-notifications');
|
||||||
|
|
||||||
|
describe('Slack Notifications Service', function () {
|
||||||
|
let scope;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
configUtils.set('hostSettings', {milestones: {enabled: true, url: 'https://testhooks.slack.com/'}});
|
||||||
|
|
||||||
|
mockManager.mockLabsEnabled('milestoneEmails');
|
||||||
|
|
||||||
|
scope = nock('https://testhooks.slack.com/')
|
||||||
|
.post('/')
|
||||||
|
.reply(200, {ok: true});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async function () {
|
||||||
|
nock.cleanAll();
|
||||||
|
await configUtils.restore();
|
||||||
|
mockManager.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can send a milestone created event', async function () {
|
||||||
|
await slackNotifications.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();
|
||||||
|
|
||||||
|
assert.strictEqual(scope.isDone(), true);
|
||||||
|
});
|
||||||
|
});
|
|
@ -131,7 +131,7 @@ module.exports = class Milestone {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
milestone.events.push(MilestoneCreatedEvent.create({milestone}));
|
milestone.events.push(MilestoneCreatedEvent.create({milestone, meta: data?.meta}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return milestone;
|
return milestone;
|
||||||
|
|
|
@ -127,44 +127,55 @@ module.exports = class MilestonesService {
|
||||||
* @param {object} milestone
|
* @param {object} milestone
|
||||||
* @param {number} milestone.value
|
* @param {number} milestone.value
|
||||||
* @param {'arr'|'members'} milestone.type
|
* @param {'arr'|'members'} milestone.type
|
||||||
|
* @param {object} milestone.meta
|
||||||
* @param {string|null} [milestone.currency]
|
* @param {string|null} [milestone.currency]
|
||||||
* @param {Date|null} [milestone.emailSentAt]
|
* @param {Date|null} [milestone.emailSentAt]
|
||||||
*
|
*
|
||||||
* @returns {Promise<Milestone>}
|
* @returns {Promise<Milestone>}
|
||||||
*/
|
*/
|
||||||
async #saveMileStoneAndSendEmail(milestone) {
|
async #saveMileStoneAndSendEmail(milestone) {
|
||||||
const shouldSendEmail = await this.#shouldSendEmail();
|
const {shouldSendEmail, reason} = await this.#shouldSendEmail();
|
||||||
|
|
||||||
if (shouldSendEmail) {
|
if (shouldSendEmail) {
|
||||||
milestone.emailSentAt = new Date();
|
milestone.emailSentAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reason) {
|
||||||
|
milestone.meta.reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
return await this.#createMilestone(milestone);
|
return await this.#createMilestone(milestone);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<{shouldSendEmail: boolean, reason: string}>}
|
||||||
*/
|
*/
|
||||||
async #shouldSendEmail() {
|
async #shouldSendEmail() {
|
||||||
let shouldSendEmail;
|
let canHaveEmail;
|
||||||
|
let reason = null;
|
||||||
// Two cases in which we don't want to send an email
|
// Two cases in which we don't want to send an email
|
||||||
// 1. There has been an import of members within the last week
|
// 1. There has been an import of members within the last week
|
||||||
// 2. The last email has been sent less than two weeks ago
|
// 2. The last email has been sent less than two weeks ago
|
||||||
const lastMilestoneSent = await this.#repository.getLastEmailSent();
|
const lastMilestoneSent = await this.#repository.getLastEmailSent();
|
||||||
|
|
||||||
if (!lastMilestoneSent) {
|
if (!lastMilestoneSent) {
|
||||||
shouldSendEmail = true;
|
canHaveEmail = true;
|
||||||
} else {
|
} else {
|
||||||
const differenceInTime = new Date().getTime() - new Date(lastMilestoneSent.emailSentAt).getTime();
|
const differenceInTime = new Date().getTime() - new Date(lastMilestoneSent.emailSentAt).getTime();
|
||||||
const differenceInDays = differenceInTime / (1000 * 3600 * 24);
|
const differenceInDays = differenceInTime / (1000 * 3600 * 24);
|
||||||
|
|
||||||
shouldSendEmail = differenceInDays >= 14;
|
canHaveEmail = differenceInDays >= 14;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMembersImported = await this.#queries.hasImportedMembersInPeriod();
|
const hasMembersImported = await this.#queries.hasImportedMembersInPeriod();
|
||||||
|
const shouldSendEmail = canHaveEmail && !hasMembersImported;
|
||||||
|
|
||||||
return shouldSendEmail && !hasMembersImported;
|
if (!shouldSendEmail) {
|
||||||
|
reason = hasMembersImported ? 'import' : 'email';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {shouldSendEmail, reason};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -198,7 +209,10 @@ module.exports = class MilestonesService {
|
||||||
|
|
||||||
if (milestone && milestone > 0) {
|
if (milestone && milestone > 0) {
|
||||||
if (!milestoneExists && (!latestMilestone || milestone > latestMilestone.value)) {
|
if (!milestoneExists && (!latestMilestone || milestone > latestMilestone.value)) {
|
||||||
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'arr', currency: defaultCurrency});
|
const meta = {
|
||||||
|
currentARR: currentARRForCurrency.arr
|
||||||
|
};
|
||||||
|
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'arr', currency: defaultCurrency, meta});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -226,7 +240,10 @@ module.exports = class MilestonesService {
|
||||||
|
|
||||||
if (milestone && milestone > 0) {
|
if (milestone && milestone > 0) {
|
||||||
if (!milestoneExists && (!latestMembersMilestone || milestone > latestMembersMilestone.value)) {
|
if (!milestoneExists && (!latestMembersMilestone || milestone > latestMembersMilestone.value)) {
|
||||||
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'members'});
|
const meta = {
|
||||||
|
currentMembers: membersCount
|
||||||
|
};
|
||||||
|
return await this.#saveMileStoneAndSendEmail({value: milestone, type: 'members', meta});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,10 +9,10 @@ const sinon = require('sinon');
|
||||||
|
|
||||||
describe('MilestonesService', function () {
|
describe('MilestonesService', function () {
|
||||||
let repository;
|
let repository;
|
||||||
let domainEventsSpy;
|
let domainEventSpy;
|
||||||
|
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
domainEventsSpy = sinon.spy(DomainEvents, 'dispatch');
|
domainEventSpy = sinon.spy(DomainEvents, 'dispatch');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(function () {
|
afterEach(function () {
|
||||||
|
@ -68,7 +68,11 @@ describe('MilestonesService', function () {
|
||||||
assert(arrResult.value === 1000);
|
assert(arrResult.value === 1000);
|
||||||
assert(arrResult.emailSentAt !== null);
|
assert(arrResult.emailSentAt !== null);
|
||||||
assert(arrResult.name === 'arr-1000-usd');
|
assert(arrResult.name === 'arr-1000-usd');
|
||||||
assert(domainEventsSpy.calledOnce === true);
|
|
||||||
|
const domainEventSpyResult = domainEventSpy.getCall(0).args[0];
|
||||||
|
assert(domainEventSpy.calledOnce === true);
|
||||||
|
assert(domainEventSpyResult.data.milestone);
|
||||||
|
assert(domainEventSpyResult.data.meta.currentARR === 1298);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Adds next ARR milestone and sends email', async function () {
|
it('Adds next ARR milestone and sends email', async function () {
|
||||||
|
@ -100,7 +104,7 @@ describe('MilestonesService', function () {
|
||||||
await repository.save(milestoneTwo);
|
await repository.save(milestoneTwo);
|
||||||
await repository.save(milestoneThree);
|
await repository.save(milestoneThree);
|
||||||
|
|
||||||
assert(domainEventsSpy.callCount === 3);
|
assert(domainEventSpy.callCount === 3);
|
||||||
|
|
||||||
const milestoneEmailService = new MilestonesService({
|
const milestoneEmailService = new MilestonesService({
|
||||||
repository,
|
repository,
|
||||||
|
@ -125,7 +129,10 @@ describe('MilestonesService', function () {
|
||||||
assert(arrResult.value === 10000);
|
assert(arrResult.value === 10000);
|
||||||
assert(arrResult.emailSentAt !== null);
|
assert(arrResult.emailSentAt !== null);
|
||||||
assert(arrResult.name === 'arr-10000-usd');
|
assert(arrResult.name === 'arr-10000-usd');
|
||||||
assert(domainEventsSpy.callCount === 4); // we have just created a new milestone
|
assert(domainEventSpy.callCount === 4); // we have just created a new milestone
|
||||||
|
const domainEventSpyResult = domainEventSpy.getCall(3).args[0];
|
||||||
|
assert(domainEventSpyResult.data.milestone);
|
||||||
|
assert(domainEventSpyResult.data.meta.currentARR === 10001);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Does not add ARR milestone for out of scope currency', async function () {
|
it('Does not add ARR milestone for out of scope currency', async function () {
|
||||||
|
@ -149,7 +156,7 @@ describe('MilestonesService', function () {
|
||||||
|
|
||||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||||
assert(arrResult === undefined);
|
assert(arrResult === undefined);
|
||||||
assert(domainEventsSpy.callCount === 0);
|
assert(domainEventSpy.callCount === 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Does not add new ARR milestone if already achieved', async function () {
|
it('Does not add new ARR milestone if already achieved', async function () {
|
||||||
|
@ -163,7 +170,7 @@ describe('MilestonesService', function () {
|
||||||
|
|
||||||
await repository.save(milestone);
|
await repository.save(milestone);
|
||||||
|
|
||||||
assert(domainEventsSpy.callCount === 1);
|
assert(domainEventSpy.callCount === 1);
|
||||||
|
|
||||||
const milestoneEmailService = new MilestonesService({
|
const milestoneEmailService = new MilestonesService({
|
||||||
repository,
|
repository,
|
||||||
|
@ -183,7 +190,7 @@ describe('MilestonesService', function () {
|
||||||
|
|
||||||
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
const arrResult = await milestoneEmailService.checkMilestones('arr');
|
||||||
assert(arrResult === undefined);
|
assert(arrResult === undefined);
|
||||||
assert(domainEventsSpy.callCount === 1);
|
assert(domainEventSpy.callCount === 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Adds ARR milestone but does not send email if imported members are detected', async function () {
|
it('Adds ARR milestone but does not send email if imported members are detected', async function () {
|
||||||
|
@ -210,7 +217,9 @@ describe('MilestonesService', function () {
|
||||||
assert(arrResult.currency === 'usd');
|
assert(arrResult.currency === 'usd');
|
||||||
assert(arrResult.value === 100000);
|
assert(arrResult.value === 100000);
|
||||||
assert(arrResult.emailSentAt === null);
|
assert(arrResult.emailSentAt === null);
|
||||||
assert(domainEventsSpy.callCount === 1);
|
assert(domainEventSpy.callCount === 1);
|
||||||
|
const domainEventSpyResult = domainEventSpy.getCall(0).args[0];
|
||||||
|
assert(domainEventSpyResult.data.meta.reason === 'import');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Adds ARR milestone but does not send email if last email was too recent', async function () {
|
it('Adds ARR milestone but does not send email if last email was too recent', async function () {
|
||||||
|
@ -227,7 +236,7 @@ describe('MilestonesService', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
await repository.save(milestone);
|
await repository.save(milestone);
|
||||||
assert(domainEventsSpy.callCount === 1);
|
assert(domainEventSpy.callCount === 1);
|
||||||
|
|
||||||
const milestoneEmailService = new MilestonesService({
|
const milestoneEmailService = new MilestonesService({
|
||||||
repository,
|
repository,
|
||||||
|
@ -237,7 +246,7 @@ describe('MilestonesService', function () {
|
||||||
return [{currency: 'idr', arr: 10000}];
|
return [{currency: 'idr', arr: 10000}];
|
||||||
},
|
},
|
||||||
async hasImportedMembersInPeriod() {
|
async hasImportedMembersInPeriod() {
|
||||||
return true;
|
return false;
|
||||||
},
|
},
|
||||||
async getDefaultCurrency() {
|
async getDefaultCurrency() {
|
||||||
return 'idr';
|
return 'idr';
|
||||||
|
@ -250,7 +259,9 @@ describe('MilestonesService', function () {
|
||||||
assert(arrResult.currency === 'idr');
|
assert(arrResult.currency === 'idr');
|
||||||
assert(arrResult.value === 10000);
|
assert(arrResult.value === 10000);
|
||||||
assert(arrResult.emailSentAt === null);
|
assert(arrResult.emailSentAt === null);
|
||||||
assert(domainEventsSpy.callCount === 2); // new milestone created
|
assert(domainEventSpy.callCount === 2); // new milestone created
|
||||||
|
const domainEventSpyResult = domainEventSpy.getCall(1).args[0];
|
||||||
|
assert(domainEventSpyResult.data.meta.reason === 'email');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -278,7 +289,7 @@ describe('MilestonesService', function () {
|
||||||
assert(membersResult.type === 'members');
|
assert(membersResult.type === 'members');
|
||||||
assert(membersResult.value === 100);
|
assert(membersResult.value === 100);
|
||||||
assert(membersResult.emailSentAt !== null);
|
assert(membersResult.emailSentAt !== null);
|
||||||
assert(domainEventsSpy.callCount === 1);
|
assert(domainEventSpy.callCount === 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Adds next Members milestone and sends email', async function () {
|
it('Adds next Members milestone and sends email', async function () {
|
||||||
|
@ -309,7 +320,7 @@ describe('MilestonesService', function () {
|
||||||
await repository.save(milestoneTwo);
|
await repository.save(milestoneTwo);
|
||||||
await repository.save(milestoneThree);
|
await repository.save(milestoneThree);
|
||||||
|
|
||||||
assert(domainEventsSpy.callCount === 3);
|
assert(domainEventSpy.callCount === 3);
|
||||||
|
|
||||||
const milestoneEmailService = new MilestonesService({
|
const milestoneEmailService = new MilestonesService({
|
||||||
repository,
|
repository,
|
||||||
|
@ -333,7 +344,7 @@ describe('MilestonesService', function () {
|
||||||
assert(membersResult.value === 50000);
|
assert(membersResult.value === 50000);
|
||||||
assert(membersResult.emailSentAt !== null);
|
assert(membersResult.emailSentAt !== null);
|
||||||
assert(membersResult.name === 'members-50000');
|
assert(membersResult.name === 'members-50000');
|
||||||
assert(domainEventsSpy.callCount === 4);
|
assert(domainEventSpy.callCount === 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Does not add new Members milestone if already achieved', async function () {
|
it('Does not add new Members milestone if already achieved', async function () {
|
||||||
|
@ -346,7 +357,7 @@ describe('MilestonesService', function () {
|
||||||
|
|
||||||
await repository.save(milestone);
|
await repository.save(milestone);
|
||||||
|
|
||||||
assert(domainEventsSpy.callCount === 1);
|
assert(domainEventSpy.callCount === 1);
|
||||||
|
|
||||||
const milestoneEmailService = new MilestonesService({
|
const milestoneEmailService = new MilestonesService({
|
||||||
repository,
|
repository,
|
||||||
|
@ -366,7 +377,7 @@ describe('MilestonesService', function () {
|
||||||
|
|
||||||
const membersResult = await milestoneEmailService.checkMilestones('members');
|
const membersResult = await milestoneEmailService.checkMilestones('members');
|
||||||
assert(membersResult === undefined);
|
assert(membersResult === undefined);
|
||||||
assert(domainEventsSpy.callCount === 1);
|
assert(domainEventSpy.callCount === 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Adds Members milestone but does not send email if imported members are detected', async function () {
|
it('Adds Members milestone but does not send email if imported members are detected', async function () {
|
||||||
|
@ -379,7 +390,7 @@ describe('MilestonesService', function () {
|
||||||
|
|
||||||
await repository.save(milestone);
|
await repository.save(milestone);
|
||||||
|
|
||||||
assert(domainEventsSpy.callCount === 1);
|
assert(domainEventSpy.callCount === 1);
|
||||||
|
|
||||||
const milestoneEmailService = new MilestonesService({
|
const milestoneEmailService = new MilestonesService({
|
||||||
repository,
|
repository,
|
||||||
|
@ -401,7 +412,7 @@ describe('MilestonesService', function () {
|
||||||
assert(membersResult.type === 'members');
|
assert(membersResult.type === 'members');
|
||||||
assert(membersResult.value === 1000);
|
assert(membersResult.value === 1000);
|
||||||
assert(membersResult.emailSentAt === null);
|
assert(membersResult.emailSentAt === null);
|
||||||
assert(domainEventsSpy.callCount === 2);
|
assert(domainEventSpy.callCount === 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Adds Members milestone but does not send email if last email was too recent', async function () {
|
it('Adds Members milestone but does not send email if last email was too recent', async function () {
|
||||||
|
@ -418,7 +429,7 @@ describe('MilestonesService', function () {
|
||||||
|
|
||||||
await repository.save(milestone);
|
await repository.save(milestone);
|
||||||
|
|
||||||
assert(domainEventsSpy.callCount === 1);
|
assert(domainEventSpy.callCount === 1);
|
||||||
|
|
||||||
const milestoneEmailService = new MilestonesService({
|
const milestoneEmailService = new MilestonesService({
|
||||||
repository,
|
repository,
|
||||||
|
@ -440,7 +451,7 @@ describe('MilestonesService', function () {
|
||||||
assert(membersResult.type === 'members');
|
assert(membersResult.type === 'members');
|
||||||
assert(membersResult.value === 50000);
|
assert(membersResult.value === 50000);
|
||||||
assert(membersResult.emailSentAt === null);
|
assert(membersResult.emailSentAt === null);
|
||||||
assert(domainEventsSpy.callCount === 2);
|
assert(domainEventSpy.callCount === 2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
6
ghost/slack-notifications/.eslintrc.js
Normal file
6
ghost/slack-notifications/.eslintrc.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: ['ghost'],
|
||||||
|
extends: [
|
||||||
|
'plugin:ghost/node'
|
||||||
|
]
|
||||||
|
};
|
23
ghost/slack-notifications/README.md
Normal file
23
ghost/slack-notifications/README.md
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Slack Notifications
|
||||||
|
|
||||||
|
Service to handle sending notifications to a Slack webhook URL
|
||||||
|
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
1
ghost/slack-notifications/index.js
Normal file
1
ghost/slack-notifications/index.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
module.exports = require('./lib/slack-notifications');
|
205
ghost/slack-notifications/lib/SlackNotifications.js
Normal file
205
ghost/slack-notifications/lib/SlackNotifications.js
Normal file
|
@ -0,0 +1,205 @@
|
||||||
|
const got = require('got');
|
||||||
|
const validator = require('@tryghost/validator');
|
||||||
|
const errors = require('@tryghost/errors');
|
||||||
|
const ghostVersion = require('@tryghost/version');
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {URL} webhookUrl
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {string} siteUrl
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('@tryghost/logging')} logging
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./SlackNotificationsService').ISlackNotifications} ISlackNotifications
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements {ISlackNotifications}
|
||||||
|
*/
|
||||||
|
class SlackNotifications {
|
||||||
|
/** @type {URL} */
|
||||||
|
#webhookUrl;
|
||||||
|
|
||||||
|
/** @type {siteUrl} */
|
||||||
|
#siteUrl;
|
||||||
|
|
||||||
|
/** @type {logging} */
|
||||||
|
#logging;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} deps
|
||||||
|
* @param {URL} deps.webhookUrl
|
||||||
|
* @param {siteUrl} deps.siteUrl
|
||||||
|
* @param {logging} deps.logging
|
||||||
|
*/
|
||||||
|
constructor(deps) {
|
||||||
|
this.#siteUrl = deps.siteUrl;
|
||||||
|
this.#webhookUrl = deps.webhookUrl;
|
||||||
|
this.#logging = deps.logging;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} eventData
|
||||||
|
* @param {import('@tryghost/milestones/lib/InMemoryMilestoneRepository').Milestone} eventData.milestone
|
||||||
|
* @param {object} [eventData.meta]
|
||||||
|
* @param {'import'|'email'} [eventData.meta.reason]
|
||||||
|
* @param {number} [eventData.meta.currentARR]
|
||||||
|
* @param {number} [eventData.meta.currentMembers]
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async notifyMilestoneReceived({milestone, meta}) {
|
||||||
|
const hasImportedMembers = meta?.reason === 'import' ? 'has imported members' : null;
|
||||||
|
const lastEmailTooSoon = meta?.reason === 'email' ? 'last email too recent' : null;
|
||||||
|
const emailNotSentReason = hasImportedMembers || lastEmailTooSoon;
|
||||||
|
const milestoneTypePretty = milestone.type === 'arr' ? 'ARR' : 'Members';
|
||||||
|
const valueFormatted = this.#getFormattedAmount({amount: milestone.value, currency: milestone?.currency});
|
||||||
|
const emailSentText = milestone?.emailSentAt ? this.#getFormattedDate(milestone?.emailSentAt) : `no / ${emailNotSentReason}`;
|
||||||
|
const title = `:tada: ${milestoneTypePretty} Milestone ${valueFormatted} reached!`;
|
||||||
|
|
||||||
|
let valueSection;
|
||||||
|
|
||||||
|
if (milestone.type === 'arr') {
|
||||||
|
valueSection = {
|
||||||
|
type: 'section',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: `*Milestone:*\n${valueFormatted}`
|
||||||
|
}
|
||||||
|
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
if (meta?.currentARR) {
|
||||||
|
valueSection.fields.push({
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: `*Current ARR:*\n${this.#getFormattedAmount({amount: meta.currentARR, currency: milestone?.currency})}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
valueSection = {
|
||||||
|
type: 'section',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: `*Milestone:*\n${valueFormatted}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
if (meta?.currentMembers) {
|
||||||
|
valueSection.fields.push({
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: `*Current Members:*\n${this.#getFormattedAmount({amount: meta.currentMembers})}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = [
|
||||||
|
{
|
||||||
|
type: 'header',
|
||||||
|
text: {
|
||||||
|
type: 'plain_text',
|
||||||
|
text: title,
|
||||||
|
emoji: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: {
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: `New *${milestoneTypePretty} Milestone* achieved for <${this.#siteUrl}|${this.#siteUrl}>`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider'
|
||||||
|
},
|
||||||
|
valueSection,
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: {
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: `*Email sent:*\n${emailSentText}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const slackData = {
|
||||||
|
unfurl_links: false,
|
||||||
|
username: 'Ghost Milestone Service',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
color: '#36a64f',
|
||||||
|
blocks
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.send(slackData, this.#webhookUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {object} slackData
|
||||||
|
* @param {URL} url
|
||||||
|
*
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
async send(slackData, url) {
|
||||||
|
if ((!url || typeof url !== 'string') || !validator.isURL(url)) {
|
||||||
|
const err = new errors.InternalServerError({
|
||||||
|
message: 'URL empty or invalid.',
|
||||||
|
code: 'URL_MISSING_INVALID',
|
||||||
|
context: url
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.#logging.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestOptions = {
|
||||||
|
body: JSON.stringify(slackData),
|
||||||
|
headers: {
|
||||||
|
'user-agent': 'Ghost/' + ghostVersion.original + ' (https://github.com/TryGhost/Ghost)'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return await got.post(url, requestOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* @param {number} options.amount
|
||||||
|
* @param {string} [options.currency]
|
||||||
|
*
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
#getFormattedAmount({amount = 0, currency}) {
|
||||||
|
if (!currency) {
|
||||||
|
return Intl.NumberFormat().format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Intl.NumberFormat('en', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
currencyDisplay: 'symbol'
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string|Date} date
|
||||||
|
*
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
#getFormattedDate(date) {
|
||||||
|
return moment(date).format('D MMM YYYY');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SlackNotifications;
|
89
ghost/slack-notifications/lib/SlackNotificationsService.js
Normal file
89
ghost/slack-notifications/lib/SlackNotificationsService.js
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
const {MilestoneCreatedEvent} = require('@tryghost/milestones');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('@tryghost/milestones/lib/InMemoryMilestoneRepository').Milestone} Milestone
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} meta
|
||||||
|
* @prop {'import'|'email'} [reason]
|
||||||
|
* @prop {number} [currentARR]
|
||||||
|
* @prop {number} [currentMembers]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('@tryghost/logging')} logging
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} ISlackNotifications
|
||||||
|
* @param {logging} logging
|
||||||
|
* @param {URL} siteUrl
|
||||||
|
* @param {URL} webhookUrl
|
||||||
|
* @prop {Object.<Milestone, ?meta>} notifyMilestoneReceived
|
||||||
|
* @prop {(slackData: object, url: URL) => Promise<void>} send
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} config
|
||||||
|
* @prop {boolean} isEnabled
|
||||||
|
* @prop {URL} webhookUrl
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = class SlackNotificationsService {
|
||||||
|
/** @type {import('@tryghost/domain-events')} */
|
||||||
|
#DomainEvents;
|
||||||
|
|
||||||
|
/** @type {import('@tryghost/logging')} */
|
||||||
|
#logging;
|
||||||
|
|
||||||
|
/** @type {config} */
|
||||||
|
#config;
|
||||||
|
|
||||||
|
/** @type {ISlackNotifications} */
|
||||||
|
#slackNotifications;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {object} deps
|
||||||
|
* @param {import('@tryghost/domain-events')} deps.DomainEvents
|
||||||
|
* @param {config} deps.config
|
||||||
|
* @param {import('@tryghost/logging')} deps.logging
|
||||||
|
* @param {ISlackNotifications} deps.slackNotifications
|
||||||
|
*/
|
||||||
|
constructor(deps) {
|
||||||
|
this.#DomainEvents = deps.DomainEvents;
|
||||||
|
this.#logging = deps.logging;
|
||||||
|
this.#config = deps.config;
|
||||||
|
this.#slackNotifications = deps.slackNotifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {MilestoneCreatedEvent} type
|
||||||
|
* @param {object} event
|
||||||
|
* @param {object} event.data
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async #handleEvent(type, event) {
|
||||||
|
if (
|
||||||
|
type === MilestoneCreatedEvent
|
||||||
|
&& event.data.milestone
|
||||||
|
&& this.#config.isEnabled
|
||||||
|
&& this.#config.webhookUrl
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.#slackNotifications.notifyMilestoneReceived(event.data);
|
||||||
|
} catch (error) {
|
||||||
|
this.#logging.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeEvents() {
|
||||||
|
this.#DomainEvents.subscribe(MilestoneCreatedEvent, async (event) => {
|
||||||
|
await this.#handleEvent(MilestoneCreatedEvent, event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
2
ghost/slack-notifications/lib/slack-notifications.js
Normal file
2
ghost/slack-notifications/lib/slack-notifications.js
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
module.exports.SlackNotificationsService = require('./SlackNotificationsService');
|
||||||
|
module.exports.SlackNotifications = require('./SlackNotifications');
|
31
ghost/slack-notifications/package.json
Normal file
31
ghost/slack-notifications/package.json
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "@tryghost/slack-notifications",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/slack-notifications",
|
||||||
|
"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",
|
||||||
|
"@tryghost/validator": "0.2.0",
|
||||||
|
"@tryghost/version": "0.1.19",
|
||||||
|
"got": "9.6.0"
|
||||||
|
}
|
||||||
|
}
|
6
ghost/slack-notifications/test/.eslintrc.js
Normal file
6
ghost/slack-notifications/test/.eslintrc.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: ['ghost'],
|
||||||
|
extends: [
|
||||||
|
'plugin:ghost/test'
|
||||||
|
]
|
||||||
|
};
|
275
ghost/slack-notifications/test/SlackNotifications.test.js
Normal file
275
ghost/slack-notifications/test/SlackNotifications.test.js
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
const assert = require('assert');
|
||||||
|
const sinon = require('sinon');
|
||||||
|
const SlackNotifications = require('../lib/SlackNotifications');
|
||||||
|
const nock = require('nock');
|
||||||
|
const ObjectId = require('bson-objectid').default;
|
||||||
|
const got = require('got');
|
||||||
|
const ghostVersion = require('@tryghost/version');
|
||||||
|
|
||||||
|
describe('SlackNotifications', function () {
|
||||||
|
let slackNotifications;
|
||||||
|
let loggingErrorStub;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
loggingErrorStub = sinon.stub();
|
||||||
|
|
||||||
|
slackNotifications = new SlackNotifications({
|
||||||
|
logging: {
|
||||||
|
warn: () => {},
|
||||||
|
error: loggingErrorStub
|
||||||
|
},
|
||||||
|
siteUrl: 'https://ghost.example',
|
||||||
|
webhookUrl: 'https://slack-webhook.example'
|
||||||
|
});
|
||||||
|
|
||||||
|
nock('https://slack-webhook.example')
|
||||||
|
.post('/')
|
||||||
|
.reply(200, {message: 'success'});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
sinon.restore();
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('notifyMilestoneReceived', function () {
|
||||||
|
let sendStub;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
sendStub = slackNotifications.send = sinon.stub().resolves();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
sinon.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Sends a notification to Slack for achieved ARR Milestone - no meta', async function () {
|
||||||
|
await slackNotifications.notifyMilestoneReceived({
|
||||||
|
milestone: {
|
||||||
|
id: ObjectId().toHexString(),
|
||||||
|
name: 'arr-1000-usd',
|
||||||
|
type: 'arr',
|
||||||
|
createdAt: '2023-02-15T00:00:00.000Z',
|
||||||
|
emailSentAt: '2023-02-15T00:00:00.000Z',
|
||||||
|
value: 1000,
|
||||||
|
currency: 'gbp'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedResult = {
|
||||||
|
unfurl_links: false,
|
||||||
|
username: 'Ghost Milestone Service',
|
||||||
|
attachments: [{
|
||||||
|
color: '#36a64f',
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'header',
|
||||||
|
text: {
|
||||||
|
type: 'plain_text',
|
||||||
|
text: ':tada: ARR Milestone £1,000.00 reached!',
|
||||||
|
emoji: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: {
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: 'New *ARR Milestone* achieved for <https://ghost.example|https://ghost.example>'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: '*Milestone:*\n£1,000.00'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: {
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: '*Email sent:*\n15 Feb 2023'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
assert(sendStub.calledOnce === true);
|
||||||
|
assert(sendStub.calledWith(expectedResult, 'https://slack-webhook.example') === true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Sends a notification to Slack for achieved Members Milestone and shows reason when imported members', async function () {
|
||||||
|
await slackNotifications.notifyMilestoneReceived({
|
||||||
|
milestone: {
|
||||||
|
id: ObjectId().toHexString(),
|
||||||
|
name: 'members-50000',
|
||||||
|
type: 'members',
|
||||||
|
createdAt: null,
|
||||||
|
emailSentAt: null,
|
||||||
|
value: 50000
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
currentMembers: 59857,
|
||||||
|
reason: 'import'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedResult = {
|
||||||
|
unfurl_links: false,
|
||||||
|
username: 'Ghost Milestone Service',
|
||||||
|
attachments: [{
|
||||||
|
color: '#36a64f',
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'header',
|
||||||
|
text: {
|
||||||
|
type: 'plain_text',
|
||||||
|
text: ':tada: Members Milestone 50,000 reached!',
|
||||||
|
emoji: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: {
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: 'New *Members Milestone* achieved for <https://ghost.example|https://ghost.example>'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: '*Milestone:*\n50,000'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: '*Current Members:*\n59,857'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: {
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: '*Email sent:*\nno / has imported members'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
assert(sendStub.calledOnce === true);
|
||||||
|
assert(sendStub.calledWith(expectedResult, 'https://slack-webhook.example') === true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shows the correct reason for email not send when last email was too recent', async function () {
|
||||||
|
await slackNotifications.notifyMilestoneReceived({
|
||||||
|
milestone: {
|
||||||
|
id: ObjectId().toHexString(),
|
||||||
|
name: 'arr-1000-eur',
|
||||||
|
type: 'arr',
|
||||||
|
currency: 'eur',
|
||||||
|
createdAt: '2023-02-15T00:00:00.000Z',
|
||||||
|
emailSentAt: null,
|
||||||
|
value: 1000
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
currentARR: 1005,
|
||||||
|
reason: 'email'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedResult = {
|
||||||
|
unfurl_links: false,
|
||||||
|
username: 'Ghost Milestone Service',
|
||||||
|
attachments: [{
|
||||||
|
color: '#36a64f',
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'header',
|
||||||
|
text: {
|
||||||
|
type: 'plain_text',
|
||||||
|
text: ':tada: ARR Milestone €1,000.00 reached!',
|
||||||
|
emoji: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: {
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: 'New *ARR Milestone* achieved for <https://ghost.example|https://ghost.example>'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: '*Milestone:*\n€1,000.00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: '*Current ARR:*\n€1,005.00'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: {
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: '*Email sent:*\nno / last email too recent'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
assert(sendStub.calledOnce === true);
|
||||||
|
assert(sendStub.calledWith(expectedResult, 'https://slack-webhook.example') === true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('send', function () {
|
||||||
|
it('Sends with correct requestOptions', async function () {
|
||||||
|
const gotStub = sinon.stub(got, 'post').resolves();
|
||||||
|
sinon.stub(ghostVersion, 'original').value('5.0.0');
|
||||||
|
|
||||||
|
const expectedRequestOptions = [
|
||||||
|
'https://slack-webhook.com',
|
||||||
|
{
|
||||||
|
body: '{"data":"test"}',
|
||||||
|
headers: {'user-agent': 'Ghost/5.0.0 (https://github.com/TryGhost/Ghost)'}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await slackNotifications.send({data: 'test'}, 'https://slack-webhook.com');
|
||||||
|
assert(loggingErrorStub.callCount === 0);
|
||||||
|
assert(gotStub.calledOnce === true);
|
||||||
|
const gotStubArgs = gotStub.getCall(0).args;
|
||||||
|
assert.deepEqual(gotStubArgs, expectedRequestOptions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Throws when invalid URL is passed', async function () {
|
||||||
|
await slackNotifications.send({}, 'https://invalid-url');
|
||||||
|
assert(loggingErrorStub.callCount === 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Throws when no URL is passed', async function () {
|
||||||
|
await slackNotifications.send({}, null);
|
||||||
|
assert(loggingErrorStub.callCount === 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
182
ghost/slack-notifications/test/SlackNotificationsService.test.js
Normal file
182
ghost/slack-notifications/test/SlackNotificationsService.test.js
Normal file
|
@ -0,0 +1,182 @@
|
||||||
|
const assert = require('assert');
|
||||||
|
const sinon = require('sinon');
|
||||||
|
const {SlackNotificationsService} = require('../index');
|
||||||
|
const ObjectId = require('bson-objectid').default;
|
||||||
|
const {MilestoneCreatedEvent} = require('@tryghost/milestones');
|
||||||
|
const DomainEvents = require('@tryghost/domain-events');
|
||||||
|
|
||||||
|
describe('SlackNotificationsService', function () {
|
||||||
|
describe('Constructor', function () {
|
||||||
|
it('doesn\'t throw', function () {
|
||||||
|
new SlackNotificationsService({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Slack notifications service', function () {
|
||||||
|
let service;
|
||||||
|
let slackNotificationStub;
|
||||||
|
let loggingSpy;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
isEnabled: true,
|
||||||
|
webhookUrl: 'https://slack-webhook.example'
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
slackNotificationStub = sinon.stub().resolves();
|
||||||
|
loggingSpy = sinon.spy();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
sinon.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subscribeEvents', function () {
|
||||||
|
it('subscribes to events', async function () {
|
||||||
|
const subscribeStub = sinon.stub().resolves();
|
||||||
|
|
||||||
|
service = new SlackNotificationsService({
|
||||||
|
logging: {
|
||||||
|
warn: () => {},
|
||||||
|
error: loggingSpy
|
||||||
|
},
|
||||||
|
DomainEvents: {
|
||||||
|
subscribe: subscribeStub
|
||||||
|
},
|
||||||
|
siteUrl: 'https://ghost.example',
|
||||||
|
config,
|
||||||
|
slackNotifications: {
|
||||||
|
notifyMilestoneReceived: slackNotificationStub
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
service.subscribeEvents();
|
||||||
|
assert(subscribeStub.callCount === 1);
|
||||||
|
assert(subscribeStub.calledWith(MilestoneCreatedEvent) === true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles milestone created event', async function () {
|
||||||
|
service = new SlackNotificationsService({
|
||||||
|
logging: {
|
||||||
|
warn: () => {},
|
||||||
|
error: loggingSpy
|
||||||
|
},
|
||||||
|
DomainEvents,
|
||||||
|
siteUrl: 'https://ghost.example',
|
||||||
|
config,
|
||||||
|
slackNotifications: {
|
||||||
|
notifyMilestoneReceived: slackNotificationStub
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
service.subscribeEvents();
|
||||||
|
|
||||||
|
DomainEvents.dispatch(MilestoneCreatedEvent.create({
|
||||||
|
milestone: {
|
||||||
|
id: new ObjectId().toHexString(),
|
||||||
|
type: 'arr',
|
||||||
|
value: 1000,
|
||||||
|
currency: 'usd',
|
||||||
|
createdAt: new Date(),
|
||||||
|
emailSentAt: new Date()
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
currentARR: 1398
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
await DomainEvents.allSettled();
|
||||||
|
|
||||||
|
assert(loggingSpy.callCount === 0);
|
||||||
|
assert(slackNotificationStub.calledOnce);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send notification when milestones is disabled in hostSettings', async function () {
|
||||||
|
service = new SlackNotificationsService({
|
||||||
|
logging: {
|
||||||
|
warn: () => {},
|
||||||
|
error: loggingSpy
|
||||||
|
},
|
||||||
|
DomainEvents,
|
||||||
|
siteUrl: 'https://ghost.example',
|
||||||
|
config: {
|
||||||
|
isEnabled: false,
|
||||||
|
webhookUrl: 'https://slack-webhook.example'
|
||||||
|
},
|
||||||
|
slackNotifications: {
|
||||||
|
notifyMilestoneReceived: slackNotificationStub
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
service.subscribeEvents();
|
||||||
|
|
||||||
|
DomainEvents.dispatch(MilestoneCreatedEvent.create({milestone: {}}));
|
||||||
|
|
||||||
|
await DomainEvents.allSettled();
|
||||||
|
|
||||||
|
assert(loggingSpy.callCount === 0);
|
||||||
|
assert(slackNotificationStub.callCount === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send notification when no url in hostSettings provided', async function () {
|
||||||
|
service = new SlackNotificationsService({
|
||||||
|
logging: {
|
||||||
|
warn: () => {},
|
||||||
|
error: loggingSpy
|
||||||
|
},
|
||||||
|
DomainEvents,
|
||||||
|
siteUrl: 'https://ghost.example',
|
||||||
|
config: {
|
||||||
|
isEnabled: true,
|
||||||
|
webhookUrl: null
|
||||||
|
},
|
||||||
|
slackNotifications: {
|
||||||
|
notifyMilestoneReceived: slackNotificationStub
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
service.subscribeEvents();
|
||||||
|
|
||||||
|
DomainEvents.dispatch(MilestoneCreatedEvent.create({milestone: {}}));
|
||||||
|
|
||||||
|
await DomainEvents.allSettled();
|
||||||
|
|
||||||
|
assert(loggingSpy.callCount === 0);
|
||||||
|
assert(slackNotificationStub.callCount === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs error when event handling fails', async function () {
|
||||||
|
service = new SlackNotificationsService({
|
||||||
|
logging: {
|
||||||
|
warn: () => {},
|
||||||
|
error: loggingSpy
|
||||||
|
},
|
||||||
|
DomainEvents,
|
||||||
|
siteUrl: 'https://ghost.example',
|
||||||
|
config,
|
||||||
|
slackNotifications: {
|
||||||
|
async notifyMilestoneReceived() {
|
||||||
|
throw new Error('test');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
service.subscribeEvents();
|
||||||
|
|
||||||
|
DomainEvents.dispatch(MilestoneCreatedEvent.create({
|
||||||
|
milestone: {
|
||||||
|
type: 'members',
|
||||||
|
name: 'members-100',
|
||||||
|
value: 100,
|
||||||
|
createdAt: new Date()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
await DomainEvents.allSettled();
|
||||||
|
const loggingSpyCall = loggingSpy.getCall(0).args[0];
|
||||||
|
assert(loggingSpy.calledOnce);
|
||||||
|
assert(loggingSpyCall instanceof Error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
20
yarn.lock
20
yarn.lock
|
@ -4860,7 +4860,7 @@
|
||||||
moment-timezone "^0.5.23"
|
moment-timezone "^0.5.23"
|
||||||
validator "7.2.0"
|
validator "7.2.0"
|
||||||
|
|
||||||
"@tryghost/validator@^0.2.0":
|
"@tryghost/validator@0.2.0", "@tryghost/validator@^0.2.0":
|
||||||
version "0.2.0"
|
version "0.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tryghost/validator/-/validator-0.2.0.tgz#cfb0b9447cfb50901b2a2fbf8519de4d5b992f12"
|
resolved "https://registry.yarnpkg.com/@tryghost/validator/-/validator-0.2.0.tgz#cfb0b9447cfb50901b2a2fbf8519de4d5b992f12"
|
||||||
integrity sha512-sKAcyZwOCdCe7jG6B1UxzOijHjvwqwj9G9l+hQhRScT1gMT4C8zhyq7BrEQmFUvsLUXVBlpph5wn95E34oqCDw==
|
integrity sha512-sKAcyZwOCdCe7jG6B1UxzOijHjvwqwj9G9l+hQhRScT1gMT4C8zhyq7BrEQmFUvsLUXVBlpph5wn95E34oqCDw==
|
||||||
|
@ -8641,6 +8641,24 @@ bytes@3.1.2:
|
||||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
|
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
|
||||||
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
|
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
|
||||||
|
|
||||||
|
c8@7.12.0:
|
||||||
|
version "7.12.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/c8/-/c8-7.12.0.tgz#402db1c1af4af5249153535d1c84ad70c5c96b14"
|
||||||
|
integrity sha512-CtgQrHOkyxr5koX1wEUmN/5cfDa2ckbHRA4Gy5LAL0zaCFtVWJS5++n+w4/sr2GWGerBxgTjpKeDclk/Qk6W/A==
|
||||||
|
dependencies:
|
||||||
|
"@bcoe/v8-coverage" "^0.2.3"
|
||||||
|
"@istanbuljs/schema" "^0.1.3"
|
||||||
|
find-up "^5.0.0"
|
||||||
|
foreground-child "^2.0.0"
|
||||||
|
istanbul-lib-coverage "^3.2.0"
|
||||||
|
istanbul-lib-report "^3.0.0"
|
||||||
|
istanbul-reports "^3.1.4"
|
||||||
|
rimraf "^3.0.2"
|
||||||
|
test-exclude "^6.0.0"
|
||||||
|
v8-to-istanbul "^9.0.0"
|
||||||
|
yargs "^16.2.0"
|
||||||
|
yargs-parser "^20.2.9"
|
||||||
|
|
||||||
c8@7.13.0:
|
c8@7.13.0:
|
||||||
version "7.13.0"
|
version "7.13.0"
|
||||||
resolved "https://registry.yarnpkg.com/c8/-/c8-7.13.0.tgz#a2a70a851278709df5a9247d62d7f3d4bcb5f2e4"
|
resolved "https://registry.yarnpkg.com/c8/-/c8-7.13.0.tgz#a2a70a851278709df5a9247d62d7f3d4bcb5f2e4"
|
||||||
|
|
Loading…
Add table
Reference in a new issue