Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"@faker-js/faker": "8.4.1",
"@playwright/test": "1.59.1",
"@tryghost/debug": "2.1.0",
"@tryghost/logging": "2.5.5",
"@tryghost/logging": "4.1.0",
"@types/dockerode": "3.3.47",
"@types/express": "4.17.25",
"busboy": "^1.6.0",
Expand Down
17 changes: 11 additions & 6 deletions ghost/core/core/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@ async function initServices() {
const emailAddressService = require('./server/services/email-address');
const statsService = require('./server/services/stats');
const explorePingService = require('./server/services/explore-ping');
const domainEvents = require('@tryghost/domain-events');
const WelcomeEmailAutomationsService = require('./server/services/welcome-email-automations');

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

// Initialize things that other services depend on first.
const apiUrl = urlUtils.urlFor('api', {type: 'admin'}, true);
const schedulerAdapter = createSchedulerAdapter();
const [schedulerIntegration] = await Promise.all([
getSchedulerIntegration(),
Expand All @@ -372,7 +375,7 @@ async function initServices() {
emailAnalytics.init(),
webhooks.listen(),
postScheduling.init({
apiUrl: urlUtils.urlFor('api', {type: 'admin'}, true),
apiUrl,
adapter: schedulerAdapter,
integration: schedulerIntegration
}),
Expand All @@ -385,7 +388,13 @@ async function initServices() {
recommendationsService.init(),
statsService.init(),
explorePingService.init(),
giftService.init()
giftService.init(),
new WelcomeEmailAutomationsService().init({
domainEvents,
apiUrl,
schedulerAdapter,
schedulerIntegration
})
]);

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

const domainEvents = require('@tryghost/domain-events');
const WelcomeEmailAutomationsService = require('./server/services/welcome-email-automations');
new WelcomeEmailAutomationsService().init(domainEvents);

debug('End: initBackgroundServices');
}

Expand Down
23 changes: 23 additions & 0 deletions ghost/core/core/server/api/endpoints/automations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const domainEvents = require('@tryghost/domain-events');
const StartAutomationsPollEvent = require('../../services/welcome-email-automations/events/start-automations-poll-event');

/** @type {import('@tryghost/api-framework').Controller} */
const controller = {
docName: 'automations',

poll: {
statusCode: 204,
headers: {
cacheInvalidate: false
},
permissions: {
docName: 'automations',
method: 'poll'
},
query() {
domainEvents.dispatch(StartAutomationsPollEvent.create());
}
}
};

module.exports = controller;
4 changes: 4 additions & 0 deletions ghost/core/core/server/api/endpoints/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ const localUtils = require('./utils');
/* eslint-disable max-lines */

module.exports = {
get automations() {
return apiFramework.pipeline(require('./automations'), localUtils);
},

get authentication() {
return apiFramework.pipeline(require('./authentication'), localUtils);
},
Expand Down
47 changes: 43 additions & 4 deletions ghost/core/core/server/services/welcome-email-automations/index.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,45 @@
// @ts-check
const urlUtils = require('../../../shared/url-utils');
const {oneAtATime} = require('../../../shared/one-at-a-time');
const {getSignedAdminToken} = require('../../adapters/scheduling/utils');
const StartAutomationsPollEvent = require('./events/start-automations-poll-event');
const {poll} = require('./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
*/

/**
* @internal
* @typedef {object} SchedulerIntegration
* @prop {Array<{
* id: string;
* secret: string;
* }>} api_keys
*/

class WelcomeEmailAutomationsService {
#initialized = false;

/**
* @param {Pick<DomainEvents, 'dispatch' | 'subscribe'>} domainEvents
* @param {object} options
* @param {Pick<DomainEvents, 'dispatch' | 'subscribe'>} options.domainEvents
* @param {string} options.apiUrl
* @param {SchedulerAdapter} options.schedulerAdapter
* @param {SchedulerIntegration} options.schedulerIntegration
* @returns {void}
*/
init(domainEvents) {
init({domainEvents, apiUrl, schedulerAdapter, schedulerIntegration}) {
if (this.#initialized) {
return;
}
Expand All @@ -25,8 +52,20 @@ class WelcomeEmailAutomationsService {
* @param {Readonly<Date>} date
*/
const enqueuePollAt = (date) => {
// TODO(NY-1191): Use Scheduler instead of `setTimeout`.
setTimeout(enqueuePollNow, date.getTime() - Date.now());
const signedAdminToken = getSignedAdminToken({
publishedAt: date.toISOString(),
apiUrl,
integration: schedulerIntegration
});
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'
}
});
};

domainEvents.subscribe(StartAutomationsPollEvent, oneAtATime(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,11 @@ async function processRun({
}

// TODO(NY-1193): Bail if member is unsubscribed
// TODO(NY-1194): Bail if member's status has changed

if (member.get('status') !== memberStatus) {
await markExited(run.id, 'member changed status');
return;
}

await memberWelcomeEmailService.api.send({
member: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const tokenPermissionCheck = function tokenPermissionCheck(req, res, next) {
tiers: ['GET', 'PUT', 'POST'],
offers: ['GET', 'PUT', 'POST'],
newsletters: ['GET', 'PUT', 'POST'],
automations: ['PUT'],
config: ['GET'],
explore: ['GET'],
schedules: ['PUT'],
Expand Down
3 changes: 3 additions & 0 deletions ghost/core/core/server/web/api/endpoints/admin/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ module.exports = function apiRoutes() {
router.put('/labels/:id', mw.authAdminApi, http(api.labels.edit));
router.delete('/labels/:id', mw.authAdminApi, http(api.labels.destroy));

// ## Automations
router.put('/automations/poll', mw.authAdminApiWithUrl, http(api.automations.poll));

// ## Automated Emails
router.get('/automated_emails', mw.authAdminApi, http(api.automatedEmails.browse));
router.get('/automated_emails/design', mw.authAdminApi, http(api.automatedEmailDesign.read));
Expand Down
2 changes: 1 addition & 1 deletion ghost/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@
"@tryghost/kg-markdown-html-renderer": "7.2.0",
"@tryghost/kg-mobiledoc-html-renderer": "7.2.0",
"@tryghost/limit-service": "1.5.2",
"@tryghost/logging": "2.5.5",
"@tryghost/logging": "4.1.0",
"@tryghost/members-csv": "2.0.5",
"@tryghost/metrics": "1.0.43",
"@tryghost/mongo-utils": "0.6.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`Automations API poll does not poll when request lacks a token 1: [body] 1`] = `
Object {
"errors": Array [
Object {
"code": "INVALID_JWT",
"context": null,
"details": null,
"ghostErrorCode": null,
"help": null,
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
"message": "Invalid token: No token found in URL",
"property": null,
"type": "UnauthorizedError",
},
],
}
`;

exports[`Automations API poll does not poll when request lacks a token 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "235",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;

exports[`Automations API poll does not poll when request token is invalid 1: [body] 1`] = `
Object {
"errors": Array [
Object {
"code": "INVALID_JWT",
"context": null,
"details": null,
"ghostErrorCode": null,
"help": null,
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
"message": "Invalid token: jwt audience invalid. expected: /\\\\/?admin\\\\/?$/",
"property": null,
"type": "UnauthorizedError",
},
],
}
`;

exports[`Automations API poll does not poll when request token is invalid 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "262",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;

exports[`Automations API poll triggers a poll with a valid scheduler integration token 1: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin",
"x-powered-by": "Express",
}
`;
101 changes: 101 additions & 0 deletions ghost/core/test/e2e-api/admin/automations.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
const sinon = require('sinon');
const domainEvents = require('@tryghost/domain-events');
const models = require('../../../core/server/models');
const {getSignedAdminToken} = require('../../../core/server/adapters/scheduling/utils');
const {agentProvider, fixtureManager, matchers, assertions} = require('../../utils/e2e-framework');
const StartAutomationsPollEvent = require('../../../core/server/services/welcome-email-automations/events/start-automations-poll-event');

const {anyContentVersion, anyEtag, anyErrorId} = matchers;
const {cacheInvalidateHeaderNotSet} = assertions;

describe('Automations API', function () {
let agent;
let schedulerIntegration;
let schedulerToken;

before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('integrations', 'api_keys');

schedulerIntegration = await models.Integration.findOne(
{slug: 'ghost-scheduler'},
{withRelated: 'api_keys'}
);

schedulerToken = getSignedAdminToken({
publishedAt: new Date().toISOString(),
apiUrl: '/admin/',
integration: schedulerIntegration.toJSON()
});
});

afterEach(function () {
sinon.restore();
});

describe('poll', function () {
/** @type {sinon.SinonStub} */
let dispatchStub;

beforeEach(function () {
dispatchStub = sinon.stub(domainEvents, 'dispatch');
});

it('does not poll when request lacks a token', async function () {
await agent
.put('automations/poll/')
.expectStatus(401)
.expect(cacheInvalidateHeaderNotSet())
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
errors: [{
id: anyErrorId,
message: 'Invalid token: No token found in URL'
}]
});

sinon.assert.notCalled(dispatchStub);
});

it('does not poll when request token is invalid', async function () {
const invalidSchedulerToken = getSignedAdminToken({
publishedAt: new Date().toISOString(),
apiUrl: '/members/',
integration: schedulerIntegration.toJSON()
});

await agent
.put(`automations/poll/?token=${invalidSchedulerToken}`)
.expectStatus(401)
.expect(cacheInvalidateHeaderNotSet())
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
});

sinon.assert.notCalled(dispatchStub);
});

it('triggers a poll with a valid scheduler integration token', async function () {
await agent
.put(`automations/poll/?token=${schedulerToken}`)
.expectStatus(204)
.expectEmptyBody()
.expect(cacheInvalidateHeaderNotSet())
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});

sinon.assert.calledOnceWithExactly(dispatchStub, sinon.match.instanceOf(StartAutomationsPollEvent));
});
});
});
Loading
Loading