Skip to content

Commit 06b62ae

Browse files
authored
Started scheduling automation polls with scheduler (TryGhost#27437)
closes https://linear.app/ghost/issue/NY-1191 ref TryGhost#27421 ref TryGhost#27519 Before, we used `setTimeout` to schedule automation polls (with a TODO). Now, we properly use the scheduler.
1 parent 1c63582 commit 06b62ae

11 files changed

Lines changed: 334 additions & 38 deletions

File tree

ghost/core/core/boot.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,8 @@ async function initServices() {
339339
const emailAddressService = require('./server/services/email-address');
340340
const statsService = require('./server/services/stats');
341341
const explorePingService = require('./server/services/explore-ping');
342+
const domainEvents = require('@tryghost/domain-events');
343+
const WelcomeEmailAutomationsService = require('./server/services/welcome-email-automations');
342344

343345
const {
344346
createAdapter: createSchedulerAdapter,
@@ -347,6 +349,7 @@ async function initServices() {
347349
const urlUtils = require('./shared/url-utils');
348350

349351
// Initialize things that other services depend on first.
352+
const apiUrl = urlUtils.urlFor('api', {type: 'admin'}, true);
350353
const schedulerAdapter = createSchedulerAdapter();
351354
const [schedulerIntegration] = await Promise.all([
352355
getSchedulerIntegration(),
@@ -372,7 +375,7 @@ async function initServices() {
372375
emailAnalytics.init(),
373376
webhooks.listen(),
374377
postScheduling.init({
375-
apiUrl: urlUtils.urlFor('api', {type: 'admin'}, true),
378+
apiUrl,
376379
adapter: schedulerAdapter,
377380
integration: schedulerIntegration
378381
}),
@@ -385,7 +388,13 @@ async function initServices() {
385388
recommendationsService.init(),
386389
statsService.init(),
387390
explorePingService.init(),
388-
giftService.init()
391+
giftService.init(),
392+
new WelcomeEmailAutomationsService().init({
393+
domainEvents,
394+
apiUrl,
395+
schedulerAdapter,
396+
schedulerIntegration
397+
})
389398
]);
390399

391400
debug('End: Services');
@@ -431,10 +440,6 @@ async function initBackgroundServices({config}) {
431440
const outboxService = require('./server/services/outbox');
432441
outboxService.init();
433442

434-
const domainEvents = require('@tryghost/domain-events');
435-
const WelcomeEmailAutomationsService = require('./server/services/welcome-email-automations');
436-
new WelcomeEmailAutomationsService().init(domainEvents);
437-
438443
debug('End: initBackgroundServices');
439444
}
440445

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const domainEvents = require('@tryghost/domain-events');
2+
const StartAutomationsPollEvent = require('../../services/welcome-email-automations/events/start-automations-poll-event');
3+
4+
/** @type {import('@tryghost/api-framework').Controller} */
5+
const controller = {
6+
docName: 'automations',
7+
8+
poll: {
9+
statusCode: 204,
10+
headers: {
11+
cacheInvalidate: false
12+
},
13+
permissions: {
14+
docName: 'automations',
15+
method: 'poll'
16+
},
17+
query() {
18+
domainEvents.dispatch(StartAutomationsPollEvent.create());
19+
}
20+
}
21+
};
22+
23+
module.exports = controller;

ghost/core/core/server/api/endpoints/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ const localUtils = require('./utils');
88
/* eslint-disable max-lines */
99

1010
module.exports = {
11+
get automations() {
12+
return apiFramework.pipeline(require('./automations'), localUtils);
13+
},
14+
1115
get authentication() {
1216
return apiFramework.pipeline(require('./authentication'), localUtils);
1317
},

ghost/core/core/server/services/welcome-email-automations/index.js

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,45 @@
11
// @ts-check
2+
const urlUtils = require('../../../shared/url-utils');
23
const {oneAtATime} = require('../../../shared/one-at-a-time');
4+
const {getSignedAdminToken} = require('../../adapters/scheduling/utils');
35
const StartAutomationsPollEvent = require('./events/start-automations-poll-event');
46
const {poll} = require('./poll');
57
const memberWelcomeEmailService = require('../member-welcome-emails/service');
68
/** @import DomainEvents from '@tryghost/domain-events' */
79

10+
/**
11+
* @internal
12+
* @typedef {object} SchedulerAdapter
13+
* @prop {(job: {
14+
* time: number;
15+
* url: string;
16+
* extra: {
17+
* httpMethod: string;
18+
* };
19+
* }) => void} schedule
20+
*/
21+
22+
/**
23+
* @internal
24+
* @typedef {object} SchedulerIntegration
25+
* @prop {Array<{
26+
* id: string;
27+
* secret: string;
28+
* }>} api_keys
29+
*/
30+
831
class WelcomeEmailAutomationsService {
932
#initialized = false;
1033

1134
/**
12-
* @param {Pick<DomainEvents, 'dispatch' | 'subscribe'>} domainEvents
35+
* @param {object} options
36+
* @param {Pick<DomainEvents, 'dispatch' | 'subscribe'>} options.domainEvents
37+
* @param {string} options.apiUrl
38+
* @param {SchedulerAdapter} options.schedulerAdapter
39+
* @param {SchedulerIntegration} options.schedulerIntegration
1340
* @returns {void}
1441
*/
15-
init(domainEvents) {
42+
init({domainEvents, apiUrl, schedulerAdapter, schedulerIntegration}) {
1643
if (this.#initialized) {
1744
return;
1845
}
@@ -25,8 +52,20 @@ class WelcomeEmailAutomationsService {
2552
* @param {Readonly<Date>} date
2653
*/
2754
const enqueuePollAt = (date) => {
28-
// TODO(NY-1191): Use Scheduler instead of `setTimeout`.
29-
setTimeout(enqueuePollNow, date.getTime() - Date.now());
55+
const signedAdminToken = getSignedAdminToken({
56+
publishedAt: date.toISOString(),
57+
apiUrl,
58+
integration: schedulerIntegration
59+
});
60+
const url = new URL(urlUtils.urlJoin(apiUrl, 'automations', 'poll'));
61+
url.searchParams.set('token', signedAdminToken);
62+
schedulerAdapter.schedule({
63+
time: date.getTime(),
64+
url: url.toString(),
65+
extra: {
66+
httpMethod: 'PUT'
67+
}
68+
});
3069
};
3170

3271
domainEvents.subscribe(StartAutomationsPollEvent, oneAtATime(async () => {

ghost/core/core/server/web/api/endpoints/admin/middleware.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const tokenPermissionCheck = function tokenPermissionCheck(req, res, next) {
6060
tiers: ['GET', 'PUT', 'POST'],
6161
offers: ['GET', 'PUT', 'POST'],
6262
newsletters: ['GET', 'PUT', 'POST'],
63+
automations: ['PUT'],
6364
config: ['GET'],
6465
explore: ['GET'],
6566
schedules: ['PUT'],

ghost/core/core/server/web/api/endpoints/admin/routes.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@ module.exports = function apiRoutes() {
182182
router.put('/labels/:id', mw.authAdminApi, http(api.labels.edit));
183183
router.delete('/labels/:id', mw.authAdminApi, http(api.labels.destroy));
184184

185+
// ## Automations
186+
router.put('/automations/poll', mw.authAdminApiWithUrl, http(api.automations.poll));
187+
185188
// ## Automated Emails
186189
router.get('/automated_emails', mw.authAdminApi, http(api.automatedEmails.browse));
187190
router.get('/automated_emails/design', mw.authAdminApi, http(api.automatedEmailDesign.read));
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`Automations API poll does not poll when request lacks a token 1: [body] 1`] = `
4+
Object {
5+
"errors": Array [
6+
Object {
7+
"code": "INVALID_JWT",
8+
"context": null,
9+
"details": null,
10+
"ghostErrorCode": null,
11+
"help": null,
12+
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
13+
"message": "Invalid token: No token found in URL",
14+
"property": null,
15+
"type": "UnauthorizedError",
16+
},
17+
],
18+
}
19+
`;
20+
21+
exports[`Automations API poll does not poll when request lacks a token 2: [headers] 1`] = `
22+
Object {
23+
"access-control-allow-origin": "http://127.0.0.1:2369",
24+
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
25+
"content-length": "235",
26+
"content-type": "application/json; charset=utf-8",
27+
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
28+
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
29+
"vary": "Accept-Version, Origin, Accept-Encoding",
30+
"x-powered-by": "Express",
31+
}
32+
`;
33+
34+
exports[`Automations API poll does not poll when request token is invalid 1: [body] 1`] = `
35+
Object {
36+
"errors": Array [
37+
Object {
38+
"code": "INVALID_JWT",
39+
"context": null,
40+
"details": null,
41+
"ghostErrorCode": null,
42+
"help": null,
43+
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
44+
"message": "Invalid token: jwt audience invalid. expected: /\\\\/?admin\\\\/?$/",
45+
"property": null,
46+
"type": "UnauthorizedError",
47+
},
48+
],
49+
}
50+
`;
51+
52+
exports[`Automations API poll does not poll when request token is invalid 2: [headers] 1`] = `
53+
Object {
54+
"access-control-allow-origin": "http://127.0.0.1:2369",
55+
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
56+
"content-length": "262",
57+
"content-type": "application/json; charset=utf-8",
58+
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
59+
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
60+
"vary": "Accept-Version, Origin, Accept-Encoding",
61+
"x-powered-by": "Express",
62+
}
63+
`;
64+
65+
exports[`Automations API poll triggers a poll with a valid scheduler integration token 1: [headers] 1`] = `
66+
Object {
67+
"access-control-allow-origin": "http://127.0.0.1:2369",
68+
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
69+
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
70+
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
71+
"vary": "Accept-Version, Origin",
72+
"x-powered-by": "Express",
73+
}
74+
`;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
const sinon = require('sinon');
2+
const domainEvents = require('@tryghost/domain-events');
3+
const models = require('../../../core/server/models');
4+
const {getSignedAdminToken} = require('../../../core/server/adapters/scheduling/utils');
5+
const {agentProvider, fixtureManager, matchers, assertions} = require('../../utils/e2e-framework');
6+
const StartAutomationsPollEvent = require('../../../core/server/services/welcome-email-automations/events/start-automations-poll-event');
7+
8+
const {anyContentVersion, anyEtag, anyErrorId} = matchers;
9+
const {cacheInvalidateHeaderNotSet} = assertions;
10+
11+
describe('Automations API', function () {
12+
let agent;
13+
let schedulerIntegration;
14+
let schedulerToken;
15+
16+
before(async function () {
17+
agent = await agentProvider.getAdminAPIAgent();
18+
await fixtureManager.init('integrations', 'api_keys');
19+
20+
schedulerIntegration = await models.Integration.findOne(
21+
{slug: 'ghost-scheduler'},
22+
{withRelated: 'api_keys'}
23+
);
24+
25+
schedulerToken = getSignedAdminToken({
26+
publishedAt: new Date().toISOString(),
27+
apiUrl: '/admin/',
28+
integration: schedulerIntegration.toJSON()
29+
});
30+
});
31+
32+
afterEach(function () {
33+
sinon.restore();
34+
});
35+
36+
describe('poll', function () {
37+
/** @type {sinon.SinonStub} */
38+
let dispatchStub;
39+
40+
beforeEach(function () {
41+
dispatchStub = sinon.stub(domainEvents, 'dispatch');
42+
});
43+
44+
it('does not poll when request lacks a token', async function () {
45+
await agent
46+
.put('automations/poll/')
47+
.expectStatus(401)
48+
.expect(cacheInvalidateHeaderNotSet())
49+
.matchHeaderSnapshot({
50+
'content-version': anyContentVersion,
51+
etag: anyEtag
52+
})
53+
.matchBodySnapshot({
54+
errors: [{
55+
id: anyErrorId,
56+
message: 'Invalid token: No token found in URL'
57+
}]
58+
});
59+
60+
sinon.assert.notCalled(dispatchStub);
61+
});
62+
63+
it('does not poll when request token is invalid', async function () {
64+
const invalidSchedulerToken = getSignedAdminToken({
65+
publishedAt: new Date().toISOString(),
66+
apiUrl: '/members/',
67+
integration: schedulerIntegration.toJSON()
68+
});
69+
70+
await agent
71+
.put(`automations/poll/?token=${invalidSchedulerToken}`)
72+
.expectStatus(401)
73+
.expect(cacheInvalidateHeaderNotSet())
74+
.matchHeaderSnapshot({
75+
'content-version': anyContentVersion,
76+
etag: anyEtag
77+
})
78+
.matchBodySnapshot({
79+
errors: [{
80+
id: anyErrorId
81+
}]
82+
});
83+
84+
sinon.assert.notCalled(dispatchStub);
85+
});
86+
87+
it('triggers a poll with a valid scheduler integration token', async function () {
88+
await agent
89+
.put(`automations/poll/?token=${schedulerToken}`)
90+
.expectStatus(204)
91+
.expectEmptyBody()
92+
.expect(cacheInvalidateHeaderNotSet())
93+
.matchHeaderSnapshot({
94+
'content-version': anyContentVersion,
95+
etag: anyEtag
96+
});
97+
98+
sinon.assert.calledOnceWithExactly(dispatchStub, sinon.match.instanceOf(StartAutomationsPollEvent));
99+
});
100+
});
101+
});

0 commit comments

Comments
 (0)