Added recommendations CRUD api (#17845)

fixes https://github.com/TryGhost/Product/issues/3784

- Includes migrations for new permissions needed for the new endpoints
This commit is contained in:
Simon Backx 2023-08-29 17:06:57 +02:00 committed by GitHub
parent 492c26c6ac
commit 935ac43584
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 920 additions and 19 deletions

View File

@ -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: {}

View File

@ -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');

View File

@ -205,6 +205,10 @@ module.exports = {
return apiFramework.pipeline(require('./mail-events'), localUtils);
},
get recommendations() {
return apiFramework.pipeline(require('./recommendations'), localUtils);
},
/**
* Content API Controllers
*

View File

@ -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);
}
}
};

View File

@ -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'
])
);

View File

@ -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"]
}
}
},

View File

@ -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;

View File

@ -0,0 +1,3 @@
const RecommendationServiceWrapper = require('./RecommendationServiceWrapper');
module.exports = new RecommendationServiceWrapper();

View File

@ -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;
};

View File

@ -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",

View File

@ -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",
}
`;

View File

@ -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
}
]
});
});
});

View File

@ -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']);

View File

@ -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);

View File

@ -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';

View File

@ -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"]
}
}
},

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/ts'
]
};

View File

@ -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

View File

@ -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": {}
}

View File

@ -0,0 +1,35 @@
import {Recommendation} from "./Recommendation";
import {RecommendationRepository} from "./RecommendationRepository";
export class InMemoryRecommendationRepository implements RecommendationRepository {
recommendations: Recommendation[] = [];
async add(recommendation: Recommendation): Promise<Recommendation> {
this.recommendations.push(recommendation);
return Promise.resolve(recommendation);
}
async edit(id: string, data: Partial<Recommendation>): Promise<Recommendation> {
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<void> {
await this.getById(id);
this.recommendations = this.recommendations.filter(r => r.id !== id);
}
async getById(id: string): Promise<Recommendation> {
const existing = this.recommendations.find(r => r.id === id);
if (!existing) {
throw new Error("Recommendation not found");
}
return Promise.resolve(existing);
}
async getAll(): Promise<Recommendation[]> {
return Promise.resolve(this.recommendations);
}
}

View File

@ -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);
}
}

View File

@ -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<Recommendation, 'id'|'createdAt'> = {
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<Recommendation> {
if (!frame.data || !frame.data.recommendations || !frame.data.recommendations[0]) {
throw new errors.BadRequestError();
}
const recommendation = frame.data.recommendations[0];
const cleanedRecommendation: Partial<Recommendation> = {
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())
);
}
}

View File

@ -0,0 +1,9 @@
import {Recommendation} from "./Recommendation";
export interface RecommendationRepository {
add(recommendation: Recommendation): Promise<Recommendation>
edit(id: string, data: Partial<Recommendation>): Promise<Recommendation>
remove(id: string): Promise<void>
getById(id: string): Promise<Recommendation>
getAll(): Promise<Recommendation[]>
};

View File

@ -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<Recommendation>) {
// 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()
}
}

View File

@ -0,0 +1,5 @@
export * from './RecommendationController';
export * from './RecommendationService';
export * from './RecommendationRepository';
export * from './InMemoryRecommendationRepository';
export * from './Recommendation';

View File

@ -0,0 +1 @@
declare module '@tryghost/errors';

View File

@ -0,0 +1,7 @@
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View File

@ -0,0 +1,8 @@
import assert from 'assert/strict';
describe('Hello world', function () {
it('Runs a test', function () {
// TODO: Write me!
assert.ok(require('../'));
});
});

View File

@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"include": [
"src/**/*"
],
"compilerOptions": {
"outDir": "build"
}
}