From 1dd83e1a0f99d43fbfb97437aa8cefc66b522892 Mon Sep 17 00:00:00 2001 From: Daniel Lockyer Date: Wed, 6 Jul 2022 09:51:11 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Added=20Ghost=20Explore=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - this new endpoint returns a special set of data for use in Ghost Explore --- core/server/api/endpoints/explore.js | 12 ++++ core/server/api/endpoints/index.js | 4 ++ .../utils/serializers/output/explore.js | 11 ++++ .../utils/serializers/output/index.js | 4 ++ core/server/services/explore/index.js | 18 ++++++ core/server/services/explore/service.js | 55 ++++++++++++++++++ core/server/services/posts/posts-service.js | 14 +++++ .../web/api/endpoints/admin/middleware.js | 1 + core/server/web/api/endpoints/admin/routes.js | 3 + .../admin/__snapshots__/explore.test.js.snap | 56 +++++++++++++++++++ test/e2e-api/admin/explore.test.js | 37 ++++++++++++ 11 files changed, 215 insertions(+) create mode 100644 core/server/api/endpoints/explore.js create mode 100644 core/server/api/endpoints/utils/serializers/output/explore.js create mode 100644 core/server/services/explore/index.js create mode 100644 core/server/services/explore/service.js create mode 100644 test/e2e-api/admin/__snapshots__/explore.test.js.snap create mode 100644 test/e2e-api/admin/explore.test.js diff --git a/core/server/api/endpoints/explore.js b/core/server/api/endpoints/explore.js new file mode 100644 index 0000000000..9769866ae0 --- /dev/null +++ b/core/server/api/endpoints/explore.js @@ -0,0 +1,12 @@ +const exploreService = require('../../services/explore'); + +module.exports = { + docName: 'explore', + + read: { + permissions: true, + query() { + return exploreService.fetchData(); + } + } +}; diff --git a/core/server/api/endpoints/index.js b/core/server/api/endpoints/index.js index 7b57d34fe0..681db12fb6 100644 --- a/core/server/api/endpoints/index.js +++ b/core/server/api/endpoints/index.js @@ -137,6 +137,10 @@ module.exports = { return shared.pipeline(require('./config'), localUtils); }, + get explore() { + return shared.pipeline(require('./explore'), localUtils); + }, + get themes() { return shared.pipeline(require('./themes'), localUtils); }, diff --git a/core/server/api/endpoints/utils/serializers/output/explore.js b/core/server/api/endpoints/utils/serializers/output/explore.js new file mode 100644 index 0000000000..edd5264667 --- /dev/null +++ b/core/server/api/endpoints/utils/serializers/output/explore.js @@ -0,0 +1,11 @@ +const debug = require('@tryghost/debug')('api:endpoints:utils:serializers:output:explore'); + +module.exports = { + all(data, apiConfig, frame) { + debug('all'); + + frame.response = { + explore: data + }; + } +}; diff --git a/core/server/api/endpoints/utils/serializers/output/index.js b/core/server/api/endpoints/utils/serializers/output/index.js index ab02851073..955f3b734e 100644 --- a/core/server/api/endpoints/utils/serializers/output/index.js +++ b/core/server/api/endpoints/utils/serializers/output/index.js @@ -21,6 +21,10 @@ module.exports = { return require('./db'); }, + get explore() { + return require('./explore'); + }, + get pages() { return require('./pages'); }, diff --git a/core/server/services/explore/index.js b/core/server/services/explore/index.js new file mode 100644 index 0000000000..46ce499159 --- /dev/null +++ b/core/server/services/explore/index.js @@ -0,0 +1,18 @@ +const ExploreService = require('./service'); + +const MembersService = require('../members'); +const PostsService = require('../posts/posts-service')(); +const PublicConfigService = require('../public-config'); +const StatsService = require('../stats'); +const StripeService = require('../stripe'); + +const models = require('../../models'); + +module.exports = new ExploreService({ + MembersService, + PostsService, + PublicConfigService, + StatsService, + StripeService, + UserModel: models.User +}); diff --git a/core/server/services/explore/service.js b/core/server/services/explore/service.js new file mode 100644 index 0000000000..7a9b470d6b --- /dev/null +++ b/core/server/services/explore/service.js @@ -0,0 +1,55 @@ +const ghostVersion = require('@tryghost/version'); + +module.exports = class ExploreService { + /** + * @param {Object} options + * @param {Object} options.MembersService + * @param {Object} options.PostsService + * @param {Object} options.PublicConfigService + * @param {Object} options.StatsService + * @param {Object} options.StripeService + * @param {Object} options.UserModel + */ + constructor({MembersService, PostsService, PublicConfigService, StatsService, StripeService, UserModel}) { + this.MembersService = MembersService; + this.PostsService = PostsService; + this.PublicConfigService = PublicConfigService; + this.StatsService = StatsService; + this.StripeService = StripeService; + this.UserModel = UserModel; + } + + /** + * Build and return the response object containing the data for the Ghost Explore endpoint + */ + async fetchData() { + const totalMembers = await this.MembersService.stats.getTotalMembers(); + const mrrStats = await this.StatsService.getMRRHistory(); + + const {description, icon, title, url} = this.PublicConfigService.site; + + const exploreProperties = { + version: ghostVersion.full, + totalMembers, + mrrStats, + site: { + description, + icon, + title, + url + }, + stripe: { + configured: this.StripeService.api.configured, + livemode: (this.StripeService.api.configured && this.StripeService.api.mode === 'live') + } + }; + + const mostRecentlyPublishedPost = await this.PostsService.getMostRecentlyPublishedPost(); + exploreProperties.mostRecentlyPublishedAt = mostRecentlyPublishedPost?.get('published_at') || null; + + const owner = await this.UserModel.findOne({role: 'Owner', status: 'all'}); + exploreProperties.ownerEmail = owner?.get('email') || null; + + return exploreProperties; + } +}; diff --git a/core/server/services/posts/posts-service.js b/core/server/services/posts/posts-service.js index bceac5caf0..0f8a93697e 100644 --- a/core/server/services/posts/posts-service.js +++ b/core/server/services/posts/posts-service.js @@ -76,6 +76,20 @@ class PostsService { } } + /** + * Returns the most recently `published_at` post that was published or sent + * via email + */ + async getMostRecentlyPublishedPost() { + const recentlyPublishedPost = await this.models.Post.findPage({ + status: ['published', 'sent'], + order: 'published_at DESC', + limit: 1 + }); + + return recentlyPublishedPost?.data[0]; + } + /** * Calculates if the email should be tried to be sent out * @private diff --git a/core/server/web/api/endpoints/admin/middleware.js b/core/server/web/api/endpoints/admin/middleware.js index ebf2de02d4..e519535e49 100644 --- a/core/server/web/api/endpoints/admin/middleware.js +++ b/core/server/web/api/endpoints/admin/middleware.js @@ -33,6 +33,7 @@ const notImplemented = function (req, res, next) { offers: ['GET', 'PUT', 'POST'], newsletters: ['GET', 'PUT', 'POST'], config: ['GET'], + explore: ['GET'], schedules: ['PUT'], files: ['POST'], media: ['POST'], diff --git a/core/server/web/api/endpoints/admin/routes.js b/core/server/web/api/endpoints/admin/routes.js index fdff7188bb..ab46cf951c 100644 --- a/core/server/web/api/endpoints/admin/routes.js +++ b/core/server/web/api/endpoints/admin/routes.js @@ -20,6 +20,9 @@ module.exports = function apiRoutes() { // ## Configuration router.get('/config', mw.authAdminApi, http(api.config.read)); + // ## Ghost Explore + router.get('/explore', mw.authAdminApi, http(api.explore.read)); + // ## Posts router.get('/posts', mw.authAdminApi, http(api.posts.browse)); router.post('/posts', mw.authAdminApi, http(api.posts.add)); diff --git a/test/e2e-api/admin/__snapshots__/explore.test.js.snap b/test/e2e-api/admin/__snapshots__/explore.test.js.snap new file mode 100644 index 0000000000..b001915091 --- /dev/null +++ b/test/e2e-api/admin/__snapshots__/explore.test.js.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Explore API Read Can request Explore data 1: [body] 1`] = ` +Object { + "explore": Object { + "mostRecentlyPublishedAt": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "mrrStats": Object { + "data": Array [ + Object { + "currency": "usd", + "date": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "mrr": 0, + }, + Object { + "currency": "usd", + "date": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/, + "mrr": 1000, + }, + ], + "meta": Object { + "totals": Array [ + Object { + "currency": "usd", + "mrr": 1000, + }, + ], + }, + }, + "ownerEmail": "jbloggs@example.com", + "site": Object { + "description": "Thoughts, stories and ideas", + "icon": null, + "title": "Ghost", + "url": "http://127.0.0.1:2369/", + }, + "stripe": Object { + "configured": true, + "livemode": false, + }, + "totalMembers": 8, + "version": StringMatching /\\\\d\\+\\\\\\.\\\\d\\+\\\\\\.\\\\d\\+/, + }, +} +`; + +exports[`Explore API Read Can request Explore data 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": "463", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/test/e2e-api/admin/explore.test.js b/test/e2e-api/admin/explore.test.js new file mode 100644 index 0000000000..c09c0b112a --- /dev/null +++ b/test/e2e-api/admin/explore.test.js @@ -0,0 +1,37 @@ +const {agentProvider, fixtureManager, matchers} = require('../../utils/e2e-framework'); +const {anyEtag, anyISODate, anyISODateTime, stringMatching} = matchers; + +describe('Explore API', function () { + let agent; + + before(async function () { + agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('members'); + await agent.loginAsOwner(); + }); + + describe('Read', function () { + it('Can request Explore data', async function () { + await agent + .get('explore/') + .expectStatus(200) + .matchBodySnapshot({ + explore: { + mostRecentlyPublishedAt: anyISODateTime, + mrrStats: { + data: [{ + date: anyISODate + }, + { + date: anyISODate + }] + }, + version: stringMatching(/\d+\.\d+\.\d+/) + } + }) + .matchHeaderSnapshot({ + etag: anyEtag + }); + }); + }); +});