From 935ac4358450c9b666c306cd5edbe70b3ae72d4b Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Tue, 29 Aug 2023 17:06:57 +0200 Subject: [PATCH] Added recommendations CRUD api (#17845) fixes https://github.com/TryGhost/Product/issues/3784 - Includes migrations for new permissions needed for the new endpoints --- .github/scripts/dev.js | 2 +- ghost/core/core/boot.js | 4 +- ghost/core/core/server/api/endpoints/index.js | 4 + .../server/api/endpoints/recommendations.js | 71 +++++++ ...17-add-recommendations-crud-permissions.js | 50 +++++ .../server/data/schema/fixtures/fixtures.json | 40 +++- .../RecommendationServiceWrapper.js | 34 ++++ .../server/services/recommendations/index.js | 3 + .../server/web/api/endpoints/admin/routes.js | 6 + ghost/core/package.json | 1 + .../recommendations.test.js.snap | 176 ++++++++++++++++++ .../e2e-api/admin/recommendations.test.js | 156 ++++++++++++++++ .../integration/migrations/migration.test.js | 10 +- .../schema/fixtures/fixture-manager.test.js | 2 +- .../unit/server/data/schema/integrity.test.js | 2 +- ghost/core/test/utils/fixtures/fixtures.json | 40 +++- ghost/recommendations/.eslintrc.js | 6 + ghost/recommendations/README.md | 21 +++ ghost/recommendations/package.json | 33 ++++ .../src/InMemoryRecommendationRepository.ts | 35 ++++ ghost/recommendations/src/Recommendation.ts | 26 +++ .../src/RecommendationController.ts | 149 +++++++++++++++ .../src/RecommendationRepository.ts | 9 + .../src/RecommendationService.ts | 29 +++ ghost/recommendations/src/index.ts | 5 + ghost/recommendations/src/libraries.d.ts | 1 + ghost/recommendations/test/.eslintrc.js | 7 + ghost/recommendations/test/hello.test.ts | 8 + ghost/recommendations/tsconfig.json | 9 + 29 files changed, 920 insertions(+), 19 deletions(-) create mode 100644 ghost/core/core/server/api/endpoints/recommendations.js create mode 100644 ghost/core/core/server/data/migrations/versions/5.61/2023-08-29-10-17-add-recommendations-crud-permissions.js create mode 100644 ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js create mode 100644 ghost/core/core/server/services/recommendations/index.js create mode 100644 ghost/core/test/e2e-api/admin/__snapshots__/recommendations.test.js.snap create mode 100644 ghost/core/test/e2e-api/admin/recommendations.test.js create mode 100644 ghost/recommendations/.eslintrc.js create mode 100644 ghost/recommendations/README.md create mode 100644 ghost/recommendations/package.json create mode 100644 ghost/recommendations/src/InMemoryRecommendationRepository.ts create mode 100644 ghost/recommendations/src/Recommendation.ts create mode 100644 ghost/recommendations/src/RecommendationController.ts create mode 100644 ghost/recommendations/src/RecommendationRepository.ts create mode 100644 ghost/recommendations/src/RecommendationService.ts create mode 100644 ghost/recommendations/src/index.ts create mode 100644 ghost/recommendations/src/libraries.d.ts create mode 100644 ghost/recommendations/test/.eslintrc.js create mode 100644 ghost/recommendations/test/hello.test.ts create mode 100644 ghost/recommendations/tsconfig.json diff --git a/.github/scripts/dev.js b/.github/scripts/dev.js index b615fefa1d..47b555f434 100644 --- a/.github/scripts/dev.js +++ b/.github/scripts/dev.js @@ -41,7 +41,7 @@ const COMMAND_ADMIN = { const COMMAND_TYPESCRIPT = { name: 'ts', - command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations -- nx run \\$NX_PROJECT_NAME:build:ts', + command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations -- nx run \\$NX_PROJECT_NAME:build:ts', cwd: path.resolve(__dirname, '../../'), prefixColor: 'cyan', env: {} diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index 4d33ae8a15..3e5df321cc 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -329,6 +329,7 @@ async function initServices({config}) { const modelToDomainEventInterceptor = require('./server/services/model-to-domain-event-interceptor'); const mailEvents = require('./server/services/mail-events'); const donationService = require('./server/services/donations'); + const recommendationsService = require('./server/services/recommendations'); const urlUtils = require('./shared/url-utils'); @@ -369,7 +370,8 @@ async function initServices({config}) { modelToDomainEventInterceptor.init(), mediaInliner.init(), mailEvents.init(), - donationService.init() + donationService.init(), + recommendationsService.init() ]); debug('End: Services'); diff --git a/ghost/core/core/server/api/endpoints/index.js b/ghost/core/core/server/api/endpoints/index.js index e3bbd73888..38cba852dc 100644 --- a/ghost/core/core/server/api/endpoints/index.js +++ b/ghost/core/core/server/api/endpoints/index.js @@ -205,6 +205,10 @@ module.exports = { return apiFramework.pipeline(require('./mail-events'), localUtils); }, + get recommendations() { + return apiFramework.pipeline(require('./recommendations'), localUtils); + }, + /** * Content API Controllers * diff --git a/ghost/core/core/server/api/endpoints/recommendations.js b/ghost/core/core/server/api/endpoints/recommendations.js new file mode 100644 index 0000000000..af5da559e2 --- /dev/null +++ b/ghost/core/core/server/api/endpoints/recommendations.js @@ -0,0 +1,71 @@ +const recommendations = require('../../services/recommendations'); + +module.exports = { + docName: 'recommendations', + + browse: { + headers: { + cacheInvalidate: false + }, + options: [], + permissions: true, + validation: {}, + async query() { + return await recommendations.controller.listRecommendations(); + } + }, + + add: { + statusCode: 201, + headers: { + cacheInvalidate: true + }, + options: [], + validation: {}, + permissions: true, + async query(frame) { + return await recommendations.controller.addRecommendation(frame); + } + }, + + edit: { + headers: { + cacheInvalidate: true + }, + options: [ + 'id' + ], + validation: { + options: { + id: { + required: true + } + } + }, + permissions: true, + async query(frame) { + return await recommendations.controller.editRecommendation(frame); + } + }, + + destroy: { + statusCode: 204, + headers: { + cacheInvalidate: true + }, + options: [ + 'id' + ], + validation: { + options: { + id: { + required: true + } + } + }, + permissions: true, + query(frame) { + return recommendations.controller.deleteRecommendation(frame); + } + } +}; diff --git a/ghost/core/core/server/data/migrations/versions/5.61/2023-08-29-10-17-add-recommendations-crud-permissions.js b/ghost/core/core/server/data/migrations/versions/5.61/2023-08-29-10-17-add-recommendations-crud-permissions.js new file mode 100644 index 0000000000..ac71f9a593 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/5.61/2023-08-29-10-17-add-recommendations-crud-permissions.js @@ -0,0 +1,50 @@ +const {combineTransactionalMigrations, addPermissionWithRoles} = require('../../utils'); + +module.exports = combineTransactionalMigrations( + addPermissionWithRoles({ + name: 'Browse recommendations', + action: 'browse', + object: 'recommendation' + }, [ + 'Administrator', + 'Admin Integration', + 'Editor', + 'Author', + 'Contributor' + ]), + addPermissionWithRoles({ + name: 'Read recommendations', + action: 'read', + object: 'recommendation' + }, [ + 'Administrator', + 'Admin Integration', + 'Editor', + 'Author', + 'Contributor' + ]), + addPermissionWithRoles({ + name: 'Edit recommendations', + action: 'edit', + object: 'recommendation' + }, [ + 'Administrator', + 'Admin Integration' + ]), + addPermissionWithRoles({ + name: 'Add recommendations', + action: 'add', + object: 'recommendation' + }, [ + 'Administrator', + 'Admin Integration' + ]), + addPermissionWithRoles({ + name: 'Delete recommendations', + action: 'destroy', + object: 'recommendation' + }, [ + 'Administrator', + 'Admin Integration' + ]) +); diff --git a/ghost/core/core/server/data/schema/fixtures/fixtures.json b/ghost/core/core/server/data/schema/fixtures/fixtures.json index eb31e9249f..8ed149d532 100644 --- a/ghost/core/core/server/data/schema/fixtures/fixtures.json +++ b/ghost/core/core/server/data/schema/fixtures/fixtures.json @@ -686,6 +686,31 @@ "name": "Delete collections", "action_type": "destroy", "object_type": "collection" + }, + { + "name": "Browse recommendations", + "action_type": "browse", + "object_type": "recommendation" + }, + { + "name": "Read recommendations", + "action_type": "read", + "object_type": "recommendation" + }, + { + "name": "Edit recommendations", + "action_type": "edit", + "object_type": "recommendation" + }, + { + "name": "Add recommendations", + "action_type": "add", + "object_type": "recommendation" + }, + { + "name": "Delete recommendations", + "action_type": "destroy", + "object_type": "recommendation" } ] }, @@ -831,7 +856,8 @@ "comment": "all", "link": "all", "mention": "browse", - "collection": "all" + "collection": "all", + "recommendation": "all" }, "DB Backup Integration": { "db": "all" @@ -873,7 +899,8 @@ "comment": "all", "link": "all", "mention": "browse", - "collection": "all" + "collection": "all", + "recommendation": "all" }, "Editor": { "notification": "all", @@ -891,7 +918,8 @@ "label": ["browse", "read"], "product": ["browse", "read"], "newsletter": ["browse", "read"], - "collection": "all" + "collection": "all", + "recommendation": ["browse", "read"] }, "Author": { "post": ["browse", "read", "add"], @@ -907,7 +935,8 @@ "label": ["browse", "read"], "product": ["browse", "read"], "newsletter": ["browse", "read"], - "collection": ["browse", "read", "add"] + "collection": ["browse", "read", "add"], + "recommendation": ["browse", "read"] }, "Contributor": { "post": ["browse", "read", "add"], @@ -920,7 +949,8 @@ "email_preview": "read", "email": "read", "snippet": ["browse", "read"], - "collection": ["browse", "read"] + "collection": ["browse", "read"], + "recommendation": ["browse", "read"] } } }, diff --git a/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js b/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js new file mode 100644 index 0000000000..8f895fa2be --- /dev/null +++ b/ghost/core/core/server/services/recommendations/RecommendationServiceWrapper.js @@ -0,0 +1,34 @@ +class RecommendationServiceWrapper { + /** + * @type {import('@tryghost/recommendations').RecommendationRepository} + */ + repository; + + /** + * @type {import('@tryghost/recommendations').RecommendationController} + */ + controller; + + /** + * @type {import('@tryghost/recommendations').RecommendationService} + */ + service; + + init() { + if (this.repository) { + return; + } + + const {InMemoryRecommendationRepository, RecommendationService, RecommendationController} = require('@tryghost/recommendations'); + + this.repository = new InMemoryRecommendationRepository(); + this.service = new RecommendationService({ + repository: this.repository + }); + this.controller = new RecommendationController({ + service: this.service + }); + } +} + +module.exports = RecommendationServiceWrapper; diff --git a/ghost/core/core/server/services/recommendations/index.js b/ghost/core/core/server/services/recommendations/index.js new file mode 100644 index 0000000000..e98388f07a --- /dev/null +++ b/ghost/core/core/server/services/recommendations/index.js @@ -0,0 +1,3 @@ +const RecommendationServiceWrapper = require('./RecommendationServiceWrapper'); + +module.exports = new RecommendationServiceWrapper(); diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index 2a91b28fae..e036695640 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -347,5 +347,11 @@ module.exports = function apiRoutes() { router.get('/links', mw.authAdminApi, http(api.links.browse)); router.put('/links/bulk', mw.authAdminApi, http(api.links.bulkEdit)); + // Recommendations + router.get('/recommendations', mw.authAdminApi, http(api.recommendations.browse)); + router.post('/recommendations', mw.authAdminApi, http(api.recommendations.add)); + router.put('/recommendations/:id', mw.authAdminApi, http(api.recommendations.edit)); + router.del('/recommendations/:id', mw.authAdminApi, http(api.recommendations.destroy)); + return router; }; diff --git a/ghost/core/package.json b/ghost/core/package.json index af76ce312a..1b827904f2 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -81,6 +81,7 @@ "@tryghost/debug": "0.1.24", "@tryghost/domain-events": "0.0.0", "@tryghost/donations": "0.0.0", + "@tryghost/recommendations": "0.0.0", "@tryghost/dynamic-routing-events": "0.0.0", "@tryghost/email-analytics-provider-mailgun": "0.0.0", "@tryghost/email-analytics-service": "0.0.0", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/recommendations.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/recommendations.test.js.snap new file mode 100644 index 0000000000..4778f9c669 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/__snapshots__/recommendations.test.js.snap @@ -0,0 +1,176 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Recommendations Admin API Can add a full recommendation 1: [body] 1`] = ` +Object { + "recommendations": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Dogs are cute", + "favicon": "https://dogpictures.com/favicon.ico", + "featured_image": "https://dogpictures.com/dog.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Because dogs are cute", + "title": "Dog Pictures", + "url": "https://dogpictures.com", + }, + ], +} +`; + +exports[`Recommendations Admin API Can add a full recommendation 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": "335", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/recommendations\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API Can add a minimal recommendation 1: [body] 1`] = ` +Object { + "recommendations": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": null, + "favicon": null, + "featured_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": false, + "reason": null, + "title": "Dog Pictures", + "url": "https://dogpictures.com", + }, + ], +} +`; + +exports[`Recommendations Admin API Can add a minimal recommendation 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": "244", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/recommendations\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API Can add a recommendation 1: [body] 1`] = ` +Object { + "recommendations": Array [ + Object { + "createdAt": "2023-08-29T09:11:34.985Z", + "excerpt": null, + "favicon": null, + "featuredImage": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "oneClickSubscribe": false, + "reason": null, + "title": "Dog Pictures", + "url": "https://dogpictures.com", + }, + ], +} +`; + +exports[`Recommendations Admin API Can add a recommendation 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": "240", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": "http://127.0.0.1:2369/ghost/api/admin/recommendations/64edb646b9e7dfdd2cc931ac/", + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API Can browse 1: [body] 1`] = ` +Object { + "recommendations": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Dogs are cute", + "favicon": "https://dogpictures.com/favicon.ico", + "featured_image": "https://dogpictures.com/dog.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Because dogs are cute", + "title": "Dog Pictures", + "url": "https://dogpictures.com", + }, + ], +} +`; + +exports[`Recommendations Admin API Can browse 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": "335", + "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[`Recommendations Admin API Can delete recommendation 1: [body] 1`] = `Object {}`; + +exports[`Recommendations Admin API Can delete recommendation 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-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API Can edit recommendation 1: [body] 1`] = ` +Object { + "recommendations": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Cats are cute", + "favicon": "https://catpictures.com/favicon.ico", + "featured_image": "https://catpictures.com/cat.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": false, + "reason": "Because cats are cute", + "title": "Cat Pictures", + "url": "https://catpictures.com", + }, + ], +} +`; + +exports[`Recommendations Admin API Can edit recommendation 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": "336", + "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-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/admin/recommendations.test.js b/ghost/core/test/e2e-api/admin/recommendations.test.js new file mode 100644 index 0000000000..617ad4a45d --- /dev/null +++ b/ghost/core/test/e2e-api/admin/recommendations.test.js @@ -0,0 +1,156 @@ +const {agentProvider, fixtureManager, mockManager, matchers} = require('../../utils/e2e-framework'); +const {anyObjectId, anyISODateTime, anyContentVersion, anyLocationFor, anyEtag} = matchers; +const assert = require('assert/strict'); +const recommendationsService = require('../../../core/server/services/recommendations'); + +describe('Recommendations Admin API', function () { + let agent; + + before(async function () { + agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('posts'); + await agent.loginAsOwner(); + }); + + afterEach(function () { + mockManager.restore(); + }); + + it('Can add a minimal recommendation', async function () { + const {body} = await agent.post('recommendations/') + .body({ + recommendations: [{ + title: 'Dog Pictures', + url: 'https://dogpictures.com' + }] + }) + .expectStatus(201) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + location: anyLocationFor('recommendations') + }) + .matchBodySnapshot({ + recommendations: [ + { + id: anyObjectId, + created_at: anyISODateTime + } + ] + }); + + // Check everything is set correctly + assert.equal(body.recommendations[0].title, 'Dog Pictures'); + assert.equal(body.recommendations[0].url, 'https://dogpictures.com'); + assert.equal(body.recommendations[0].reason, null); + assert.equal(body.recommendations[0].excerpt, null); + assert.equal(body.recommendations[0].featured_image, null); + assert.equal(body.recommendations[0].favicon, null); + assert.equal(body.recommendations[0].one_click_subscribe, false); + }); + + it('Can add a full recommendation', async function () { + const {body} = await agent.post('recommendations/') + .body({ + recommendations: [{ + title: 'Dog Pictures', + url: 'https://dogpictures.com', + reason: 'Because dogs are cute', + excerpt: 'Dogs are cute', + featured_image: 'https://dogpictures.com/dog.jpg', + favicon: 'https://dogpictures.com/favicon.ico', + one_click_subscribe: true + }] + }) + .expectStatus(201) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + location: anyLocationFor('recommendations') + }) + .matchBodySnapshot({ + recommendations: [ + { + id: anyObjectId, + created_at: anyISODateTime + } + ] + }); + + // Check everything is set correctly + assert.equal(body.recommendations[0].title, 'Dog Pictures'); + assert.equal(body.recommendations[0].url, 'https://dogpictures.com'); + assert.equal(body.recommendations[0].reason, 'Because dogs are cute'); + assert.equal(body.recommendations[0].excerpt, 'Dogs are cute'); + assert.equal(body.recommendations[0].featured_image, 'https://dogpictures.com/dog.jpg'); + assert.equal(body.recommendations[0].favicon, 'https://dogpictures.com/favicon.ico'); + assert.equal(body.recommendations[0].one_click_subscribe, true); + }); + + it('Can edit recommendation', async function () { + const id = (await recommendationsService.repository.getAll())[0].id; + const {body} = await agent.put(`recommendations/${id}/`) + .body({ + recommendations: [{ + title: 'Cat Pictures', + url: 'https://catpictures.com', + reason: 'Because cats are cute', + excerpt: 'Cats are cute', + featured_image: 'https://catpictures.com/cat.jpg', + favicon: 'https://catpictures.com/favicon.ico', + one_click_subscribe: false + }] + }) + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + recommendations: [ + { + id: anyObjectId, + created_at: anyISODateTime + } + ] + }); + + // Check everything is set correctly + assert.equal(body.recommendations[0].id, id); + assert.equal(body.recommendations[0].title, 'Cat Pictures'); + assert.equal(body.recommendations[0].url, 'https://catpictures.com'); + assert.equal(body.recommendations[0].reason, 'Because cats are cute'); + assert.equal(body.recommendations[0].excerpt, 'Cats are cute'); + assert.equal(body.recommendations[0].featured_image, 'https://catpictures.com/cat.jpg'); + assert.equal(body.recommendations[0].favicon, 'https://catpictures.com/favicon.ico'); + assert.equal(body.recommendations[0].one_click_subscribe, false); + }); + + it('Can delete recommendation', async function () { + const id = (await recommendationsService.repository.getAll())[0].id; + await agent.delete(`recommendations/${id}/`) + .expectStatus(204) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({}); + }); + + it('Can browse', async function () { + await agent.get('recommendations/') + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + recommendations: [ + { + id: anyObjectId, + created_at: anyISODateTime + } + ] + }); + }); +}); diff --git a/ghost/core/test/integration/migrations/migration.test.js b/ghost/core/test/integration/migrations/migration.test.js index 26f7b9cb0e..1a5fc87a85 100644 --- a/ghost/core/test/integration/migrations/migration.test.js +++ b/ghost/core/test/integration/migrations/migration.test.js @@ -34,14 +34,14 @@ describe('Migrations', function () { await knexMigrator.reset({force: true}); await knexMigrator.init(); }); - + it('can rollback to the previous minor version', async function () { await knexMigrator.rollback({ version: previousVersion, force: true }); }); - + it('can rollback to the previous minor version and then forwards again', async function () { await knexMigrator.rollback({ version: previousVersion, @@ -51,7 +51,7 @@ describe('Migrations', function () { force: true }); }); - + it('should have idempotent migrations', async function () { // Delete all knowledge that we've run migrations so we can run them again if (dbUtils.isMySQL()) { @@ -59,7 +59,7 @@ describe('Migrations', function () { } else { await db.knex('migrations').whereLike('version', `${currentMajor}.%`).del(); } - + await knexMigrator.migrate({ force: true }); @@ -99,7 +99,7 @@ describe('Migrations', function () { const permissions = this.obj; // If you have to change this number, please add the relevant `havePermission` checks below - permissions.length.should.eql(115); + permissions.length.should.eql(120); permissions.should.havePermission('Export database', ['Administrator', 'DB Backup Integration']); permissions.should.havePermission('Import database', ['Administrator', 'Self-Serve Migration Integration', 'DB Backup Integration']); diff --git a/ghost/core/test/unit/server/data/schema/fixtures/fixture-manager.test.js b/ghost/core/test/unit/server/data/schema/fixtures/fixture-manager.test.js index f7bda09968..149c3e6085 100644 --- a/ghost/core/test/unit/server/data/schema/fixtures/fixture-manager.test.js +++ b/ghost/core/test/unit/server/data/schema/fixtures/fixture-manager.test.js @@ -191,7 +191,7 @@ describe('Migration Fixture Utils', function () { const rolesAllStub = sinon.stub(models.Role, 'findAll').returns(Promise.resolve(dataMethodStub)); fixtureManager.addFixturesForRelation(fixtures.relations[0]).then(function (result) { - const FIXTURE_COUNT = 106; + const FIXTURE_COUNT = 111; should.exist(result); result.should.be.an.Object(); result.should.have.property('expected', FIXTURE_COUNT); diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index a141c8fce0..bad7841e3b 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -36,7 +36,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route describe('DB version integrity', function () { // Only these variables should need updating const currentSchemaHash = 'ad44bf95fee71a878704bff2a313a583'; - const currentFixturesHash = '1803057343a6afa7b50f1dabbc21424d'; + const currentFixturesHash = '31865c37aacfec9b8f16c1354b36a7de'; const currentSettingsHash = 'dd0e318627ded65e41f188fb5bdf5b74'; const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01'; diff --git a/ghost/core/test/utils/fixtures/fixtures.json b/ghost/core/test/utils/fixtures/fixtures.json index cf42a2054f..f8ca181b44 100644 --- a/ghost/core/test/utils/fixtures/fixtures.json +++ b/ghost/core/test/utils/fixtures/fixtures.json @@ -686,6 +686,31 @@ "name": "Delete collections", "action_type": "destroy", "object_type": "collection" + }, + { + "name": "Browse recommendations", + "action_type": "browse", + "object_type": "recommendation" + }, + { + "name": "Read recommendations", + "action_type": "read", + "object_type": "recommendation" + }, + { + "name": "Edit recommendations", + "action_type": "edit", + "object_type": "recommendation" + }, + { + "name": "Add recommendations", + "action_type": "add", + "object_type": "recommendation" + }, + { + "name": "Delete recommendations", + "action_type": "destroy", + "object_type": "recommendation" } ] }, @@ -1012,7 +1037,8 @@ "comment": "all", "link": "all", "mention": "browse", - "collection": "all" + "collection": "all", + "recommendation": "all" }, "DB Backup Integration": { "db": "all" @@ -1054,7 +1080,8 @@ "comment": "all", "link": "all", "mention": "browse", - "collection": "all" + "collection": "all", + "recommendation": "all" }, "Editor": { "notification": "all", @@ -1072,7 +1099,8 @@ "label": ["browse", "read"], "product": ["browse", "read"], "newsletter": ["browse", "read"], - "collection": "all" + "collection": "all", + "recommendation": ["browse", "read"] }, "Author": { "post": ["browse", "read", "add"], @@ -1088,7 +1116,8 @@ "label": ["browse", "read"], "product": ["browse", "read"], "newsletter": ["browse", "read"], - "collection": ["browse", "read", "add"] + "collection": ["browse", "read", "add"], + "recommendation": ["browse", "read"] }, "Contributor": { "post": ["browse", "read", "add"], @@ -1101,7 +1130,8 @@ "email_preview": "read", "email": "read", "snippet": ["browse", "read"], - "collection": ["browse", "read"] + "collection": ["browse", "read"], + "recommendation": ["browse", "read"] } } }, diff --git a/ghost/recommendations/.eslintrc.js b/ghost/recommendations/.eslintrc.js new file mode 100644 index 0000000000..cb690be63f --- /dev/null +++ b/ghost/recommendations/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/ts' + ] +}; diff --git a/ghost/recommendations/README.md b/ghost/recommendations/README.md new file mode 100644 index 0000000000..e2b40ab20b --- /dev/null +++ b/ghost/recommendations/README.md @@ -0,0 +1,21 @@ +# Recommendations + + +## 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/recommendations/package.json b/ghost/recommendations/package.json new file mode 100644 index 0000000000..e6a87540c3 --- /dev/null +++ b/ghost/recommendations/package.json @@ -0,0 +1,33 @@ +{ + "name": "@tryghost/recommendations", + "version": "0.0.0", + "repository": "https://github.com/TryGhost/Ghost/tree/main/ghost/recommendations", + "author": "Ghost Foundation", + "private": true, + "main": "build/index.js", + "types": "build/index.d.ts", + "scripts": { + "dev": "tsc --watch --preserveWatchOutput --sourceMap", + "build": "tsc", + "build:ts": "yarn build", + "prepare": "tsc", + "test:unit": "NODE_ENV=testing c8 --src src --all --reporter text --reporter cobertura mocha -r ts-node/register './test/**/*.test.ts'", + "test": "yarn test:types && yarn test:unit", + "test:types": "tsc --noEmit", + "lint:code": "eslint src/ --ext .ts --cache", + "lint": "yarn lint:code && yarn lint:test", + "lint:test": "eslint -c test/.eslintrc.js test/ --ext .ts --cache" + }, + "files": [ + "build" + ], + "devDependencies": { + "c8": "8.0.1", + "mocha": "10.2.0", + "sinon": "15.2.0", + "ts-node": "10.9.1", + "typescript": "5.1.6", + "@tryghost/errors": "1.2.24" + }, + "dependencies": {} +} diff --git a/ghost/recommendations/src/InMemoryRecommendationRepository.ts b/ghost/recommendations/src/InMemoryRecommendationRepository.ts new file mode 100644 index 0000000000..59fb7d401c --- /dev/null +++ b/ghost/recommendations/src/InMemoryRecommendationRepository.ts @@ -0,0 +1,35 @@ +import {Recommendation} from "./Recommendation"; +import {RecommendationRepository} from "./RecommendationRepository"; + +export class InMemoryRecommendationRepository implements RecommendationRepository { + recommendations: Recommendation[] = []; + + async add(recommendation: Recommendation): Promise { + this.recommendations.push(recommendation); + return Promise.resolve(recommendation); + } + + async edit(id: string, data: Partial): Promise { + const existing = await this.getById(id); + const updated = {...existing, ...data}; + this.recommendations = this.recommendations.map(r => r.id === id ? updated : r); + return Promise.resolve(updated); + } + + async remove(id: string): Promise { + await this.getById(id); + this.recommendations = this.recommendations.filter(r => r.id !== id); + } + + async getById(id: string): Promise { + const existing = this.recommendations.find(r => r.id === id); + if (!existing) { + throw new Error("Recommendation not found"); + } + return Promise.resolve(existing); + } + + async getAll(): Promise { + return Promise.resolve(this.recommendations); + } +} diff --git a/ghost/recommendations/src/Recommendation.ts b/ghost/recommendations/src/Recommendation.ts new file mode 100644 index 0000000000..617f1e0617 --- /dev/null +++ b/ghost/recommendations/src/Recommendation.ts @@ -0,0 +1,26 @@ +import ObjectId from "bson-objectid"; + +export class Recommendation { + id: string + title: string + reason: string|null + excerpt: string|null // Fetched from the site meta data + featuredImage: string|null // Fetched from the site meta data + favicon: string|null // Fetched from the site meta data + url: string + oneClickSubscribe: boolean + createdAt: Date + + constructor(data: {id?: string, title: string, reason: string|null, excerpt: string|null, featuredImage: string|null, favicon: string|null, url: string, oneClickSubscribe: boolean, createdAt?: Date}) { + this.id = data.id ?? ObjectId().toString(); + this.title = data.title; + this.reason = data.reason; + this.excerpt = data.excerpt; + this.featuredImage = data.featuredImage; + this.favicon = data.favicon; + this.url = data.url; + this.oneClickSubscribe = data.oneClickSubscribe; + this.createdAt = data.createdAt ?? new Date(); + this.createdAt.setMilliseconds(0); + } +} diff --git a/ghost/recommendations/src/RecommendationController.ts b/ghost/recommendations/src/RecommendationController.ts new file mode 100644 index 0000000000..da118d5751 --- /dev/null +++ b/ghost/recommendations/src/RecommendationController.ts @@ -0,0 +1,149 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import {Recommendation} from "./Recommendation"; +import {RecommendationService} from "./RecommendationService"; +import errors from '@tryghost/errors'; + +type Frame = { + data: any, + options: any, + user: any +}; + +function validateString(object: any, key: string, {required = true} = {}): string|undefined { + if (typeof object !== 'object' || object === null) { + throw new errors.BadRequestError({message: `${key} must be an object`}); + } + + if (object[key] !== undefined) { + if (typeof object[key] !== "string") { + throw new errors.BadRequestError({message: `${key} must be a string`}); + } + return object[key]; + } else if (required) { + throw new errors.BadRequestError({message: `${key} is required`}); + } +} + +function validateBoolean(object: any, key: string, {required = true} = {}): boolean|undefined { + if (typeof object !== 'object' || object === null) { + throw new errors.BadRequestError({message: `${key} must be an object`}); + } + if (object[key] !== undefined) { + if (typeof object[key] !== "boolean") { + throw new errors.BadRequestError({message: `${key} must be a boolean`}); + } + return object[key]; + } else if (required) { + throw new errors.BadRequestError({message: `${key} is required`}); + } +} + +export class RecommendationController { + service: RecommendationService; + + constructor(deps: {service: RecommendationService}) { + this.service = deps.service; + } + + #getFrameId(frame: Frame): string { + if (!frame.options) { + throw new errors.BadRequestError(); + } + + const id = frame.options.id; + if (!id) { + throw new errors.BadRequestError(); + } + + return id; + } + + #getFrameRecommendation(frame: Frame): Recommendation { + if (!frame.data || !frame.data.recommendations || !frame.data.recommendations[0]) { + throw new errors.BadRequestError(); + } + + const recommendation = frame.data.recommendations[0]; + + const cleanedRecommendation: Omit = { + title: validateString(recommendation, "title") ?? '', + url: validateString(recommendation, "url") ?? '', + + // Optional fields + oneClickSubscribe: validateBoolean(recommendation, "one_click_subscribe", {required: false}) ?? false, + reason: validateString(recommendation, "reason", {required: false}) ?? null, + excerpt: validateString(recommendation, "excerpt", {required: false}) ?? null, + featuredImage: validateString(recommendation, "featured_image", {required: false}) ?? null, + favicon: validateString(recommendation, "favicon", {required: false}) ?? null, + }; + + // Create a new recommendation + return new Recommendation(cleanedRecommendation); + } + + #getFrameRecommendationEdit(frame: Frame): Partial { + if (!frame.data || !frame.data.recommendations || !frame.data.recommendations[0]) { + throw new errors.BadRequestError(); + } + + const recommendation = frame.data.recommendations[0]; + const cleanedRecommendation: Partial = { + title: validateString(recommendation, "title", {required: false}), + url: validateString(recommendation, "url", {required: false}), + oneClickSubscribe: validateBoolean(recommendation, "one_click_subscribe", {required: false}), + reason: validateString(recommendation, "reason", {required: false}), + excerpt: validateString(recommendation, "excerpt", {required: false}), + featuredImage: validateString(recommendation, "featured_image", {required: false}), + favicon: validateString(recommendation, "favicon", {required: false}), + }; + + // Create a new recommendation + return cleanedRecommendation; + } + + + #returnRecommendations(...recommendations: Recommendation[]) { + return { + data: recommendations.map(r => { + return { + id: r.id, + title: r.title, + reason: r.reason, + excerpt: r.excerpt, + featured_image: r.featuredImage, + favicon: r.favicon, + url: r.url, + one_click_subscribe: r.oneClickSubscribe, + created_at: r.createdAt, + }; + }) + } + } + + async addRecommendation(frame: Frame) { + const recommendation = this.#getFrameRecommendation(frame); + return this.#returnRecommendations( + await this.service.addRecommendation(recommendation) + ); + } + + async editRecommendation(frame: Frame) { + const id = this.#getFrameId(frame); + const recommendationEdit = this.#getFrameRecommendationEdit(frame); + + return this.#returnRecommendations( + await this.service.editRecommendation(id, recommendationEdit) + ); + } + + async deleteRecommendation(frame: Frame) { + const id = this.#getFrameId(frame); + await this.service.deleteRecommendation(id); + } + + async listRecommendations() { + return this.#returnRecommendations( + ...(await this.service.listRecommendations()) + ); + } +} diff --git a/ghost/recommendations/src/RecommendationRepository.ts b/ghost/recommendations/src/RecommendationRepository.ts new file mode 100644 index 0000000000..b3c0f7cdb3 --- /dev/null +++ b/ghost/recommendations/src/RecommendationRepository.ts @@ -0,0 +1,9 @@ +import {Recommendation} from "./Recommendation"; + +export interface RecommendationRepository { + add(recommendation: Recommendation): Promise + edit(id: string, data: Partial): Promise + remove(id: string): Promise + getById(id: string): Promise + getAll(): Promise +}; diff --git a/ghost/recommendations/src/RecommendationService.ts b/ghost/recommendations/src/RecommendationService.ts new file mode 100644 index 0000000000..c7f5bbcb4b --- /dev/null +++ b/ghost/recommendations/src/RecommendationService.ts @@ -0,0 +1,29 @@ +import {Recommendation} from "./Recommendation"; +import {RecommendationRepository} from "./RecommendationRepository"; + +export class RecommendationService { + repository: RecommendationRepository; + + constructor(deps: {repository: RecommendationRepository}) { + this.repository = deps.repository; + } + + async addRecommendation(recommendation: Recommendation) { + return this.repository.add(recommendation); + } + + async editRecommendation(id: string, recommendationEdit: Partial) { + // Check if it exists + const existing = await this.repository.getById(id); + return this.repository.edit(existing.id, recommendationEdit); + } + + async deleteRecommendation(id: string) { + const existing = await this.repository.getById(id); + await this.repository.remove(existing.id); + } + + async listRecommendations() { + return await this.repository.getAll() + } +} diff --git a/ghost/recommendations/src/index.ts b/ghost/recommendations/src/index.ts new file mode 100644 index 0000000000..da6b0d0cd0 --- /dev/null +++ b/ghost/recommendations/src/index.ts @@ -0,0 +1,5 @@ +export * from './RecommendationController'; +export * from './RecommendationService'; +export * from './RecommendationRepository'; +export * from './InMemoryRecommendationRepository'; +export * from './Recommendation'; diff --git a/ghost/recommendations/src/libraries.d.ts b/ghost/recommendations/src/libraries.d.ts new file mode 100644 index 0000000000..afc8627392 --- /dev/null +++ b/ghost/recommendations/src/libraries.d.ts @@ -0,0 +1 @@ +declare module '@tryghost/errors'; diff --git a/ghost/recommendations/test/.eslintrc.js b/ghost/recommendations/test/.eslintrc.js new file mode 100644 index 0000000000..6fe6dc1504 --- /dev/null +++ b/ghost/recommendations/test/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + parser: '@typescript-eslint/parser', + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ] +}; diff --git a/ghost/recommendations/test/hello.test.ts b/ghost/recommendations/test/hello.test.ts new file mode 100644 index 0000000000..e66b88fad4 --- /dev/null +++ b/ghost/recommendations/test/hello.test.ts @@ -0,0 +1,8 @@ +import assert from 'assert/strict'; + +describe('Hello world', function () { + it('Runs a test', function () { + // TODO: Write me! + assert.ok(require('../')); + }); +}); diff --git a/ghost/recommendations/tsconfig.json b/ghost/recommendations/tsconfig.json new file mode 100644 index 0000000000..7f7ed38664 --- /dev/null +++ b/ghost/recommendations/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "src/**/*" + ], + "compilerOptions": { + "outDir": "build" + } +}