mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-23 02:41:50 +03:00
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:
parent
492c26c6ac
commit
935ac43584
2
.github/scripts/dev.js
vendored
2
.github/scripts/dev.js
vendored
@ -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: {}
|
||||
|
@ -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');
|
||||
|
||||
|
@ -205,6 +205,10 @@ module.exports = {
|
||||
return apiFramework.pipeline(require('./mail-events'), localUtils);
|
||||
},
|
||||
|
||||
get recommendations() {
|
||||
return apiFramework.pipeline(require('./recommendations'), localUtils);
|
||||
},
|
||||
|
||||
/**
|
||||
* Content API Controllers
|
||||
*
|
||||
|
71
ghost/core/core/server/api/endpoints/recommendations.js
Normal file
71
ghost/core/core/server/api/endpoints/recommendations.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
@ -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'
|
||||
])
|
||||
);
|
@ -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"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -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;
|
3
ghost/core/core/server/services/recommendations/index.js
Normal file
3
ghost/core/core/server/services/recommendations/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
const RecommendationServiceWrapper = require('./RecommendationServiceWrapper');
|
||||
|
||||
module.exports = new RecommendationServiceWrapper();
|
@ -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;
|
||||
};
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
}
|
||||
`;
|
156
ghost/core/test/e2e-api/admin/recommendations.test.js
Normal file
156
ghost/core/test/e2e-api/admin/recommendations.test.js
Normal 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
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
@ -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']);
|
||||
|
@ -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);
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
6
ghost/recommendations/.eslintrc.js
Normal file
6
ghost/recommendations/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/ts'
|
||||
]
|
||||
};
|
21
ghost/recommendations/README.md
Normal file
21
ghost/recommendations/README.md
Normal 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
|
||||
|
33
ghost/recommendations/package.json
Normal file
33
ghost/recommendations/package.json
Normal 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": {}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
26
ghost/recommendations/src/Recommendation.ts
Normal file
26
ghost/recommendations/src/Recommendation.ts
Normal 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);
|
||||
}
|
||||
}
|
149
ghost/recommendations/src/RecommendationController.ts
Normal file
149
ghost/recommendations/src/RecommendationController.ts
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
9
ghost/recommendations/src/RecommendationRepository.ts
Normal file
9
ghost/recommendations/src/RecommendationRepository.ts
Normal 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[]>
|
||||
};
|
29
ghost/recommendations/src/RecommendationService.ts
Normal file
29
ghost/recommendations/src/RecommendationService.ts
Normal 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()
|
||||
}
|
||||
}
|
5
ghost/recommendations/src/index.ts
Normal file
5
ghost/recommendations/src/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './RecommendationController';
|
||||
export * from './RecommendationService';
|
||||
export * from './RecommendationRepository';
|
||||
export * from './InMemoryRecommendationRepository';
|
||||
export * from './Recommendation';
|
1
ghost/recommendations/src/libraries.d.ts
vendored
Normal file
1
ghost/recommendations/src/libraries.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module '@tryghost/errors';
|
7
ghost/recommendations/test/.eslintrc.js
Normal file
7
ghost/recommendations/test/.eslintrc.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
8
ghost/recommendations/test/hello.test.ts
Normal file
8
ghost/recommendations/test/hello.test.ts
Normal 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('../'));
|
||||
});
|
||||
});
|
9
ghost/recommendations/tsconfig.json
Normal file
9
ghost/recommendations/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"outDir": "build"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user