From 2f57e95a5d780866fc7f6303b3d0e717d3e84952 Mon Sep 17 00:00:00 2001 From: Aileen Booker Date: Fri, 17 Feb 2023 12:59:18 +0200 Subject: [PATCH] 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 --- ghost/core/core/boot.js | 4 +- .../services/slack-notifications/index.js | 1 + .../services/slack-notifications/service.js | 60 ++++ ghost/core/package.json | 1 + .../e2e-server/services/milestones.test.js | 3 - .../slack-notifications/index.test.js | 49 ++++ ghost/milestones/lib/Milestone.js | 2 +- ghost/milestones/lib/MilestonesService.js | 33 ++- .../milestones/test/MilestonesService.test.js | 53 ++-- ghost/slack-notifications/.eslintrc.js | 6 + ghost/slack-notifications/README.md | 23 ++ ghost/slack-notifications/index.js | 1 + .../lib/SlackNotifications.js | 205 +++++++++++++ .../lib/SlackNotificationsService.js | 89 ++++++ .../lib/slack-notifications.js | 2 + ghost/slack-notifications/package.json | 31 ++ ghost/slack-notifications/test/.eslintrc.js | 6 + .../test/SlackNotifications.test.js | 275 ++++++++++++++++++ .../test/SlackNotificationsService.test.js | 182 ++++++++++++ yarn.lock | 20 +- 20 files changed, 1011 insertions(+), 35 deletions(-) create mode 100644 ghost/core/core/server/services/slack-notifications/index.js create mode 100644 ghost/core/core/server/services/slack-notifications/service.js create mode 100644 ghost/core/test/unit/server/services/slack-notifications/index.test.js create mode 100644 ghost/slack-notifications/.eslintrc.js create mode 100644 ghost/slack-notifications/README.md create mode 100644 ghost/slack-notifications/index.js create mode 100644 ghost/slack-notifications/lib/SlackNotifications.js create mode 100644 ghost/slack-notifications/lib/SlackNotificationsService.js create mode 100644 ghost/slack-notifications/lib/slack-notifications.js create mode 100644 ghost/slack-notifications/package.json create mode 100644 ghost/slack-notifications/test/.eslintrc.js create mode 100644 ghost/slack-notifications/test/SlackNotifications.test.js create mode 100644 ghost/slack-notifications/test/SlackNotificationsService.test.js diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index ad0d6947fc..3be81d2b25 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -296,6 +296,7 @@ async function initServices({config}) { const mentionsService = require('./server/services/mentions'); const tagsPublic = require('./server/services/tags-public'); const postsPublic = require('./server/services/posts-public'); + const slackNotifications = require('./server/services/slack-notifications'); const urlUtils = require('./shared/url-utils'); @@ -331,7 +332,8 @@ async function initServices({config}) { }), comments.init(), linkTracking.init(), - emailSuppressionList.init() + emailSuppressionList.init(), + slackNotifications.init() ]); debug('End: Services'); diff --git a/ghost/core/core/server/services/slack-notifications/index.js b/ghost/core/core/server/services/slack-notifications/index.js new file mode 100644 index 0000000000..102ef66d4f --- /dev/null +++ b/ghost/core/core/server/services/slack-notifications/index.js @@ -0,0 +1 @@ +module.exports = require('./service'); diff --git a/ghost/core/core/server/services/slack-notifications/service.js b/ghost/core/core/server/services/slack-notifications/service.js new file mode 100644 index 0000000000..27f81dba87 --- /dev/null +++ b/ghost/core/core/server/services/slack-notifications/service.js @@ -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(); diff --git a/ghost/core/package.json b/ghost/core/package.json index d806a1d3be..9c7cbbc3cd 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -137,6 +137,7 @@ "@tryghost/tiers": "0.0.0", "@tryghost/tpl": "0.1.21", "@tryghost/update-check-service": "0.0.0", + "@tryghost/slack-notifications": "0.0.0", "@tryghost/url-utils": "4.3.0", "@tryghost/validator": "0.1.31", "@tryghost/verification-trigger": "0.0.0", diff --git a/ghost/core/test/e2e-server/services/milestones.test.js b/ghost/core/test/e2e-server/services/milestones.test.js index 7d96eb741d..8f7e17cfab 100644 --- a/ghost/core/test/e2e-server/services/milestones.test.js +++ b/ghost/core/test/e2e-server/services/milestones.test.js @@ -151,9 +151,6 @@ describe('Milestones Service', function () { beforeEach(async function () { sinon.createSandbox(); - // TODO: stub out stripe mode - // stripeModeStub = sinon.stub().returns(true); - // milestonesService.__set__('getStripeLiveEnabled', stripeModeStub); configUtils.set('milestones', milestonesConfig); mockManager.mockLabsEnabled('milestoneEmails'); }); diff --git a/ghost/core/test/unit/server/services/slack-notifications/index.test.js b/ghost/core/test/unit/server/services/slack-notifications/index.test.js new file mode 100644 index 0000000000..dff6c1d3c1 --- /dev/null +++ b/ghost/core/test/unit/server/services/slack-notifications/index.test.js @@ -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); + }); +}); diff --git a/ghost/milestones/lib/Milestone.js b/ghost/milestones/lib/Milestone.js index 79589f6b0a..999a5eba7f 100644 --- a/ghost/milestones/lib/Milestone.js +++ b/ghost/milestones/lib/Milestone.js @@ -131,7 +131,7 @@ module.exports = class Milestone { }); if (isNew) { - milestone.events.push(MilestoneCreatedEvent.create({milestone})); + milestone.events.push(MilestoneCreatedEvent.create({milestone, meta: data?.meta})); } return milestone; diff --git a/ghost/milestones/lib/MilestonesService.js b/ghost/milestones/lib/MilestonesService.js index 25d110dc95..9fe378f366 100644 --- a/ghost/milestones/lib/MilestonesService.js +++ b/ghost/milestones/lib/MilestonesService.js @@ -127,44 +127,55 @@ module.exports = class MilestonesService { * @param {object} milestone * @param {number} milestone.value * @param {'arr'|'members'} milestone.type + * @param {object} milestone.meta * @param {string|null} [milestone.currency] * @param {Date|null} [milestone.emailSentAt] * * @returns {Promise} */ async #saveMileStoneAndSendEmail(milestone) { - const shouldSendEmail = await this.#shouldSendEmail(); + const {shouldSendEmail, reason} = await this.#shouldSendEmail(); if (shouldSendEmail) { milestone.emailSentAt = new Date(); } + if (reason) { + milestone.meta.reason = reason; + } + return await this.#createMilestone(milestone); } /** * - * @returns {Promise} + * @returns {Promise<{shouldSendEmail: boolean, reason: string}>} */ async #shouldSendEmail() { - let shouldSendEmail; + let canHaveEmail; + let reason = null; // 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; + canHaveEmail = true; } else { const differenceInTime = new Date().getTime() - new Date(lastMilestoneSent.emailSentAt).getTime(); const differenceInDays = differenceInTime / (1000 * 3600 * 24); - shouldSendEmail = differenceInDays >= 14; + canHaveEmail = differenceInDays >= 14; } 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 (!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 (!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}); } } } diff --git a/ghost/milestones/test/MilestonesService.test.js b/ghost/milestones/test/MilestonesService.test.js index 33ac10edfc..928302f9fe 100644 --- a/ghost/milestones/test/MilestonesService.test.js +++ b/ghost/milestones/test/MilestonesService.test.js @@ -9,10 +9,10 @@ const sinon = require('sinon'); describe('MilestonesService', function () { let repository; - let domainEventsSpy; + let domainEventSpy; beforeEach(async function () { - domainEventsSpy = sinon.spy(DomainEvents, 'dispatch'); + domainEventSpy = sinon.spy(DomainEvents, 'dispatch'); }); afterEach(function () { @@ -68,7 +68,11 @@ describe('MilestonesService', function () { assert(arrResult.value === 1000); assert(arrResult.emailSentAt !== null); 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 () { @@ -100,7 +104,7 @@ describe('MilestonesService', function () { await repository.save(milestoneTwo); await repository.save(milestoneThree); - assert(domainEventsSpy.callCount === 3); + assert(domainEventSpy.callCount === 3); const milestoneEmailService = new MilestonesService({ repository, @@ -125,7 +129,10 @@ describe('MilestonesService', function () { assert(arrResult.value === 10000); assert(arrResult.emailSentAt !== null); 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 () { @@ -149,7 +156,7 @@ describe('MilestonesService', function () { const arrResult = await milestoneEmailService.checkMilestones('arr'); assert(arrResult === undefined); - assert(domainEventsSpy.callCount === 0); + assert(domainEventSpy.callCount === 0); }); it('Does not add new ARR milestone if already achieved', async function () { @@ -163,7 +170,7 @@ describe('MilestonesService', function () { await repository.save(milestone); - assert(domainEventsSpy.callCount === 1); + assert(domainEventSpy.callCount === 1); const milestoneEmailService = new MilestonesService({ repository, @@ -183,7 +190,7 @@ describe('MilestonesService', function () { const arrResult = await milestoneEmailService.checkMilestones('arr'); 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 () { @@ -210,7 +217,9 @@ describe('MilestonesService', function () { assert(arrResult.currency === 'usd'); assert(arrResult.value === 100000); 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 () { @@ -227,7 +236,7 @@ describe('MilestonesService', function () { }); await repository.save(milestone); - assert(domainEventsSpy.callCount === 1); + assert(domainEventSpy.callCount === 1); const milestoneEmailService = new MilestonesService({ repository, @@ -237,7 +246,7 @@ describe('MilestonesService', function () { return [{currency: 'idr', arr: 10000}]; }, async hasImportedMembersInPeriod() { - return true; + return false; }, async getDefaultCurrency() { return 'idr'; @@ -250,7 +259,9 @@ describe('MilestonesService', function () { assert(arrResult.currency === 'idr'); assert(arrResult.value === 10000); 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.value === 100); assert(membersResult.emailSentAt !== null); - assert(domainEventsSpy.callCount === 1); + assert(domainEventSpy.callCount === 1); }); it('Adds next Members milestone and sends email', async function () { @@ -309,7 +320,7 @@ describe('MilestonesService', function () { await repository.save(milestoneTwo); await repository.save(milestoneThree); - assert(domainEventsSpy.callCount === 3); + assert(domainEventSpy.callCount === 3); const milestoneEmailService = new MilestonesService({ repository, @@ -333,7 +344,7 @@ describe('MilestonesService', function () { assert(membersResult.value === 50000); assert(membersResult.emailSentAt !== null); 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 () { @@ -346,7 +357,7 @@ describe('MilestonesService', function () { await repository.save(milestone); - assert(domainEventsSpy.callCount === 1); + assert(domainEventSpy.callCount === 1); const milestoneEmailService = new MilestonesService({ repository, @@ -366,7 +377,7 @@ describe('MilestonesService', function () { const membersResult = await milestoneEmailService.checkMilestones('members'); 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 () { @@ -379,7 +390,7 @@ describe('MilestonesService', function () { await repository.save(milestone); - assert(domainEventsSpy.callCount === 1); + assert(domainEventSpy.callCount === 1); const milestoneEmailService = new MilestonesService({ repository, @@ -401,7 +412,7 @@ describe('MilestonesService', function () { assert(membersResult.type === 'members'); assert(membersResult.value === 1000); 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 () { @@ -418,7 +429,7 @@ describe('MilestonesService', function () { await repository.save(milestone); - assert(domainEventsSpy.callCount === 1); + assert(domainEventSpy.callCount === 1); const milestoneEmailService = new MilestonesService({ repository, @@ -440,7 +451,7 @@ describe('MilestonesService', function () { assert(membersResult.type === 'members'); assert(membersResult.value === 50000); assert(membersResult.emailSentAt === null); - assert(domainEventsSpy.callCount === 2); + assert(domainEventSpy.callCount === 2); }); }); }); diff --git a/ghost/slack-notifications/.eslintrc.js b/ghost/slack-notifications/.eslintrc.js new file mode 100644 index 0000000000..c9c1bcb522 --- /dev/null +++ b/ghost/slack-notifications/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node' + ] +}; diff --git a/ghost/slack-notifications/README.md b/ghost/slack-notifications/README.md new file mode 100644 index 0000000000..c7ce8a33a5 --- /dev/null +++ b/ghost/slack-notifications/README.md @@ -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 + diff --git a/ghost/slack-notifications/index.js b/ghost/slack-notifications/index.js new file mode 100644 index 0000000000..29b5f94d45 --- /dev/null +++ b/ghost/slack-notifications/index.js @@ -0,0 +1 @@ +module.exports = require('./lib/slack-notifications'); diff --git a/ghost/slack-notifications/lib/SlackNotifications.js b/ghost/slack-notifications/lib/SlackNotifications.js new file mode 100644 index 0000000000..049b22a729 --- /dev/null +++ b/ghost/slack-notifications/lib/SlackNotifications.js @@ -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} + */ + 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} + */ + 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; diff --git a/ghost/slack-notifications/lib/SlackNotificationsService.js b/ghost/slack-notifications/lib/SlackNotificationsService.js new file mode 100644 index 0000000000..3d1a82dc32 --- /dev/null +++ b/ghost/slack-notifications/lib/SlackNotificationsService.js @@ -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.} notifyMilestoneReceived + * @prop {(slackData: object, url: URL) => Promise} 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} + */ + 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); + }); + } +}; diff --git a/ghost/slack-notifications/lib/slack-notifications.js b/ghost/slack-notifications/lib/slack-notifications.js new file mode 100644 index 0000000000..d3fa82081a --- /dev/null +++ b/ghost/slack-notifications/lib/slack-notifications.js @@ -0,0 +1,2 @@ +module.exports.SlackNotificationsService = require('./SlackNotificationsService'); +module.exports.SlackNotifications = require('./SlackNotifications'); diff --git a/ghost/slack-notifications/package.json b/ghost/slack-notifications/package.json new file mode 100644 index 0000000000..f7fc46c61b --- /dev/null +++ b/ghost/slack-notifications/package.json @@ -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" + } +} diff --git a/ghost/slack-notifications/test/.eslintrc.js b/ghost/slack-notifications/test/.eslintrc.js new file mode 100644 index 0000000000..829b601eb0 --- /dev/null +++ b/ghost/slack-notifications/test/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ] +}; diff --git a/ghost/slack-notifications/test/SlackNotifications.test.js b/ghost/slack-notifications/test/SlackNotifications.test.js new file mode 100644 index 0000000000..41a7375b74 --- /dev/null +++ b/ghost/slack-notifications/test/SlackNotifications.test.js @@ -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 ' + } + }, + { + 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 ' + } + }, + { + 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 ' + } + }, + { + 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); + }); + }); +}); diff --git a/ghost/slack-notifications/test/SlackNotificationsService.test.js b/ghost/slack-notifications/test/SlackNotificationsService.test.js new file mode 100644 index 0000000000..e4f942ae9a --- /dev/null +++ b/ghost/slack-notifications/test/SlackNotificationsService.test.js @@ -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); + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 946f9a856e..65264b1d70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4860,7 +4860,7 @@ moment-timezone "^0.5.23" validator "7.2.0" -"@tryghost/validator@^0.2.0": +"@tryghost/validator@0.2.0", "@tryghost/validator@^0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@tryghost/validator/-/validator-0.2.0.tgz#cfb0b9447cfb50901b2a2fbf8519de4d5b992f12" integrity sha512-sKAcyZwOCdCe7jG6B1UxzOijHjvwqwj9G9l+hQhRScT1gMT4C8zhyq7BrEQmFUvsLUXVBlpph5wn95E34oqCDw== @@ -8641,6 +8641,24 @@ bytes@3.1.2: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" 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: version "7.13.0" resolved "https://registry.yarnpkg.com/c8/-/c8-7.13.0.tgz#a2a70a851278709df5a9247d62d7f3d4bcb5f2e4"