Improved recommendation controller structure (#18208)

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

1. The service never returns a Recommendation Entity, but always plain
objects (which for now is the same as Recommendation without the
methods).
2. Updated the controller to be more readable and minimal (we keep this
controller, in addition to the existing endpoints and serializers)
- The controller does minimal validation and allows for type checking
(so we get compile time errors in case the service expects new fields)
- The controller uses the `UnsafeData` class to easily validate the
input from requests, and throws appropriate errors (with correct field
descriptions — "Expected a string at recommendations.0.title") without
too much boilerplate code. In addition the interface is typed, so we get
compile errors if there are breaking changes in the service.
- Removed `EntityWithIncludes`, since we now use plain objects, we
inject the relations directly into those plain objects (with some new
types that add type support)
- Added new tests to make sure that edits only affect the given fields,
and never undefined fields
This commit is contained in:
Simon Backx 2023-09-18 16:36:49 +02:00 committed by GitHub
parent 541b7c6a6d
commit b545dfa0cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 978 additions and 478 deletions

View File

@ -15,7 +15,7 @@ module.exports = {
permissions: true,
validation: {},
async query(frame) {
return await recommendations.controller.listRecommendations(frame);
return await recommendations.controller.browse(frame);
}
},

View File

@ -15,7 +15,7 @@ module.exports = {
permissions: true,
validation: {},
async query(frame) {
return await recommendations.controller.listRecommendations(frame);
return await recommendations.controller.browse(frame);
}
},
@ -28,7 +28,7 @@ module.exports = {
validation: {},
permissions: true,
async query(frame) {
return await recommendations.controller.addRecommendation(frame);
return await recommendations.controller.add(frame);
}
},
@ -48,7 +48,7 @@ module.exports = {
},
permissions: true,
async query(frame) {
return await recommendations.controller.editRecommendation(frame);
return await recommendations.controller.edit(frame);
}
},
@ -69,7 +69,7 @@ module.exports = {
},
permissions: true,
query(frame) {
return recommendations.controller.deleteRecommendation(frame);
return recommendations.controller.destroy(frame);
}
}
};

View File

@ -115,11 +115,11 @@ 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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation0.com/favicon.ico",
"featured_image": "https://recommendation0.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 0",
"title": "Recommendation 0",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -133,7 +133,7 @@ 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": "386",
"content-length": "470",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -189,6 +189,72 @@ Object {
}
`;
exports[`Recommendations Admin API Can edit recommendation and set nullable fields to null 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": true,
"reason": null,
"title": "Recommendation 0",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation0.com/",
},
],
}
`;
exports[`Recommendations Admin API Can edit recommendation and set nullable fields to null 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": "292",
"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",
}
`;
exports[`Recommendations Admin API Can edit some fields of a recommendation without changing others 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": "Test excerpt",
"favicon": "https://recommendation0.com/favicon.ico",
"featured_image": "https://recommendation0.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": true,
"reason": "Reason 0",
"title": "Changed",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation0.com/",
},
],
}
`;
exports[`Recommendations Admin API Can edit some fields of a recommendation without changing others 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": "374",
"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",
}
`;
exports[`Recommendations Admin API Can fetch recommendations when there are none 1: [body] 1`] = `
Object {
"meta": Object {
@ -295,11 +361,11 @@ Object {
"subscribers": 3,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": null,
"favicon": null,
"featured_image": null,
"excerpt": "Test excerpt",
"favicon": "https://recommendation4.com/favicon.ico",
"featured_image": "https://recommendation4.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 4",
"title": "Recommendation 4",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -311,11 +377,11 @@ Object {
"subscribers": 0,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": null,
"favicon": null,
"featured_image": null,
"excerpt": "Test excerpt",
"favicon": "https://recommendation3.com/favicon.ico",
"featured_image": "https://recommendation3.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 3",
"title": "Recommendation 3",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -327,11 +393,11 @@ Object {
"subscribers": 2,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": null,
"favicon": null,
"featured_image": null,
"excerpt": "Test excerpt",
"favicon": "https://recommendation2.com/favicon.ico",
"featured_image": "https://recommendation2.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 2",
"title": "Recommendation 2",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -343,11 +409,11 @@ Object {
"subscribers": 0,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": null,
"favicon": null,
"featured_image": null,
"excerpt": "Test excerpt",
"favicon": "https://recommendation1.com/favicon.ico",
"featured_image": "https://recommendation1.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 1",
"title": "Recommendation 1",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -359,11 +425,11 @@ Object {
"subscribers": 0,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": null,
"favicon": null,
"featured_image": null,
"excerpt": "Test excerpt",
"favicon": "https://recommendation0.com/favicon.ico",
"featured_image": "https://recommendation0.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 0",
"title": "Recommendation 0",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -377,7 +443,7 @@ exports[`Recommendations Admin API Can include click and subscribe counts 2: [he
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": "1683",
"content-length": "2103",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -504,6 +570,216 @@ Object {
}
`;
exports[`Recommendations Admin API Can include only clicks 1: [body] 1`] = `
Object {
"meta": Object {
"pagination": Object {
"limit": 5,
"next": null,
"page": 1,
"pages": 1,
"prev": null,
"total": 5,
},
},
"recommendations": Array [
Object {
"count": Object {
"clicks": 2,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": "Test excerpt",
"favicon": "https://recommendation4.com/favicon.ico",
"featured_image": "https://recommendation4.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": true,
"reason": "Reason 4",
"title": "Recommendation 4",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation4.com/",
},
Object {
"count": Object {
"clicks": 3,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": "Test excerpt",
"favicon": "https://recommendation3.com/favicon.ico",
"featured_image": "https://recommendation3.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": true,
"reason": "Reason 3",
"title": "Recommendation 3",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation3.com/",
},
Object {
"count": Object {
"clicks": 0,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": "Test excerpt",
"favicon": "https://recommendation2.com/favicon.ico",
"featured_image": "https://recommendation2.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": true,
"reason": "Reason 2",
"title": "Recommendation 2",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation2.com/",
},
Object {
"count": Object {
"clicks": 0,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": "Test excerpt",
"favicon": "https://recommendation1.com/favicon.ico",
"featured_image": "https://recommendation1.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": true,
"reason": "Reason 1",
"title": "Recommendation 1",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation1.com/",
},
Object {
"count": Object {
"clicks": 0,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": "Test excerpt",
"favicon": "https://recommendation0.com/favicon.ico",
"featured_image": "https://recommendation0.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": true,
"reason": "Reason 0",
"title": "Recommendation 0",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation0.com/",
},
],
}
`;
exports[`Recommendations Admin API Can include only clicks 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": "2023",
"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 include only subscribers 1: [body] 1`] = `
Object {
"meta": Object {
"pagination": Object {
"limit": 5,
"next": null,
"page": 1,
"pages": 1,
"prev": null,
"total": 5,
},
},
"recommendations": Array [
Object {
"count": Object {
"subscribers": 3,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": "Test excerpt",
"favicon": "https://recommendation4.com/favicon.ico",
"featured_image": "https://recommendation4.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": true,
"reason": "Reason 4",
"title": "Recommendation 4",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation4.com/",
},
Object {
"count": Object {
"subscribers": 0,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": "Test excerpt",
"favicon": "https://recommendation3.com/favicon.ico",
"featured_image": "https://recommendation3.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": true,
"reason": "Reason 3",
"title": "Recommendation 3",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation3.com/",
},
Object {
"count": Object {
"subscribers": 2,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": "Test excerpt",
"favicon": "https://recommendation2.com/favicon.ico",
"featured_image": "https://recommendation2.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": true,
"reason": "Reason 2",
"title": "Recommendation 2",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation2.com/",
},
Object {
"count": Object {
"subscribers": 0,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": "Test excerpt",
"favicon": "https://recommendation1.com/favicon.ico",
"featured_image": "https://recommendation1.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": true,
"reason": "Reason 1",
"title": "Recommendation 1",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation1.com/",
},
Object {
"count": Object {
"subscribers": 0,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"excerpt": "Test excerpt",
"favicon": "https://recommendation0.com/favicon.ico",
"featured_image": "https://recommendation0.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": true,
"reason": "Reason 0",
"title": "Recommendation 0",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation0.com/",
},
],
}
`;
exports[`Recommendations Admin API Can include only subscribers 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": "2048",
"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 request pages 1: [body] 1`] = `
Object {
"meta": Object {
@ -519,11 +795,11 @@ 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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation14.com/favicon.ico",
"featured_image": "https://recommendation14.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 14",
"title": "Recommendation 14",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -531,11 +807,11 @@ Object {
},
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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation13.com/favicon.ico",
"featured_image": "https://recommendation13.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 13",
"title": "Recommendation 13",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -543,11 +819,11 @@ Object {
},
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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation12.com/favicon.ico",
"featured_image": "https://recommendation12.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 12",
"title": "Recommendation 12",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -555,11 +831,11 @@ Object {
},
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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation11.com/favicon.ico",
"featured_image": "https://recommendation11.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 11",
"title": "Recommendation 11",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -567,11 +843,11 @@ Object {
},
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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation10.com/favicon.ico",
"featured_image": "https://recommendation10.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 10",
"title": "Recommendation 10",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -579,11 +855,11 @@ Object {
},
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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation9.com/favicon.ico",
"featured_image": "https://recommendation9.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 9",
"title": "Recommendation 9",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -591,11 +867,11 @@ Object {
},
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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation8.com/favicon.ico",
"featured_image": "https://recommendation8.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 8",
"title": "Recommendation 8",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -603,11 +879,11 @@ Object {
},
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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation7.com/favicon.ico",
"featured_image": "https://recommendation7.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 7",
"title": "Recommendation 7",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -615,11 +891,11 @@ Object {
},
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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation6.com/favicon.ico",
"featured_image": "https://recommendation6.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 6",
"title": "Recommendation 6",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -627,11 +903,11 @@ Object {
},
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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation5.com/favicon.ico",
"featured_image": "https://recommendation5.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 5",
"title": "Recommendation 5",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -645,7 +921,7 @@ exports[`Recommendations Admin API Can request pages 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": "2902",
"content-length": "3752",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -669,11 +945,11 @@ 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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation4.com/favicon.ico",
"featured_image": "https://recommendation4.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 4",
"title": "Recommendation 4",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -681,11 +957,11 @@ Object {
},
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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation3.com/favicon.ico",
"featured_image": "https://recommendation3.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 3",
"title": "Recommendation 3",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -693,11 +969,11 @@ Object {
},
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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation2.com/favicon.ico",
"featured_image": "https://recommendation2.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 2",
"title": "Recommendation 2",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -705,11 +981,11 @@ Object {
},
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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation1.com/favicon.ico",
"featured_image": "https://recommendation1.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 1",
"title": "Recommendation 1",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -717,11 +993,11 @@ Object {
},
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,
"excerpt": "Test excerpt",
"favicon": "https://recommendation0.com/favicon.ico",
"featured_image": "https://recommendation0.com/featured.jpg",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"one_click_subscribe": true,
"reason": "Reason 0",
"title": "Recommendation 0",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
@ -735,7 +1011,7 @@ exports[`Recommendations Admin API Can request pages 4: [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": "1497",
"content-length": "1917",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -817,7 +1093,7 @@ Object {
"errors": Array [
Object {
"code": null,
"context": "Featured image must be a valid URL",
"context": "recommendations.0.featured_image must be a valid URL",
"details": null,
"ghostErrorCode": null,
"help": null,
@ -834,7 +1110,7 @@ exports[`Recommendations Admin API Cannot use invalid protocols when editing 2:
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": "265",
"content-length": "283",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -847,7 +1123,7 @@ exports[`Recommendations Admin API Uses default limit of 5 1: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1495",
"content-length": "1915",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View File

@ -9,10 +9,10 @@ async function addDummyRecommendation(i = 0) {
title: `Recommendation ${i}`,
reason: `Reason ${i}`,
url: new URL(`https://recommendation${i}.com`),
favicon: null,
featuredImage: null,
excerpt: null,
oneClickSubscribe: false,
favicon: new URL(`https://recommendation${i}.com/favicon.ico`),
featuredImage: new URL(`https://recommendation${i}.com/featured.jpg`),
excerpt: 'Test excerpt',
oneClickSubscribe: true,
createdAt: new Date(i * 5000) // Reliable ordering
});
@ -27,6 +27,48 @@ async function addDummyRecommendations(amount = 15) {
}
}
async function addClicksAndSubscribers({memberId}) {
const recommendations = await recommendationsService.repository.getAll({order: [{field: 'createdAt', direction: 'desc'}]});
// Create 2 clicks for 1st
for (let i = 0; i < 2; i++) {
const clickEvent = ClickEvent.create({
recommendationId: recommendations[0].id
});
await recommendationsService.clickEventRepository.save(clickEvent);
}
// Create 3 clicks for 2nd
for (let i = 0; i < 3; i++) {
const clickEvent = ClickEvent.create({
recommendationId: recommendations[1].id
});
await recommendationsService.clickEventRepository.save(clickEvent);
}
// Create 3 subscribers for 1st
for (let i = 0; i < 3; i++) {
const subscribeEvent = SubscribeEvent.create({
recommendationId: recommendations[0].id,
memberId
});
await recommendationsService.subscribeEventRepository.save(subscribeEvent);
}
// Create 2 subscribers for 3rd
for (let i = 0; i < 2; i++) {
const subscribeEvent = SubscribeEvent.create({
recommendationId: recommendations[2].id,
memberId
});
await recommendationsService.subscribeEventRepository.save(subscribeEvent);
}
}
describe('Recommendations Admin API', function () {
let agent, memberId;
@ -206,6 +248,74 @@ describe('Recommendations Admin API', function () {
assert.equal(body.recommendations[0].one_click_subscribe, false);
});
it('Can edit recommendation and set nullable fields to null', async function () {
const id = await addDummyRecommendation();
const {body} = await agent.put(`recommendations/${id}/`)
.body({
recommendations: [{
reason: null,
excerpt: null,
featured_image: null,
favicon: null
}]
})
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: [
{
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
}
]
});
// Check everything is set correctly
assert.equal(body.recommendations[0].id, id);
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);
});
it('Can edit some fields of a recommendation without changing others', async function () {
const id = await addDummyRecommendation();
const {body} = await agent.put(`recommendations/${id}/`)
.body({
recommendations: [{
title: 'Changed'
}]
})
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: [
{
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
}
]
});
// Check everything is set correctly
assert.equal(body.recommendations[0].id, id);
assert.equal(body.recommendations[0].title, 'Changed');
assert.equal(body.recommendations[0].url, 'https://recommendation0.com/');
assert.equal(body.recommendations[0].reason, 'Reason 0');
assert.equal(body.recommendations[0].excerpt, 'Test excerpt');
assert.equal(body.recommendations[0].featured_image, 'https://recommendation0.com/featured.jpg');
assert.equal(body.recommendations[0].favicon, 'https://recommendation0.com/favicon.ico');
assert.equal(body.recommendations[0].one_click_subscribe, true);
});
it('Cannot use invalid protocols when editing', async function () {
const id = await addDummyRecommendation();
@ -327,45 +437,7 @@ describe('Recommendations Admin API', function () {
it('Can include click and subscribe counts', async function () {
await addDummyRecommendations(5);
const recommendations = await recommendationsService.repository.getAll({order: [{field: 'createdAt', direction: 'desc'}]});
// Create 2 clicks for 1st
for (let i = 0; i < 2; i++) {
const clickEvent = ClickEvent.create({
recommendationId: recommendations[0].id
});
await recommendationsService.clickEventRepository.save(clickEvent);
}
// Create 3 clicks for 2nd
for (let i = 0; i < 3; i++) {
const clickEvent = ClickEvent.create({
recommendationId: recommendations[1].id
});
await recommendationsService.clickEventRepository.save(clickEvent);
}
// Create 3 subscribers for 1st
for (let i = 0; i < 3; i++) {
const subscribeEvent = SubscribeEvent.create({
recommendationId: recommendations[0].id,
memberId
});
await recommendationsService.subscribeEventRepository.save(subscribeEvent);
}
// Create 2 subscribers for 3rd
for (let i = 0; i < 2; i++) {
const subscribeEvent = SubscribeEvent.create({
recommendationId: recommendations[2].id,
memberId
});
await recommendationsService.subscribeEventRepository.save(subscribeEvent);
}
await addClicksAndSubscribers({memberId});
const {body: page1} = await agent.get('recommendations/?include=count.clicks,count.subscribers')
.expectStatus(200)
@ -388,4 +460,56 @@ describe('Recommendations Admin API', function () {
assert.equal(page1.recommendations[1].count.subscribers, 0);
assert.equal(page1.recommendations[2].count.subscribers, 2);
});
it('Can include only clicks', async function () {
await addDummyRecommendations(5);
await addClicksAndSubscribers({memberId});
const {body: page1} = await agent.get('recommendations/?include=count.clicks')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: new Array(5).fill({
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
})
});
assert.equal(page1.recommendations[0].count.clicks, 2);
assert.equal(page1.recommendations[1].count.clicks, 3);
assert.equal(page1.recommendations[0].count.subscribers, undefined);
assert.equal(page1.recommendations[1].count.subscribers, undefined);
assert.equal(page1.recommendations[2].count.subscribers, undefined);
});
it('Can include only subscribers', async function () {
await addDummyRecommendations(5);
await addClicksAndSubscribers({memberId});
const {body: page1} = await agent.get('recommendations/?include=count.subscribers')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
recommendations: new Array(5).fill({
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
})
});
assert.equal(page1.recommendations[0].count.clicks, undefined);
assert.equal(page1.recommendations[1].count.clicks, undefined);
assert.equal(page1.recommendations[0].count.subscribers, 3);
assert.equal(page1.recommendations[1].count.subscribers, 0);
assert.equal(page1.recommendations[2].count.subscribers, 2);
});
});

View File

@ -46,9 +46,9 @@ export class BookshelfRecommendationRepository extends BookshelfRepository<strin
title: model.get('title') as string,
reason: model.get('reason') as string | null,
excerpt: model.get('excerpt') as string | null,
featuredImage: (model.get('featured_image') as string | null) !== null ? new URL(model.get('featured_image') as string) : null,
favicon: (model.get('favicon') as string | null) !== null ? new URL(model.get('favicon') as string) : null,
url: new URL(model.get('url') as string),
featuredImage: model.get('featured_image') as string | null,
favicon: model.get('favicon') as string | null,
url: model.get('url') as string,
oneClickSubscribe: model.get('one_click_subscribe') as boolean,
createdAt: model.get('created_at') as Date,
updatedAt: model.get('updated_at') as Date | null

View File

@ -1,17 +0,0 @@
export class EntityWithIncludes<T, Includes extends string = string> {
entity: T;
includes: Map<Includes, unknown> = new Map();
private constructor(entity: T) {
this.entity = entity;
}
// eslint-disable-next-line no-shadow
static create<Entity, Includes extends string>(entity: Entity): EntityWithIncludes<Entity, Includes> {
return new EntityWithIncludes(entity);
}
setInclude(include: Includes, value: unknown) {
this.includes.set(include, value);
}
}

View File

@ -1,19 +1,38 @@
import ObjectId from 'bson-objectid';
import errors from '@tryghost/errors';
import {UnsafeData} from './UnsafeData';
export type AddRecommendation = {
/**
* We never expose Entities outside of services. Because we should never expose the bussiness logic methods. The plain objects are used for that
*/
export type RecommendationPlain = {
id: string,
title: string
reason: string|null
excerpt: string|null // Fetched from the site meta data
featuredImage: URL|null // Fetched from the site meta data
favicon: URL|null // Fetched from the site meta data
url: URL
oneClickSubscribe: boolean
oneClickSubscribe: boolean,
createdAt: Date,
updatedAt: Date|null
}
export type RecommendationCreateData = {
id?: string
title: string
reason: string|null
excerpt: string|null // Fetched from the site meta data
featuredImage: URL|string|null // Fetched from the site meta data
favicon: URL|string|null // Fetched from the site meta data
url: URL|string
oneClickSubscribe: boolean
createdAt?: Date
updatedAt?: Date|null
}
export type AddRecommendation = Omit<RecommendationCreateData, 'id'|'createdAt'|'updatedAt'>
export type EditRecommendation = Partial<AddRecommendation>
type RecommendationConstructorData = AddRecommendation & {id: string, createdAt: Date, updatedAt: Date|null}
export type RecommendationCreateData = AddRecommendation & {id?: string, createdAt?: Date, updatedAt?: Date|null}
export class Recommendation {
id: string;
@ -33,7 +52,7 @@ export class Recommendation {
return this.#deleted;
}
private constructor(data: RecommendationConstructorData) {
private constructor(data: RecommendationPlain) {
this.id = data.id;
this.title = data.title;
this.reason = data.reason;
@ -48,28 +67,6 @@ export class Recommendation {
}
static validate(properties: AddRecommendation) {
if (properties.url.protocol !== 'http:' && properties.url.protocol !== 'https:') {
throw new errors.ValidationError({
message: 'url must be a valid URL'
});
}
if (properties.featuredImage !== null) {
if (properties.featuredImage.protocol !== 'http:' && properties.featuredImage.protocol !== 'https:') {
throw new errors.ValidationError({
message: 'Featured image must be a valid URL'
});
}
}
if (properties.favicon !== null) {
if (properties.favicon.protocol !== 'http:' && properties.favicon.protocol !== 'https:') {
throw new errors.ValidationError({
message: 'Favicon must be a valid URL'
});
}
}
if (properties.title.length === 0) {
throw new errors.ValidationError({
message: 'Title must not be empty'
@ -120,9 +117,9 @@ export class Recommendation {
title: data.title,
reason: data.reason,
excerpt: data.excerpt,
featuredImage: data.featuredImage,
favicon: data.favicon,
url: data.url,
featuredImage: new UnsafeData(data.featuredImage).nullable.url,
favicon: new UnsafeData(data.favicon).nullable.url,
url: new UnsafeData(data.url).url,
oneClickSubscribe: data.oneClickSubscribe,
createdAt: data.createdAt ?? new Date(),
updatedAt: data.updatedAt ?? null
@ -135,11 +132,38 @@ export class Recommendation {
return recommendation;
}
edit(properties: EditRecommendation) {
Recommendation.validate({...this, ...properties});
get plain(): RecommendationPlain {
return {
id: this.id,
title: this.title,
reason: this.reason,
excerpt: this.excerpt,
featuredImage: this.featuredImage,
favicon: this.favicon,
url: this.url,
oneClickSubscribe: this.oneClickSubscribe,
createdAt: this.createdAt,
updatedAt: this.updatedAt
};
}
Object.assign(this, properties);
this.clean();
/**
* Change the specified properties. Properties that are set to undefined will not be changed
*/
edit(properties: EditRecommendation) {
// Delete undefined properties
const newProperties = this.plain;
for (const key of Object.keys(properties) as (keyof EditRecommendation)[]) {
if (Object.prototype.hasOwnProperty.call(properties, key) && properties[key] !== undefined) {
(newProperties as Record<string, unknown>)[key] = properties[key] as unknown;
}
}
newProperties.updatedAt = new Date();
const created = Recommendation.create(newProperties);
Object.assign(this, created);
}
delete() {

View File

@ -1,91 +1,16 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {EntityWithIncludes} from './EntityWithIncludes';
import {AddRecommendation, EditRecommendation, Recommendation} from './Recommendation';
import {RecommendationInclude, RecommendationService} from './RecommendationService';
import errors from '@tryghost/errors';
import {AddRecommendation, RecommendationPlain} from './Recommendation';
import {RecommendationIncludeFields, RecommendationService, RecommendationWithIncludes} from './RecommendationService';
import {UnsafeData} from './UnsafeData';
type Frame = {
data: any,
options: any,
user: any,
member: any,
data: unknown,
options: unknown,
user: unknown,
member: unknown,
};
function validateString(object: any, key: string, {required = true, nullable = false} = {}): string|undefined|null {
if (typeof object !== 'object' || object === null) {
throw new errors.BadRequestError({message: `${key} must be an object`});
}
if (nullable && object[key] === null) {
return null;
}
if (object[key] !== undefined && object[key] !== null) {
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`});
}
}
function validateURL(object: any, key: string, {required = true, nullable = false} = {}): URL|undefined|null {
const string = validateString(object, key, {required, nullable});
if (string === null) {
return null;
}
if (string !== undefined) {
try {
return new URL(string);
} catch (e) {
throw new errors.BadRequestError({message: `${key} must be a valid URL`});
}
}
}
function validateInteger(object: any, key: string, {required = true, nullable = false} = {}): number|undefined|null {
if (typeof object !== 'object' || object === null) {
throw new errors.BadRequestError({message: `${key} must be an object`});
}
if (nullable && object[key] === null) {
return null;
}
if (object[key] !== undefined && object[key] !== null) {
if (typeof object[key] === 'string') {
// Try to cast to a number
const parsed = parseInt(object[key]);
if (isNaN(parsed) || !isFinite(parsed)) {
throw new errors.BadRequestError({message: `${key} must be a number`});
}
return parsed;
}
if (typeof object[key] !== 'number') {
throw new errors.BadRequestError({message: `${key} must be a number`});
}
return object[key];
} else if (required) {
throw new errors.BadRequestError({message: `${key} is required`});
}
}
export class RecommendationController {
service: RecommendationService;
@ -93,115 +18,130 @@ export class RecommendationController {
this.service = deps.service;
}
#getFrameId(frame: Frame): string {
if (!frame.options) {
throw new errors.BadRequestError();
}
async add(frame: Frame) {
const data = new UnsafeData(frame.data);
const recommendation = data.key('recommendations').index(0);
const plain: AddRecommendation = {
title: recommendation.key('title').string,
url: recommendation.key('url').url,
const id = frame.options.id;
if (!id) {
throw new errors.BadRequestError();
}
// Optional fields
oneClickSubscribe: recommendation.optionalKey('one_click_subscribe')?.boolean ?? false,
reason: recommendation.optionalKey('reason')?.nullable.string ?? null,
excerpt: recommendation.optionalKey('excerpt')?.nullable.string ?? null,
featuredImage: recommendation.optionalKey('featured_image')?.nullable.url ?? null,
favicon: recommendation.optionalKey('favicon')?.nullable.url ?? null
};
return id;
return this.#serialize(
[await this.service.addRecommendation(plain)]
);
}
#getFrameInclude(frame: Frame, allowedIncludes: RecommendationInclude[]): RecommendationInclude[] {
if (!frame.options || !frame.options.withRelated) {
return [];
}
async edit(frame: Frame) {
const options = new UnsafeData(frame.options);
const data = new UnsafeData(frame.data);
const recommendation = data.key('recommendations').index(0);
const includes = frame.options.withRelated;
const id = options.key('id').string;
const plain: Partial<RecommendationPlain> = {
title: recommendation.optionalKey('title')?.string,
url: recommendation.optionalKey('url')?.url,
oneClickSubscribe: recommendation.optionalKey('one_click_subscribe')?.boolean,
reason: recommendation.optionalKey('reason')?.nullable.string,
excerpt: recommendation.optionalKey('excerpt')?.nullable.string,
featuredImage: recommendation.optionalKey('featured_image')?.nullable.url,
favicon: recommendation.optionalKey('favicon')?.nullable.url
};
// Check if all includes are allowed
const invalidIncludes = includes.filter((i: unknown) => {
if (typeof i !== 'string') {
return true;
return this.#serialize(
[await this.service.editRecommendation(id, plain)]
);
}
async destroy(frame: Frame) {
const options = new UnsafeData(frame.options);
const id = options.key('id').string;
await this.service.deleteRecommendation(id);
}
async browse(frame: Frame) {
const options = new UnsafeData(frame.options);
const page = options.optionalKey('page')?.integer ?? 1;
const limit = options.optionalKey('limit')?.integer ?? 5;
const include = options.optionalKey('withRelated')?.array.map(item => item.enum<RecommendationIncludeFields>(['count.clicks', 'count.subscribers'])) ?? [];
const order = [
{
field: 'createdAt' as const,
direction: 'desc' as const
}
return !allowedIncludes.includes(i as RecommendationInclude);
];
const count = await this.service.countRecommendations({});
const recommendations = (await this.service.listRecommendations({page, limit, order, include}));
return this.#serialize(
recommendations,
{
pagination: this.#serializePagination({page, limit, count})
}
);
}
async trackClicked(frame: Frame) {
const member = this.#optionalAuthMember(frame);
const options = new UnsafeData(frame.options);
const id = options.key('id').string;
await this.service.trackClicked({
id,
memberId: member?.id
});
}
async trackSubscribed(frame: Frame) {
const member = this.#authMember(frame);
const options = new UnsafeData(frame.options);
const id = options.key('id').string;
if (invalidIncludes.length) {
throw new errors.BadRequestError({
message: `Invalid include: ${invalidIncludes.join(',')}`
});
}
return includes as RecommendationInclude[];
await this.service.trackSubscribed({
id,
memberId: member.id
});
}
#getFramePage(frame: Frame): number {
const page = validateInteger(frame.options, 'page', {required: false, nullable: true}) ?? 1;
if (page < 1) {
throw new errors.BadRequestError({message: 'page must be greater or equal to 1'});
}
return page;
}
#getFrameLimit(frame: Frame, defaultLimit = 15): number {
const limit = validateInteger(frame.options, 'limit', {required: false, nullable: true}) ?? defaultLimit;
if (limit < 1) {
throw new errors.BadRequestError({message: 'limit must be greater or equal to 1'});
}
return limit;
}
#getFrameMemberId(frame: Frame): string {
if (!frame.options?.context?.member?.id) {
#authMember(frame: Frame): {id: string} {
const options = new UnsafeData(frame.options);
const memberId = options.key('context').optionalKey('member')?.nullable.key('id').string;
if (!memberId) {
// This is an internal server error because authentication should happen outside this service.
throw new errors.UnauthorizedError({
message: 'Member not found'
});
}
return frame.options.context.member.id;
}
#getFrameRecommendation(frame: Frame): AddRecommendation {
if (!frame.data || !frame.data.recommendations || !frame.data.recommendations[0]) {
throw new errors.BadRequestError();
}
const recommendation = frame.data.recommendations[0];
const cleanedRecommendation: AddRecommendation = {
title: validateString(recommendation, 'title') ?? '',
url: validateURL(recommendation, 'url')!,
// Optional fields
oneClickSubscribe: validateBoolean(recommendation, 'one_click_subscribe', {required: false}) ?? false,
reason: validateString(recommendation, 'reason', {required: false, nullable: true}) ?? null,
excerpt: validateString(recommendation, 'excerpt', {required: false, nullable: true}) ?? null,
featuredImage: validateURL(recommendation, 'featured_image', {required: false, nullable: true}) ?? null,
favicon: validateURL(recommendation, 'favicon', {required: false, nullable: true}) ?? null
};
// Create a new recommendation
return cleanedRecommendation;
}
#getFrameRecommendationEdit(frame: Frame): Partial<EditRecommendation> {
if (!frame.data || !frame.data.recommendations || !frame.data.recommendations[0]) {
throw new errors.BadRequestError();
}
const recommendation = frame.data.recommendations[0];
const cleanedRecommendation: EditRecommendation = {
title: validateString(recommendation, 'title', {required: false}) ?? undefined,
url: validateURL(recommendation, 'url', {required: false}) ?? undefined,
oneClickSubscribe: validateBoolean(recommendation, 'one_click_subscribe', {required: false}),
reason: validateString(recommendation, 'reason', {required: false, nullable: true}),
excerpt: validateString(recommendation, 'excerpt', {required: false, nullable: true}),
featuredImage: validateURL(recommendation, 'featured_image', {required: false, nullable: true}),
favicon: validateURL(recommendation, 'favicon', {required: false, nullable: true})
};
// Create a new recommendation
return cleanedRecommendation;
}
#returnRecommendations(recommendations: EntityWithIncludes<Recommendation, RecommendationInclude>[], meta?: any) {
return {
data: recommendations.map(({entity, includes}) => {
id: memberId
};
}
#optionalAuthMember(frame: Frame): {id: string}|null {
try {
const member = this.#authMember(frame);
return member;
} catch (e) {
if (e instanceof errors.UnauthorizedError) {
// This is fine, this is not required
} else {
throw e;
}
}
return null;
}
#serialize(recommendations: RecommendationWithIncludes[], meta?: any) {
return {
data: recommendations.map((entity) => {
const d = {
id: entity.id,
title: entity.title,
@ -216,7 +156,7 @@ export class RecommendationController {
count: undefined as undefined|{clicks?: number, subscribers?: number}
};
for (const [key, value] of includes) {
for (const [key, value] of Object.entries(entity)) {
if (key === 'count.clicks') {
if (typeof value !== 'number') {
continue;
@ -238,12 +178,6 @@ export class RecommendationController {
};
continue;
}
// This should never happen (if you get a compile error: check if you added all includes above)
const n: never = key;
throw new errors.BadRequestError({
message: `Unsupported include: ${n}`
});
}
return d;
@ -252,7 +186,7 @@ export class RecommendationController {
};
}
#buildPagination({page, limit, count}: {page: number, limit: number, count: number}) {
#serializePagination({page, limit, count}: {page: number, limit: number, count: number}) {
const pages = Math.ceil(count / limit);
return {
@ -264,77 +198,4 @@ export class RecommendationController {
next: page < pages ? page + 1 : null
};
}
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(frame: Frame) {
const page = this.#getFramePage(frame);
const limit = this.#getFrameLimit(frame, 5);
const include = this.#getFrameInclude(frame, ['count.clicks', 'count.subscribers']);
const order = [
{
field: 'createdAt' as const,
direction: 'desc' as const
}
];
const count = await this.service.countRecommendations({});
const data = (await this.service.listRecommendations({page, limit, order, include}));
return this.#returnRecommendations(
data,
{
pagination: this.#buildPagination({page, limit, count})
}
);
}
async trackClicked(frame: Frame) {
// First get the ID of the recommendation that was clicked
const id = this.#getFrameId(frame);
// Check type of event
let memberId: string | undefined;
try {
memberId = this.#getFrameMemberId(frame);
} catch (e) {
if (e instanceof errors.UnauthorizedError) {
// This is fine, this is not required
} else {
throw e;
}
}
await this.service.trackClicked({
id,
memberId
});
}
async trackSubscribed(frame: Frame) {
// First get the ID of the recommendation that was clicked
const id = this.#getFrameId(frame);
const memberId = this.#getFrameMemberId(frame);
await this.service.trackSubscribed({
id,
memberId
});
}
}

View File

@ -1,14 +1,26 @@
import {BookshelfRepository, OrderOption} from '@tryghost/bookshelf-repository';
import {AddRecommendation, Recommendation} from './Recommendation';
import {AddRecommendation, Recommendation, RecommendationPlain} from './Recommendation';
import {RecommendationRepository} from './RecommendationRepository';
import {WellknownService} from './WellknownService';
import errors from '@tryghost/errors';
import tpl from '@tryghost/tpl';
import {ClickEvent} from './ClickEvent';
import {SubscribeEvent} from './SubscribeEvent';
import {EntityWithIncludes} from './EntityWithIncludes';
export type RecommendationInclude = 'count.clicks'|'count.subscribers';
export type RecommendationIncludeTypes = {
'count.clicks': number,
'count.subscribers': number
};
export type RecommendationIncludeFields = keyof RecommendationIncludeTypes;
/**
* All includes are optional, but if they are explicitly loaded, they will not be optional in the result.
*
* E.g. RecommendationWithIncludes['count.clicks'|'count.subscribers'].
*
* When using methods like listRecommendations with the include option, the result will automatically return the correct relations
*/
export type RecommendationWithIncludes<IncludeFields extends RecommendationIncludeFields = never> = RecommendationPlain & Partial<RecommendationIncludeTypes> & Record<IncludeFields, RecommendationIncludeTypes[IncludeFields]>;
type MentionSendingService = {
sendAll(options: {url: URL, links: URL[]}): Promise<void>
@ -49,7 +61,7 @@ export class RecommendationService {
}
async init() {
const recommendations = (await this.listRecommendations()).map(r => r.entity);
const recommendations = await this.#listRecommendations();
await this.updateWellknown(recommendations);
}
@ -77,7 +89,7 @@ export class RecommendationService {
}).catch(console.error); // eslint-disable-line no-console
}
async addRecommendation(addRecommendation: AddRecommendation) {
async addRecommendation(addRecommendation: AddRecommendation): Promise<RecommendationPlain> {
const recommendation = Recommendation.create(addRecommendation);
// If a recommendation with this URL already exists, throw an error
@ -90,16 +102,16 @@ export class RecommendationService {
await this.repository.save(recommendation);
const recommendations = (await this.listRecommendations()).map(r => r.entity);
const recommendations = await this.#listRecommendations();
await this.updateWellknown(recommendations);
await this.updateRecommendationsEnabledSetting(recommendations);
// Only send an update for the mentioned URL
this.sendMentionToRecommendation(recommendation);
return EntityWithIncludes.create<Recommendation, RecommendationInclude>(recommendation);
return recommendation.plain;
}
async editRecommendation(id: string, recommendationEdit: Partial<Recommendation>) {
async editRecommendation(id: string, recommendationEdit: Partial<Recommendation>): Promise<RecommendationPlain> {
// Check if it exists
const existing = await this.repository.getById(id);
if (!existing) {
@ -111,11 +123,11 @@ export class RecommendationService {
existing.edit(recommendationEdit);
await this.repository.save(existing);
const recommendations = (await this.listRecommendations()).map(r => r.entity);
const recommendations = await this.#listRecommendations();
await this.updateWellknown(recommendations);
this.sendMentionToRecommendation(existing);
return EntityWithIncludes.create<Recommendation, RecommendationInclude>(existing);
return existing.plain;
}
async deleteRecommendation(id: string) {
@ -129,7 +141,7 @@ export class RecommendationService {
existing.delete();
await this.repository.save(existing);
const recommendations = (await this.listRecommendations()).map(r => r.entity);
const recommendations = await this.#listRecommendations();
await this.updateWellknown(recommendations);
await this.updateRecommendationsEnabledSetting(recommendations);
@ -137,61 +149,73 @@ export class RecommendationService {
this.sendMentionToRecommendation(existing);
}
async listRecommendations({page, limit, filter, order, include}: { page: number; limit: number | 'all', filter?: string, order?: OrderOption<Recommendation>, include?: RecommendationInclude[] } = {page: 1, limit: 'all'}): Promise<EntityWithIncludes<Recommendation, RecommendationInclude>[]> {
async #listRecommendations({page, limit, filter, order}: { page: number; limit: number | 'all', filter?: string, order?: OrderOption<Recommendation>} = {page: 1, limit: 'all'}): Promise<Recommendation[]> {
let list: Recommendation[];
if (limit === 'all') {
list = await this.repository.getAll({filter, order});
} else {
if (page < 1) {
throw new errors.BadRequestError({message: 'page must be greater or equal to 1'});
}
if (limit < 1) {
throw new errors.BadRequestError({message: 'limit must be greater or equal to 1'});
}
list = await this.repository.getPage({page, limit, filter, order});
}
// Transform to includes
const entities = list.map(entity => EntityWithIncludes.create<Recommendation, RecommendationInclude>(entity));
await this.loadRelations(entities, include);
return entities;
return list;
}
async loadRelations(list: EntityWithIncludes<Recommendation, RecommendationInclude>[], include?: RecommendationInclude[]) {
/**
* Same as #listRecommendations, but with includes and returns a plain object for external use
*/
async listRecommendations<IncludeFields extends RecommendationIncludeFields = never>({page, limit, filter, order, include}: { page: number; limit: number | 'all', filter?: string, order?: OrderOption<Recommendation>, include?: IncludeFields[] } = {page: 1, limit: 'all', include: []}): Promise<RecommendationWithIncludes<IncludeFields>[]> {
const list = await this.#listRecommendations({page, limit, filter, order});
return await this.loadRelations(list, include);
}
async loadRelations<IncludeFields extends RecommendationIncludeFields>(list: Recommendation[], include?: IncludeFields[]): Promise<RecommendationWithIncludes<IncludeFields>[]> {
const plainList: RecommendationWithIncludes[] = list.map(e => e.plain);
if (!include || !include.length) {
return;
return plainList as RecommendationWithIncludes<IncludeFields>[];
}
if (list.length === 0) {
// Avoid doing queries with broken filters
return;
return plainList as RecommendationWithIncludes<IncludeFields>[];
}
for (const relation of include) {
switch (relation) {
case 'count.clicks':
const clickCounts = await this.clickEventRepository.getGroupedCount({groupBy: 'recommendationId', filter: `recommendationId:[${list.map(entity => entity.entity.id).join(',')}]`});
const clickCounts = await this.clickEventRepository.getGroupedCount({groupBy: 'recommendationId', filter: `recommendationId:[${list.map(entity => entity.id).join(',')}]`});
// Set all to zero by default
for (const entity of list) {
entity.setInclude(relation, 0);
for (const entity of plainList) {
entity[relation] = 0;
}
for (const r of clickCounts) {
const entity = list.find(e => e.entity.id === r.recommendationId);
const entity = plainList.find(e => e.id === r.recommendationId);
if (entity) {
entity.setInclude(relation, r.count);
entity[relation] = r.count;
}
}
break;
case 'count.subscribers':
const subscribersCounts = await this.subscribeEventRepository.getGroupedCount({groupBy: 'recommendationId', filter: `recommendationId:[${list.map(entity => entity.entity.id).join(',')}]`});
const subscribersCounts = await this.subscribeEventRepository.getGroupedCount({groupBy: 'recommendationId', filter: `recommendationId:[${list.map(entity => entity.id).join(',')}]`});
// Set all to zero by default
for (const entity of list) {
entity.setInclude(relation, 0);
for (const entity of plainList) {
entity[relation] = 0;
}
for (const r of subscribersCounts) {
const entity = list.find(e => e.entity.id === r.recommendationId);
const entity = plainList.find(e => e.id === r.recommendationId);
if (entity) {
entity.setInclude(relation, r.count);
entity[relation] = r.count;
}
}
@ -202,6 +226,8 @@ export class RecommendationService {
console.error(`Unknown relation ${r}`); // eslint-disable-line no-console
}
}
return plainList as RecommendationWithIncludes<IncludeFields>[];
}
async countRecommendations({filter}: { filter?: string }) {

View File

@ -0,0 +1,206 @@
import errors from '@tryghost/errors';
type UnsafeDataContext = {
field?: string[]
}
function serializeField(field: string[]) {
if (field.length === 0) {
return 'data';
}
return field.join('.');
}
type NullData = {
readonly string: null,
readonly boolean: null,
readonly number: null,
readonly integer: null,
readonly url: null
enum(): null
key(key: string): NullData
optionalKey(key: string): NullData
readonly array: NullData
index(index: number): NullData
}
/**
* NOTE: should be moved to a separate package in case this pattern is found to be useful
*/
export class UnsafeData {
protected data: unknown;
protected context: UnsafeDataContext;
constructor(data: unknown, context: UnsafeDataContext = {}) {
this.data = data;
this.context = context;
}
protected get field() {
return serializeField(this.context.field ?? []);
}
protected addKeyToField(key: string) {
return this.context.field ? [...this.context.field, key] : [key];
}
protected fieldWithKey(key: string) {
return serializeField(this.addKeyToField(key));
}
/**
* Returns undefined if the key is not present on the object. Note that this doesn't check for null.
*/
optionalKey(key: string): UnsafeData|undefined {
if (typeof this.data !== 'object' || this.data === null) {
throw new errors.ValidationError({message: `${this.fieldWithKey(key)} must be an object`});
}
if (!Object.prototype.hasOwnProperty.call(this.data, key)) {
return undefined;
}
return new UnsafeData((this.data as Record<string, unknown>)[key], {
field: this.addKeyToField(key)
});
}
key(key: string): UnsafeData {
if (typeof this.data !== 'object' || this.data === null) {
throw new errors.ValidationError({message: `${this.fieldWithKey(key)} must be an object`});
}
if (!Object.prototype.hasOwnProperty.call(this.data, key)) {
throw new errors.ValidationError({message: `${this.fieldWithKey(key)} is required`});
}
return new UnsafeData((this.data as Record<string, unknown>)[key], {
field: this.addKeyToField(key)
});
}
/**
* Use this to get a nullable value:
* ```
* const url: string|null = data.key('url').nullable.string
* ```
*/
get nullable(): UnsafeData|NullData {
if (this.data === null) {
const d: NullData = {
get string() {
return null;
},
get boolean() {
return null;
},
get number() {
return null;
},
get integer() {
return null;
},
get url() {
return null;
},
enum() {
return null;
},
key() {
return d;
},
optionalKey() {
return d;
},
get array() {
return d;
},
index() {
return d;
}
};
return d;
}
return this;
}
get string(): string {
if (typeof this.data !== 'string') {
throw new errors.ValidationError({message: `${this.field} must be a string`});
}
return this.data;
}
get boolean(): boolean {
if (typeof this.data !== 'boolean') {
throw new errors.ValidationError({message: `${this.field} must be a boolean`});
}
return this.data;
}
get number(): number {
if (typeof this.data === 'string') {
return new UnsafeData(parseFloat(this.data), this.context).number;
}
if (typeof this.data !== 'number') {
throw new errors.ValidationError({message: `${this.field} must be a number, got ${typeof this.data}`});
}
if (Number.isNaN(this.data) || !Number.isFinite(this.data)) {
throw new errors.ValidationError({message: `${this.field} must be a finite number`});
}
return this.data;
}
get integer(): number {
if (typeof this.data === 'string') {
return new UnsafeData(parseInt(this.data), this.context).integer;
}
const number = this.number;
if (!Number.isSafeInteger(number)) {
throw new errors.ValidationError({message: `${this.field} must be an integer`});
}
return number;
}
get url(): URL {
if (this.data instanceof URL) {
return this.data;
}
const string = this.string;
try {
const url = new URL(string);
if (!['http:', 'https:'].includes(url.protocol)) {
throw new errors.ValidationError({message: `${this.field} must be a valid URL`});
}
return url;
} catch (e) {
throw new errors.ValidationError({message: `${this.field} must be a valid URL`});
}
}
enum<T>(allowedValues: T[]): T {
if (!allowedValues.includes(this.data as T)) {
throw new errors.ValidationError({message: `${this.field} must be one of ${allowedValues.join(',')}`});
}
return this.data as T;
}
get array(): UnsafeData[] {
if (!Array.isArray(this.data)) {
throw new errors.ValidationError({message: `${this.field} must be an array`});
}
return this.data.map((d, i) => new UnsafeData(d, {field: this.addKeyToField(`${i}`)}));
}
index(index: number) {
const arr = this.array;
if (index < 0 || index >= arr.length) {
throw new errors.ValidationError({message: `${this.field} must be an array of length ${index + 1}`});
}
return arr[index];
}
};