Added stats to recommendations endpoints

fixes https://github.com/TryGhost/Product/issues/3854
fixes https://github.com/TryGhost/Product/issues/3864
This commit is contained in:
Simon Backx 2023-09-15 14:38:43 +02:00 committed by Simon Backx
parent 6e68c43f78
commit 4e2710ada2
13 changed files with 851 additions and 75 deletions

View File

@ -10,7 +10,8 @@ export type Recommendation = {
url: string
one_click_subscribe: boolean
created_at: string,
updated_at: string|null
updated_at: string|null,
count?: {subscribers?: number, clicks?: number}
}
export type EditOrAddRecommendation = Omit<Recommendation, 'id'|'created_at'|'updated_at'> & {id?: string};

View File

@ -16,7 +16,11 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
handleSave
} = useSettingGroup();
const {pagination, data: {recommendations} = {}, isLoading} = useBrowseRecommendations();
const {pagination, data: {recommendations} = {}, isLoading} = useBrowseRecommendations({
searchParams: {
include: 'count.clicks,count.subscribers'
}
});
const [selectedTab, setSelectedTab] = useState('your-recommendations');
const {updateRoute} = useRouting();

View File

@ -55,6 +55,9 @@ const RecommendationItem: React.FC<{recommendation: Recommendation}> = ({recomme
updateRoute({route: `recommendations/${recommendation.id}`});
};
const showSubscribes = recommendation.one_click_subscribe && (recommendation.count?.subscribers || recommendation.count?.clicks === 0);
const count = (showSubscribes ? recommendation.count?.subscribers : recommendation.count?.clicks) || 0;
return (
<TableRow action={action} hideActions>
<TableCell onClick={showDetails}>
@ -70,11 +73,8 @@ const RecommendationItem: React.FC<{recommendation: Recommendation}> = ({recomme
</TableCell>
<TableCell className='hidden md:!visible md:!table-cell' onClick={showDetails}>
<div className={`flex grow flex-col`}>
{/* If it's 0 */}
{/* <span className="text-grey-500">-</span> */}
{/* If it's more than 0 */}
<span>12</span>
<span className='whitespace-nowrap text-xs text-grey-700'>Subscribers from you</span>
{count === 0 ? <span className="text-grey-500">-</span> : <span>{count}</span>}
<span className='whitespace-nowrap text-xs text-grey-700'>{showSubscribes ? 'Subscribers from you' : 'Clicks from you'}</span>
</div>
</TableCell>
</TableRow>

View File

@ -17,7 +17,7 @@ export type ModelClass<T> = {
add: (data: object) => Promise<ModelInstance<T>>;
getFilteredCollection: (options: {filter?: string, mongoTransformer?: unknown}) => {
count(): Promise<number>,
query: (f: (q: Knex.QueryBuilder) => void) => void,
query: (f?: (q: Knex.QueryBuilder) => void) => Knex.QueryBuilder,
fetchAll: () => Promise<ModelInstance<T>[]>
};
}
@ -132,4 +132,23 @@ export abstract class BookshelfRepository<IDType, T extends Entity<IDType>> {
});
return await collection.count();
}
async getGroupedCount<K extends keyof T>({filter, groupBy}: { filter?: string, groupBy: K }): Promise<({count: number} & Record<K, T[K]>)[]> {
const columnName = this.#entityFieldToColumn(groupBy);
const data = (await this.Model.getFilteredCollection({
filter,
mongoTransformer: this.#getNQLKeyTransformer()
}).query()
.select(columnName)
.count('* as count')
.groupBy(columnName)) as ({count: number} & Record<string, T[K]>)[];
return data.map((row) => {
return {
count: row.count,
[groupBy]: row[columnName]
};
}) as ({count: number} & Record<K, T[K]>)[];
}
}

View File

@ -47,6 +47,7 @@ class Model implements ModelClass<string> {
orderRaw?: string;
limit?: number;
offset?: number;
returnCount = false;
constructor() {
this.items = [];
@ -114,21 +115,41 @@ class Model implements ModelClass<string> {
}
// eslint-disable-next-line no-unused-vars
query(f: (q: Knex.QueryBuilder) => void) {
return f({
query(f?: (q: Knex.QueryBuilder) => void): Knex.QueryBuilder {
const builder = {
limit: (limit: number) => {
this.limit = limit;
return this;
return builder;
},
offset: (offset: number) => {
this.offset = offset;
return this;
return builder;
},
orderByRaw: (order: string) => {
this.orderRaw = order;
return this;
return builder;
},
select: () => {
return builder;
},
count: () => {
return builder;
},
groupBy: (field: string) => {
return Promise.resolve([
{
[field]: 5,
count: 5
}
]);
}
} as any as Knex.QueryBuilder);
} as any as Knex.QueryBuilder;
if (f) {
f(builder);
}
return builder;
}
}
@ -351,4 +372,36 @@ describe('BookshelfRepository', function () {
const result = await repository.getCount({});
assert(result === 3);
});
it('Can retrieve grouped count', async function () {
const repository = new SimpleBookshelfRepository(new Model());
const entities = [{
id: '1',
deleted: false,
name: 'Kym',
age: 24,
birthday: new Date('2000-01-01').toISOString()
}, {
id: '2',
deleted: false,
name: 'John',
age: 30,
birthday: new Date('2000-01-01').toISOString()
}, {
id: '3',
deleted: false,
name: 'Kevin',
age: 5,
birthday: new Date('2000-01-01').toISOString()
}];
for (const entity of entities) {
await repository.save(entity);
}
const result = await repository.getGroupedCount({groupBy: 'age'});
assert(result.length === 1);
assert(result[0].age === 5);
assert(result[0].count === 5);
});
});

View File

@ -9,7 +9,8 @@ module.exports = {
},
options: [
'limit',
'page'
'page',
'include'
],
permissions: true,
validation: {},

View File

@ -120,10 +120,10 @@ Object {
"featured_image": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"reason": null,
"title": "Dog Pictures",
"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://dogpictures.com/",
"url": "https://recommendation0.com/",
},
],
}
@ -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": "372",
"content-length": "386",
"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,321 @@ Object {
}
`;
exports[`Recommendations Admin API Can fetch recommendations when there are none 1: [body] 1`] = `
Object {
"meta": Object {
"pagination": Object {
"limit": 5,
"next": null,
"page": 1,
"pages": 0,
"prev": null,
"total": 0,
},
},
"recommendations": Array [],
}
`;
exports[`Recommendations Admin API Can fetch recommendations when there are none 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": "109",
"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 fetch recommendations with relations when there are no recommendations 1: [body] 1`] = `
Object {
"meta": Object {
"pagination": Object {
"limit": 5,
"next": null,
"page": 1,
"pages": 0,
"prev": null,
"total": 0,
},
},
"recommendations": Array [],
}
`;
exports[`Recommendations Admin API Can fetch recommendations with relations when there are no recommendations 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": "109",
"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 fetch recommendations with relations when there are none 1: [body] 1`] = `
Object {
"meta": Object {
"pagination": Object {
"limit": 5,
"next": null,
"page": 1,
"pages": 0,
"prev": null,
"total": 0,
},
},
"recommendations": Array [],
}
`;
exports[`Recommendations Admin API Can fetch recommendations with relations when there are none 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": "109",
"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 click and subscribe counts 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,
"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,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"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,
"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,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"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,
"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,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"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,
"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,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"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,
"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,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"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 click and subscribe counts 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": "1683",
"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 click counts 1: [body] 1`] = `
Object {
"meta": Object {
"pagination": Object {
"limit": 5,
"next": 2,
"page": 1,
"pages": 4,
"prev": null,
"total": 16,
},
},
"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": "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",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://dogpictures.com/",
},
Object {
"count": Object {
"clicks": 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,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"reason": "Reason 14",
"title": "Recommendation 14",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation14.com/",
},
Object {
"count": Object {
"clicks": 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,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"reason": "Reason 13",
"title": "Recommendation 13",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation13.com/",
},
Object {
"count": Object {
"clicks": 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,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"reason": "Reason 12",
"title": "Recommendation 12",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation12.com/",
},
Object {
"count": Object {
"clicks": 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,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"one_click_subscribe": false,
"reason": "Reason 11",
"title": "Recommendation 11",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation11.com/",
},
],
}
`;
exports[`Recommendations Admin API Can include click counts 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": "1690",
"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 click counts 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": "1690",
"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 {
@ -429,6 +744,43 @@ Object {
}
`;
exports[`Recommendations Admin API Cannot add the same recommendation twice 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",
"updated_at": null,
"url": "https://dogpictures.com/",
},
],
}
`;
exports[`Recommendations Admin API Cannot add the same recommendation twice 2: [body] 1`] = `
Object {
"errors": Array [
Object {
"code": null,
"context": "A recommendation with this URL already exists.",
"details": null,
"ghostErrorCode": null,
"help": null,
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
"message": "Validation error, cannot save recommendation.",
"property": null,
"type": "ValidationError",
},
],
}
`;
exports[`Recommendations Admin API Cannot edit to invalid recommendation state 1: [body] 1`] = `
Object {
"errors": Array [
@ -495,7 +847,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": "109",
"content-length": "1495",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View File

@ -2,26 +2,40 @@ const {agentProvider, fixtureManager, mockManager, matchers} = require('../../ut
const {anyObjectId, anyErrorId, anyISODateTime, anyContentVersion, anyLocationFor, anyEtag} = matchers;
const assert = require('assert/strict');
const recommendationsService = require('../../../core/server/services/recommendations');
const {Recommendation} = require('@tryghost/recommendations');
const {Recommendation, ClickEvent, SubscribeEvent} = require('@tryghost/recommendations');
async function addDummyRecommendation(agent) {
await agent.post('recommendations/').body({
recommendations: [{
title: 'Dog Pictures',
url: 'https://dogpictures.com'
}]
async function addDummyRecommendation(i = 0) {
const recommendation = Recommendation.create({
title: `Recommendation ${i}`,
reason: `Reason ${i}`,
url: new URL(`https://recommendation${i}.com`),
favicon: null,
featuredImage: null,
excerpt: null,
oneClickSubscribe: false,
createdAt: new Date(i * 5000) // Reliable ordering
});
const id = (await recommendationsService.repository.getAll())[0].id;
return id;
await recommendationsService.repository.save(recommendation);
return recommendation.id;
}
async function addDummyRecommendations(amount = 15) {
// Add 15 recommendations using the repository
for (let i = 0; i < amount; i++) {
await addDummyRecommendation(i);
}
}
describe('Recommendations Admin API', function () {
let agent;
let agent, memberId;
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('posts');
await fixtureManager.init('posts', 'members');
await agent.loginAsOwner();
memberId = fixtureManager.get('members', 0).id;
});
afterEach(async function () {
@ -29,10 +43,24 @@ describe('Recommendations Admin API', function () {
recommendation.delete();
await recommendationsService.repository.save(recommendation);
}
mockManager.restore();
});
it('Can fetch recommendations with relations when there are no recommendations', async function () {
const recommendations = await recommendationsService.repository.getCount();
assert.equal(recommendations, 0, 'This test expects there to be no recommendations');
const {body: page1} = await agent.get('recommendations/?include=count.clicks,count.subscribers')
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({});
assert.equal(page1.recommendations.length, 0);
});
it('Can add a minimal recommendation', async function () {
const {body} = await agent.post('recommendations/')
.body({
@ -111,6 +139,14 @@ describe('Recommendations Admin API', function () {
title: 'Dog Pictures',
url: 'https://dogpictures.com'
}]
})
.matchBodySnapshot({
recommendations: [
{
id: anyObjectId,
created_at: anyISODateTime
}
]
});
await agent.post('recommendations/')
@ -120,11 +156,18 @@ describe('Recommendations Admin API', function () {
url: 'https://dogpictures.com'
}]
})
.expectStatus(422);
.expectStatus(422)
.matchBodySnapshot({
errors: [
{
id: anyErrorId
}
]
});
});
it('Can edit recommendation', async function () {
const id = await addDummyRecommendation(agent);
const id = await addDummyRecommendation();
const {body} = await agent.put(`recommendations/${id}/`)
.body({
recommendations: [{
@ -164,7 +207,7 @@ describe('Recommendations Admin API', function () {
});
it('Cannot use invalid protocols when editing', async function () {
const id = await addDummyRecommendation(agent);
const id = await addDummyRecommendation();
await agent.put(`recommendations/${id}/`)
.body({
@ -193,7 +236,7 @@ describe('Recommendations Admin API', function () {
});
it('Can delete recommendation', async function () {
const id = await addDummyRecommendation(agent);
const id = await addDummyRecommendation();
await agent.delete(`recommendations/${id}/`)
.expectStatus(204)
.matchHeaderSnapshot({
@ -204,7 +247,7 @@ describe('Recommendations Admin API', function () {
});
it('Can browse', async function () {
await addDummyRecommendation(agent);
await addDummyRecommendation();
await agent.get('recommendations/')
.expectStatus(200)
@ -225,20 +268,7 @@ describe('Recommendations Admin API', function () {
it('Can request pages', async function () {
// Add 15 recommendations using the repository
for (let i = 0; i < 15; i++) {
const recommendation = Recommendation.create({
title: `Recommendation ${i}`,
reason: `Reason ${i}`,
url: new URL(`https://recommendation${i}.com`),
favicon: null,
featuredImage: null,
excerpt: null,
oneClickSubscribe: false,
createdAt: new Date(i * 5000) // Reliable ordering
});
await recommendationsService.repository.save(recommendation);
}
await addDummyRecommendations(15);
const {body: page1} = await agent.get('recommendations/?page=1&limit=10')
.expectStatus(200)
@ -284,6 +314,7 @@ describe('Recommendations Admin API', function () {
});
it('Uses default limit of 5', async function () {
await addDummyRecommendations(6);
const {body: page1} = await agent.get('recommendations/')
.expectStatus(200)
.matchHeaderSnapshot({
@ -293,4 +324,68 @@ describe('Recommendations Admin API', function () {
assert.equal(page1.meta.pagination.limit, 5);
});
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);
}
const {body: page1} = await agent.get('recommendations/?include=count.clicks,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, 2);
assert.equal(page1.recommendations[1].count.clicks, 3);
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

@ -143,3 +143,93 @@ Object {
"x-powered-by": "Express",
}
`;
exports[`Recommendations Content API Does not allow includes 1: [body] 1`] = `
Object {
"meta": Object {
"pagination": Object {
"limit": 5,
"next": 2,
"page": 1,
"pages": 2,
"prev": null,
"total": 7,
},
},
"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": "Reason 6",
"title": "Recommendation 6",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation6.com/",
},
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": "Reason 5",
"title": "Recommendation 5",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"url": "https://recommendation5.com/",
},
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": "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 {
"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": "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 {
"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": "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/",
},
],
}
`;
exports[`Recommendations Content API Does not allow includes 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "*",
"cache-control": "public, max-age=0",
"content-length": "1495",
"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, Accept-Encoding",
"x-powered-by": "Express",
}
`;

View File

@ -2,6 +2,7 @@ const {agentProvider, fixtureManager, matchers} = require('../../utils/e2e-frame
const recommendationsService = require('../../../core/server/services/recommendations');
const {Recommendation} = require('@tryghost/recommendations');
const {anyObjectId, anyISODateTime} = matchers;
const assert = require('assert/strict');
describe('Recommendations Content API', function () {
let agent;
@ -63,4 +64,26 @@ describe('Recommendations Content API', function () {
})
});
});
it('Does not allow includes', async function () {
const {body} = await agent.get(`recommendations/?include=count.clicks,count.subscribers`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': matchers.anyContentVersion,
etag: matchers.anyEtag
})
.matchBodySnapshot({
recommendations: new Array(5).fill({
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime
})
});
assert(body.recommendations[0].count === undefined);
assert(body.recommendations[1].count === undefined);
assert(body.recommendations[2].count === undefined);
assert(body.recommendations[3].count === undefined);
assert(body.recommendations[4].count === undefined);
});
});

View File

@ -0,0 +1,17 @@
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,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {EntityWithIncludes} from './EntityWithIncludes';
import {AddRecommendation, EditRecommendation, Recommendation} from './Recommendation';
import {RecommendationService} from './RecommendationService';
import {RecommendationInclude, RecommendationService} from './RecommendationService';
import errors from '@tryghost/errors';
type Frame = {
@ -105,6 +106,29 @@ export class RecommendationController {
return id;
}
#getFrameInclude(frame: Frame, allowedIncludes: RecommendationInclude[]): RecommendationInclude[] {
if (!frame.options || !frame.options.withRelated) {
return [];
}
const includes = frame.options.withRelated;
// Check if all includes are allowed
const invalidIncludes = includes.filter((i: unknown) => {
if (typeof i !== 'string') {
return true;
}
return !allowedIncludes.includes(i as RecommendationInclude);
});
if (invalidIncludes.length) {
throw new errors.BadRequestError({
message: `Invalid include: ${invalidIncludes.join(',')}`
});
}
return includes as RecommendationInclude[];
}
#getFramePage(frame: Frame): number {
const page = validateInteger(frame.options, 'page', {required: false, nullable: true}) ?? 1;
if (page < 1) {
@ -175,21 +199,54 @@ export class RecommendationController {
return cleanedRecommendation;
}
#returnRecommendations(recommendations: Recommendation[], meta?: any) {
#returnRecommendations(recommendations: EntityWithIncludes<Recommendation, RecommendationInclude>[], meta?: any) {
return {
data: recommendations.map((r) => {
return {
id: r.id,
title: r.title,
reason: r.reason,
excerpt: r.excerpt,
featured_image: r.featuredImage?.toString() ?? null,
favicon: r.favicon?.toString() ?? null,
url: r.url.toString(),
one_click_subscribe: r.oneClickSubscribe,
created_at: r.createdAt,
updated_at: r.updatedAt
data: recommendations.map(({entity, includes}) => {
const d = {
id: entity.id,
title: entity.title,
reason: entity.reason,
excerpt: entity.excerpt,
featured_image: entity.featuredImage?.toString() ?? null,
favicon: entity.favicon?.toString() ?? null,
url: entity.url.toString(),
one_click_subscribe: entity.oneClickSubscribe,
created_at: entity.createdAt,
updated_at: entity.updatedAt,
count: undefined as undefined|{clicks?: number, subscribers?: number}
};
for (const [key, value] of includes) {
if (key === 'count.clicks') {
if (typeof value !== 'number') {
continue;
}
d.count = {
...(d.count ?? {}),
clicks: value
};
continue;
}
if (key === 'count.subscribers') {
if (typeof value !== 'number') {
continue;
}
d.count = {
...(d.count ?? {}),
subscribers: value
};
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;
}),
meta
};
@ -232,6 +289,7 @@ export class RecommendationController {
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,
@ -240,7 +298,7 @@ export class RecommendationController {
];
const count = await this.service.countRecommendations({});
const data = (await this.service.listRecommendations({page, limit, order}));
const data = (await this.service.listRecommendations({page, limit, order, include}));
return this.#returnRecommendations(
data,

View File

@ -6,6 +6,9 @@ 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';
type MentionSendingService = {
sendAll(options: {url: URL, links: URL[]}): Promise<void>
@ -46,7 +49,7 @@ export class RecommendationService {
}
async init() {
const recommendations = await this.listRecommendations();
const recommendations = (await this.listRecommendations()).map(r => r.entity);
await this.updateWellknown(recommendations);
}
@ -87,13 +90,13 @@ export class RecommendationService {
await this.repository.save(recommendation);
const recommendations = await this.listRecommendations();
const recommendations = (await this.listRecommendations()).map(r => r.entity);
await this.updateWellknown(recommendations);
await this.updateRecommendationsEnabledSetting(recommendations);
// Only send an update for the mentioned URL
this.sendMentionToRecommendation(recommendation);
return recommendation;
return EntityWithIncludes.create<Recommendation, RecommendationInclude>(recommendation);
}
async editRecommendation(id: string, recommendationEdit: Partial<Recommendation>) {
@ -108,11 +111,11 @@ export class RecommendationService {
existing.edit(recommendationEdit);
await this.repository.save(existing);
const recommendations = await this.listRecommendations();
const recommendations = (await this.listRecommendations()).map(r => r.entity);
await this.updateWellknown(recommendations);
this.sendMentionToRecommendation(existing);
return existing;
return EntityWithIncludes.create<Recommendation, RecommendationInclude>(existing);
}
async deleteRecommendation(id: string) {
@ -126,7 +129,7 @@ export class RecommendationService {
existing.delete();
await this.repository.save(existing);
const recommendations = await this.listRecommendations();
const recommendations = (await this.listRecommendations()).map(r => r.entity);
await this.updateWellknown(recommendations);
await this.updateRecommendationsEnabledSetting(recommendations);
@ -134,11 +137,71 @@ export class RecommendationService {
this.sendMentionToRecommendation(existing);
}
async listRecommendations({page, limit, filter, order}: { page: number; limit: number | 'all', filter?: string, order?: OrderOption<Recommendation> } = {page: 1, limit: 'all'}) {
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>[]> {
let list: Recommendation[];
if (limit === 'all') {
return await this.repository.getAll({filter, order});
list = await this.repository.getAll({filter, order});
} else {
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;
}
async loadRelations(list: EntityWithIncludes<Recommendation, RecommendationInclude>[], include?: RecommendationInclude[]) {
if (!include || !include.length) {
return;
}
if (list.length === 0) {
// Avoid doing queries with broken filters
return;
}
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(',')}]`});
// Set all to zero by default
for (const entity of list) {
entity.setInclude(relation, 0);
}
for (const r of clickCounts) {
const entity = list.find(e => e.entity.id === r.recommendationId);
if (entity) {
entity.setInclude(relation, r.count);
}
}
break;
case 'count.subscribers':
const subscribersCounts = await this.subscribeEventRepository.getGroupedCount({groupBy: 'recommendationId', filter: `recommendationId:[${list.map(entity => entity.entity.id).join(',')}]`});
// Set all to zero by default
for (const entity of list) {
entity.setInclude(relation, 0);
}
for (const r of subscribersCounts) {
const entity = list.find(e => e.entity.id === r.recommendationId);
if (entity) {
entity.setInclude(relation, r.count);
}
}
break;
default:
// Should create a Type compile error in case we didn't catch all relations
const r: never = relation;
console.error(`Unknown relation ${r}`); // eslint-disable-line no-console
}
}
return await this.repository.getPage({page, limit, filter, order});
}
async countRecommendations({filter}: { filter?: string }) {