From 2eb8a652164f925ebd92b0a0b40dc98c2bfcbbd0 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 3 Jun 2026 12:33:19 -0500 Subject: [PATCH] Moved automations service to separate file towards https://linear.app/ghost/issue/NY-1286 ref https://github.com/TryGhost/Ghost/pull/28120 This change should have no impact on functionality. We have [a lint rule that limits the length of `index.js` files][0]. In [an upcoming change][1], the automations service will cross that limit. This patch moves it into a separate file. Doing so also makes it a little easier to test. [0]: https://github.com/TryGhost/eslint-plugin-ghost/blob/22d154a8e3807e4e30219fb8449e7aa9b90a110b/lib/config/node.js#L32-L37 [1]: https://github.com/TryGhost/Ghost/pull/28120 --- .../core/server/services/automations/index.js | 88 +----------------- .../server/services/automations/service.js | 89 +++++++++++++++++++ .../{index.test.js => service.test.js} | 7 +- 3 files changed, 92 insertions(+), 92 deletions(-) create mode 100644 ghost/core/core/server/services/automations/service.js rename ghost/core/test/unit/server/services/automations/{index.test.js => service.test.js} (90%) diff --git a/ghost/core/core/server/services/automations/index.js b/ghost/core/core/server/services/automations/index.js index 4f4227510b7..a907ac85217 100644 --- a/ghost/core/core/server/services/automations/index.js +++ b/ghost/core/core/server/services/automations/index.js @@ -1,89 +1,3 @@ -// @ts-check -const urlUtils = require('../../../shared/url-utils'); -const {oneAtATime} = require('../../../shared/one-at-a-time'); -const logging = require('@tryghost/logging'); -const {getSignedAdminToken} = require('../../adapters/scheduling/utils'); -const StartAutomationsPollEvent = require('./events/start-automations-poll-event'); -const {poll} = require('./poll'); -const {welcomeEmailAutomationPoll} = require('./welcome-email-automation-poll'); -const memberWelcomeEmailService = require('../member-welcome-emails/service'); -/** @import DomainEvents from '@tryghost/domain-events' */ - -/** - * @internal - * @typedef {object} SchedulerAdapter - * @prop {(job: { - * time: number; - * url: string; - * extra: { - * httpMethod: string; - * }; - * }) => void} schedule - * @prop {(rescheduler: {rescheduleAll: () => unknown}) => void} register - */ - -class AutomationsService { - #initialized = false; - #enqueuePollNow; - - /** - * @param {object} options - * @param {Pick} options.domainEvents - * @param {string} options.apiUrl - * @param {SchedulerAdapter} options.schedulerAdapter - * @param {ReadonlyMap>} options.internalKeys - * @returns {void} - */ - init({domainEvents, apiUrl, schedulerAdapter, internalKeys}) { - if (this.#initialized) { - return; - } - - this.#enqueuePollNow = () => domainEvents.dispatch(StartAutomationsPollEvent.create()); - - /** @param {Readonly} date */ - const enqueuePollAt = async (date) => { - const isRequestedDateInTheFuture = new Date() < date; - if (!isRequestedDateInTheFuture) { - this.#enqueuePollNow(); - return; - } - - try { - const key = await internalKeys.get('ghost-scheduler'); - const signedAdminToken = getSignedAdminToken({publishedAt: date.toISOString(), apiUrl, key}); - const url = new URL(urlUtils.urlJoin(apiUrl, 'automations', 'poll')); - url.searchParams.set('token', signedAdminToken); - schedulerAdapter.schedule({time: date.getTime(), url: url.toString(), extra: {httpMethod: 'PUT'}}); - } catch (err) { - logging.error({event: {name: 'automations.enqueue-poll.error'}, err, at: date.toISOString()}, 'Failed to enqueue automations poll'); - } - }; - - domainEvents.subscribe(StartAutomationsPollEvent, oneAtATime(async () => poll({ - enqueueAnotherPollAt: enqueuePollAt - }))); - - domainEvents.subscribe(StartAutomationsPollEvent, oneAtATime(async () => welcomeEmailAutomationPoll({ - memberWelcomeEmailService, - enqueueAnotherPollAt: enqueuePollAt - }))); - - schedulerAdapter.register(this); - - enqueuePollAt(new Date()); - - this.#initialized = true; - } - - /** - * Re-arm the poll chain. A queued poll signed under the previous scheduler - * key fails JWT verification when fired; this dispatches a fresh in-process - * poll that re-schedules the next callback under the current key. - */ - rescheduleAll() { - this.#enqueuePollNow?.(); - } -} +const AutomationsService = require('./service'); module.exports = new AutomationsService(); diff --git a/ghost/core/core/server/services/automations/service.js b/ghost/core/core/server/services/automations/service.js new file mode 100644 index 00000000000..2da4e7311cd --- /dev/null +++ b/ghost/core/core/server/services/automations/service.js @@ -0,0 +1,89 @@ +// @ts-check +const urlUtils = require('../../../shared/url-utils'); +const {oneAtATime} = require('../../../shared/one-at-a-time'); +const logging = require('@tryghost/logging'); +const {getSignedAdminToken} = require('../../adapters/scheduling/utils'); +const StartAutomationsPollEvent = require('./events/start-automations-poll-event'); +const {poll} = require('./poll'); +const {welcomeEmailAutomationPoll} = require('./welcome-email-automation-poll'); +const memberWelcomeEmailService = require('../member-welcome-emails/service'); +/** @import DomainEvents from '@tryghost/domain-events' */ + +/** + * @internal + * @typedef {object} SchedulerAdapter + * @prop {(job: { + * time: number; + * url: string; + * extra: { + * httpMethod: string; + * }; + * }) => void} schedule + * @prop {(rescheduler: {rescheduleAll: () => unknown}) => void} register + */ + +class AutomationsService { + #initialized = false; + #enqueuePollNow; + + /** + * @param {object} options + * @param {Pick} options.domainEvents + * @param {string} options.apiUrl + * @param {SchedulerAdapter} options.schedulerAdapter + * @param {ReadonlyMap>} options.internalKeys + * @returns {void} + */ + init({domainEvents, apiUrl, schedulerAdapter, internalKeys}) { + if (this.#initialized) { + return; + } + + this.#enqueuePollNow = () => domainEvents.dispatch(StartAutomationsPollEvent.create()); + + /** @param {Readonly} date */ + const enqueuePollAt = async (date) => { + const isRequestedDateInTheFuture = new Date() < date; + if (!isRequestedDateInTheFuture) { + this.#enqueuePollNow(); + return; + } + + try { + const key = await internalKeys.get('ghost-scheduler'); + const signedAdminToken = getSignedAdminToken({publishedAt: date.toISOString(), apiUrl, key}); + const url = new URL(urlUtils.urlJoin(apiUrl, 'automations', 'poll')); + url.searchParams.set('token', signedAdminToken); + schedulerAdapter.schedule({time: date.getTime(), url: url.toString(), extra: {httpMethod: 'PUT'}}); + } catch (err) { + logging.error({event: {name: 'automations.enqueue-poll.error'}, err, at: date.toISOString()}, 'Failed to enqueue automations poll'); + } + }; + + domainEvents.subscribe(StartAutomationsPollEvent, oneAtATime(async () => poll({ + enqueueAnotherPollAt: enqueuePollAt + }))); + + domainEvents.subscribe(StartAutomationsPollEvent, oneAtATime(async () => welcomeEmailAutomationPoll({ + memberWelcomeEmailService, + enqueueAnotherPollAt: enqueuePollAt + }))); + + schedulerAdapter.register(this); + + enqueuePollAt(new Date()); + + this.#initialized = true; + } + + /** + * Re-arm the poll chain. A queued poll signed under the previous scheduler + * key fails JWT verification when fired; this dispatches a fresh in-process + * poll that re-schedules the next callback under the current key. + */ + rescheduleAll() { + this.#enqueuePollNow?.(); + } +} + +module.exports = AutomationsService; diff --git a/ghost/core/test/unit/server/services/automations/index.test.js b/ghost/core/test/unit/server/services/automations/service.test.js similarity index 90% rename from ghost/core/test/unit/server/services/automations/index.test.js rename to ghost/core/test/unit/server/services/automations/service.test.js index 37e8d49c1a6..c17581b8336 100644 --- a/ghost/core/test/unit/server/services/automations/index.test.js +++ b/ghost/core/test/unit/server/services/automations/service.test.js @@ -1,8 +1,7 @@ const sinon = require('sinon'); const StartAutomationsPollEvent = require('../../../../../core/server/services/automations/events/start-automations-poll-event'); - -const automationsModulePath = require.resolve('../../../../../core/server/services/automations'); +const AutomationsService = require('../../../../../core/server/services/automations/service'); describe('automations service', function () { let automations; @@ -11,9 +10,7 @@ describe('automations service', function () { let initOptions; beforeEach(function () { - // Reset the module-level singleton between tests. - delete require.cache[automationsModulePath]; - automations = require(automationsModulePath); + automations = new AutomationsService(); domainEvents = { dispatch: sinon.stub(), subscribe: sinon.stub()