mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-24 03:14:03 +03:00
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:
parent
6e68c43f78
commit
4e2710ada2
@ -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};
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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]>)[];
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -9,7 +9,8 @@ module.exports = {
|
||||
},
|
||||
options: [
|
||||
'limit',
|
||||
'page'
|
||||
'page',
|
||||
'include'
|
||||
],
|
||||
permissions: true,
|
||||
validation: {},
|
||||
|
@ -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 \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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",
|
||||
}
|
||||
`;
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
17
ghost/recommendations/src/EntityWithIncludes.ts
Normal file
17
ghost/recommendations/src/EntityWithIncludes.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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 }) {
|
||||
|
Loading…
Reference in New Issue
Block a user