Added order option to recommendations API with sorting on counts (#18417)
refs https://github.com/TryGhost/Product/issues/3957 This changes how we fetch recommendations: - Relations can be included in one query instead of extra queries - Sorting is now possible by click or subscriber counts
This commit is contained in:
parent
1f251f63ac
commit
b5fc527f8d
@ -22,7 +22,8 @@
|
||||
"devDependencies": {
|
||||
"c8": "7.14.0",
|
||||
"mocha": "10.2.0",
|
||||
"sinon": "15.2.0"
|
||||
"sinon": "15.2.0",
|
||||
"@tryghost/nql": "0.11.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tryghost/mongo-utils": "0.5.0",
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {Knex} from 'knex';
|
||||
import {mapKeys, chainTransformers} from '@tryghost/mongo-utils';
|
||||
import errors from '@tryghost/errors';
|
||||
|
||||
type Entity<T> = {
|
||||
id: T;
|
||||
@ -29,8 +30,18 @@ export type ModelInstance<T> = {
|
||||
save(properties: object, options?: {autoRefresh?: boolean; method?: 'update' | 'insert'}): Promise<ModelInstance<T>>;
|
||||
}
|
||||
|
||||
type OptionalPropertyOf<T extends object> = Exclude<{
|
||||
[K in keyof T]: T extends Record<K, Exclude<T[K], undefined>>
|
||||
? never
|
||||
: K
|
||||
}[keyof T], undefined>
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type OrderOption<T extends Entity<any> = any> = Order<T>[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type IncludeOption<T extends Entity<any> = any> = OptionalPropertyOf<T>[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type AllOptions<T extends Entity<any> = any> = { filter?: string; order?: OrderOption<T>; page?: number; limit?: number, include?: IncludeOption<T> }
|
||||
|
||||
export abstract class BookshelfRepository<IDType, T extends Entity<IDType>> {
|
||||
protected Model: ModelClass<IDType>;
|
||||
@ -43,6 +54,14 @@ export abstract class BookshelfRepository<IDType, T extends Entity<IDType>> {
|
||||
protected abstract modelToEntity (model: ModelInstance<IDType>): Promise<T|null> | T | null
|
||||
protected abstract getFieldToColumnMap(): Record<keyof T, string>;
|
||||
|
||||
/**
|
||||
* override this method to add custom query logic to knex queries
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
applyCustomQuery(query: Knex.QueryBuilder, options: AllOptions<T>) {
|
||||
return;
|
||||
}
|
||||
|
||||
#entityFieldToColumn(field: keyof T): string {
|
||||
const mapping = this.getFieldToColumnMap();
|
||||
return mapping[field];
|
||||
@ -78,50 +97,71 @@ export abstract class BookshelfRepository<IDType, T extends Entity<IDType>> {
|
||||
}
|
||||
|
||||
async getById(id: IDType): Promise<T | null> {
|
||||
const model = await this.Model.findOne({id}, {require: false}) as ModelInstance<IDType> | null;
|
||||
return model ? this.modelToEntity(model) : null;
|
||||
const models = await this.#fetchAll({
|
||||
filter: `id:'${id}'`,
|
||||
limit: 1
|
||||
});
|
||||
if (models.length === 1) {
|
||||
return models[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async #fetchAll({filter, order, page, limit}: { filter?: string; order?: OrderOption<T>; page?: number; limit?: number }): Promise<T[]> {
|
||||
async #fetchAll(options: AllOptions<T> = {}): Promise<T[]> {
|
||||
const {filter, order, page, limit} = options;
|
||||
if (page !== undefined) {
|
||||
if (page < 1) {
|
||||
throw new errors.BadRequestError({message: 'page must be greater or equal to 1'});
|
||||
}
|
||||
if (limit !== undefined && limit < 1) {
|
||||
throw new errors.BadRequestError({message: 'limit must be greater or equal to 1'});
|
||||
}
|
||||
}
|
||||
|
||||
const collection = this.Model.getFilteredCollection({
|
||||
filter,
|
||||
mongoTransformer: this.#getNQLKeyTransformer()
|
||||
});
|
||||
const orderString = this.#orderToString(order);
|
||||
|
||||
if ((limit && page) || orderString) {
|
||||
collection
|
||||
.query((q) => {
|
||||
if (limit && page) {
|
||||
q.limit(limit);
|
||||
q.offset(limit * (page - 1));
|
||||
}
|
||||
collection
|
||||
.query((q) => {
|
||||
this.applyCustomQuery(q, options);
|
||||
|
||||
if (orderString) {
|
||||
q.orderByRaw(
|
||||
orderString
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (limit) {
|
||||
q.limit(limit);
|
||||
}
|
||||
if (limit && page) {
|
||||
q.limit(limit);
|
||||
q.offset(limit * (page - 1));
|
||||
}
|
||||
|
||||
if (orderString) {
|
||||
q.orderByRaw(
|
||||
orderString
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const models = await collection.fetchAll();
|
||||
return (await Promise.all(models.map(model => this.modelToEntity(model)))).filter(entity => !!entity) as T[];
|
||||
}
|
||||
|
||||
async getAll({filter, order}: { filter?: string; order?: OrderOption<T> } = {}): Promise<T[]> {
|
||||
async getAll({filter, order, include}: Omit<AllOptions<T>, 'page'|'limit'> = {}): Promise<T[]> {
|
||||
return this.#fetchAll({
|
||||
filter,
|
||||
order
|
||||
order,
|
||||
include
|
||||
});
|
||||
}
|
||||
|
||||
async getPage({filter, order, page, limit}: { filter?: string; order?: OrderOption<T>; page: number; limit: number }): Promise<T[]> {
|
||||
async getPage({filter, order, page, limit, include}: AllOptions<T> & Required<Pick<AllOptions<T>, 'page'|'limit'>>): Promise<T[]> {
|
||||
return this.#fetchAll({
|
||||
filter,
|
||||
order,
|
||||
page,
|
||||
limit
|
||||
limit,
|
||||
include
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1 +1,3 @@
|
||||
declare module '@tryghost/mongo-utils';
|
||||
declare module '@tryghost/errors';
|
||||
declare module '@tryghost/nql';
|
||||
|
@ -1,6 +1,7 @@
|
||||
import assert from 'assert';
|
||||
import {BookshelfRepository, ModelClass, ModelInstance} from '../src/index';
|
||||
import {Knex} from 'knex';
|
||||
import nql from '@tryghost/nql';
|
||||
|
||||
type SimpleEntity = {
|
||||
id: string;
|
||||
@ -87,6 +88,7 @@ class Model implements ModelClass<string> {
|
||||
add(data: object): Promise<ModelInstance<string>> {
|
||||
const item = {
|
||||
id: (data as any).id,
|
||||
...data,
|
||||
get(field: string): unknown {
|
||||
return (data as any)[field];
|
||||
},
|
||||
@ -106,8 +108,18 @@ class Model implements ModelClass<string> {
|
||||
return Promise.resolve(item);
|
||||
}
|
||||
|
||||
getFilteredCollection() {
|
||||
return this;
|
||||
getFilteredCollection({filter, mongoTransformer}: {filter?: string, mongoTransformer?: unknown}) {
|
||||
// Filter all items by filter and mongoTransformer
|
||||
if (!filter) {
|
||||
return this;
|
||||
}
|
||||
const n = nql(filter, {
|
||||
transformer: mongoTransformer
|
||||
});
|
||||
|
||||
const duplicate = new Model();
|
||||
duplicate.items = this.items.filter(item => n.queryJSON(item));
|
||||
return duplicate;
|
||||
}
|
||||
|
||||
count() {
|
||||
@ -343,6 +355,76 @@ describe('BookshelfRepository', function () {
|
||||
assert(result.length === 3);
|
||||
});
|
||||
|
||||
it('Cannot retrieve zero page number', 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 = repository.getPage({
|
||||
order: [],
|
||||
limit: 5,
|
||||
page: 0
|
||||
});
|
||||
|
||||
await assert.rejects(result, /page/);
|
||||
});
|
||||
|
||||
it('Cannot retrieve zero limit', 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 = repository.getPage({
|
||||
order: [],
|
||||
limit: 0,
|
||||
page: 5
|
||||
});
|
||||
|
||||
await assert.rejects(result, /limit/);
|
||||
});
|
||||
|
||||
it('Can retrieve count', async function () {
|
||||
const repository = new SimpleBookshelfRepository(new Model());
|
||||
const entities = [{
|
||||
|
@ -1 +1 @@
|
||||
Subproject commit 276e2c9d0140c902e1c8d3760bc194790722fa71
|
||||
Subproject commit 4d3319d05ce92e7b0244e5608d3fc6cc9c86e735
|
@ -11,7 +11,8 @@ module.exports = {
|
||||
'limit',
|
||||
'page',
|
||||
'include',
|
||||
'filter'
|
||||
'filter',
|
||||
'order'
|
||||
],
|
||||
permissions: true,
|
||||
validation: {},
|
||||
|
@ -1501,6 +1501,116 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Admin API browse Can include click and subscribe counts and order by clicks+subscribe count 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": 3,
|
||||
"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 {
|
||||
"clicks": 2,
|
||||
"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 {
|
||||
"clicks": 0,
|
||||
"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/",
|
||||
},
|
||||
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": "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,
|
||||
"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/",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Admin API browse Can include click and subscribe counts and order by clicks+subscribe count 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": "2103",
|
||||
"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 browse Can include only clicks 1: [body] 1`] = `
|
||||
Object {
|
||||
"meta": Object {
|
||||
@ -1711,6 +1821,116 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Admin API browse Can order by click and subscribe counts and they will be included by default 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": 3,
|
||||
"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 {
|
||||
"clicks": 2,
|
||||
"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 {
|
||||
"clicks": 0,
|
||||
"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/",
|
||||
},
|
||||
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": "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,
|
||||
"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/",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Admin API browse Can order by click and subscribe counts and they will be included by default 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": "2103",
|
||||
"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 browse Can request pages 1: [body] 1`] = `
|
||||
Object {
|
||||
"meta": Object {
|
||||
|
@ -246,6 +246,58 @@ describe('Recommendations Admin API', function () {
|
||||
assert.equal(page1.recommendations[2].count.subscribers, 2);
|
||||
});
|
||||
|
||||
it('Can include click and subscribe counts and order by clicks+subscribe count', async function () {
|
||||
await addDummyRecommendations(5);
|
||||
await addClicksAndSubscribers({memberId});
|
||||
|
||||
const {body: page1} = await agent.get('recommendations/?include=count.clicks,count.subscribers&order=' + encodeURIComponent('count.clicks desc, count.subscribers asc'))
|
||||
.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, 3);
|
||||
assert.equal(page1.recommendations[1].count.clicks, 2);
|
||||
|
||||
assert.equal(page1.recommendations[0].count.subscribers, 0);
|
||||
assert.equal(page1.recommendations[1].count.subscribers, 3);
|
||||
assert.equal(page1.recommendations[2].count.subscribers, 0);
|
||||
});
|
||||
|
||||
it('Can order by click and subscribe counts and they will be included by default', async function () {
|
||||
await addDummyRecommendations(5);
|
||||
await addClicksAndSubscribers({memberId});
|
||||
|
||||
const {body: page1} = await agent.get('recommendations/?order=' + encodeURIComponent('count.clicks desc, count.subscribers asc'))
|
||||
.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, 3);
|
||||
assert.equal(page1.recommendations[1].count.clicks, 2);
|
||||
|
||||
assert.equal(page1.recommendations[0].count.subscribers, 0);
|
||||
assert.equal(page1.recommendations[1].count.subscribers, 3);
|
||||
assert.equal(page1.recommendations[2].count.subscribers, 0);
|
||||
});
|
||||
|
||||
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');
|
||||
|
@ -1,5 +1,219 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Incoming Recommendation Emails Sends a different email if we receive a recommendation back 1: [html 1] 1`] = `
|
||||
"<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name=\\"viewport\\" content=\\"width=device-width\\">
|
||||
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\">
|
||||
<title>👍 New recommendation</title>
|
||||
<style>
|
||||
/* -------------------------------------
|
||||
RESPONSIVE AND MOBILE FRIENDLY STYLES
|
||||
------------------------------------- */
|
||||
@media only screen and (max-width: 620px) {
|
||||
table[class=body] h1 {
|
||||
font-size: 28px !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
table[class=body] p,
|
||||
table[class=body] ul,
|
||||
table[class=body] ol,
|
||||
table[class=body] td,
|
||||
table[class=body] span,
|
||||
table[class=body] a {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
table[class=body] .wrapper,
|
||||
table[class=body] .article {
|
||||
padding: 10px !important;
|
||||
}
|
||||
table[class=body] .content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
table[class=body] .container {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .main {
|
||||
border-left-width: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border-right-width: 0 !important;
|
||||
}
|
||||
table[class=body] .btn table {
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .btn a {
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .img-responsive {
|
||||
height: auto !important;
|
||||
max-width: 100% !important;
|
||||
width: auto !important;
|
||||
}
|
||||
table[class=body] p[class=small],
|
||||
table[class=body] a[class=small] {
|
||||
font-size: 11px !important;
|
||||
}
|
||||
.new-mention-thumbnail {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
/* -------------------------------------
|
||||
PRESERVE THESE STYLES IN THE HEAD
|
||||
------------------------------------- */
|
||||
@media all {
|
||||
.ExternalClass {
|
||||
width: 100%;
|
||||
}
|
||||
.ExternalClass,
|
||||
.ExternalClass p,
|
||||
.ExternalClass span,
|
||||
.ExternalClass font,
|
||||
.ExternalClass td,
|
||||
.ExternalClass div {
|
||||
line-height: 100%;
|
||||
}
|
||||
/* Reset styles for Gmail (it wraps email address in link with custom styles) */
|
||||
.text-link a {
|
||||
color: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
}
|
||||
hr {
|
||||
border-width: 0;
|
||||
height: 0;
|
||||
margin-top: 34px;
|
||||
margin-bottom: 34px;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #EEF5F8;
|
||||
}
|
||||
a {
|
||||
color: #15212A;
|
||||
}
|
||||
blockquote {
|
||||
margin-left: 0;
|
||||
padding-left: 20px;
|
||||
border-left: 3px solid #DDE1E5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style=\\"background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.5em; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;\\">
|
||||
|
||||
<table border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"body\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
|
||||
<tr>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;\\"> </td>
|
||||
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; padding: 10px;\\">
|
||||
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;\\">
|
||||
|
||||
<!-- START CENTERED CONTAINER -->
|
||||
<table class=\\"main\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;\\">
|
||||
|
||||
<!-- START MAIN CONTENT AREA -->
|
||||
<tr>
|
||||
<td class=\\"wrapper\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box;\\">
|
||||
<table border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
|
||||
<tr>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;\\">
|
||||
|
||||
<p style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 20px; color: #15212A; font-weight: bold; line-height: 25px; margin: 0; margin-bottom: 15px;\\">Great news!</p>
|
||||
<p style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 16px;\\">One of the sites you're recommending is now <strong>recommending you back</strong>:</p>
|
||||
|
||||
<figure style=\\"margin:0 0 1.5em;padding:0;width:100%;\\">
|
||||
<a style=\\"display:flex;min-height:110px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;background:#F9F9FA;border-radius:3px;border:1px solid #F9F9FA;color:#15171a;text-decoration:none\\" href=\\"https://www.otherghostsite.com/\\">
|
||||
<div style=\\"display:inline-block; width:100%; padding:20px\\">
|
||||
<div style=\\"display:flex;margin-top:14px;color:#15212a;font-size:13px;font-weight:400\\">
|
||||
|
||||
<div style=\\"color:#15212a;font-size:16px;line-height:1.3em;font-weight:600\\">Other Ghost Site</div>
|
||||
</div>
|
||||
<div class=\\"kg-bookmark-description\\" style=\\"display:-webkit-box;overflow-y:hidden;margin-top:12px;max-height:40px;color:#738a94;font-size:13px;line-height:1.5em;font-weight:400\\"></div>
|
||||
</div>
|
||||
</a>
|
||||
</figure>
|
||||
|
||||
<table border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"btn btn-primary\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;\\">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align=\\"left\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; padding-top: 32px; padding-bottom: 12px;\\">
|
||||
<table border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;\\">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: #15171a; border-radius: 5px; text-align: center;\\"> <a href=\\"http://127.0.0.1:2369/ghost/#/settings-x/recommendations\\" target=\\"_blank\\" style=\\"display: inline-block; color: #ffffff; background-color: #15171a; border: solid 1px #15171a; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: #15171a;\\">View recommendations</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr/>
|
||||
<p style=\\"word-break: break-all; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 5px;\\">You can also copy & paste this URL into your browser:</p>
|
||||
<p class=\\"text-link\\" style=\\"word-break: break-all; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; line-height: 25px; margin-top:0; color: #3A464C;\\">http://127.0.0.1:2369/ghost/#/settings-x/recommendations</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- START FOOTER -->
|
||||
<tr>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; padding-top: 80px;\\">
|
||||
<p class=\\"small\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 18px; font-size: 11px; color: #738A94; font-weight: normal; margin: 0; margin-bottom: 2px;\\">This message was sent from <a class=\\"small\\" href=\\"http://127.0.0.1:2369/\\" style=\\"text-decoration: underline; color: #738A94; font-size: 11px;\\">127.0.0.1</a> to <a class=\\"small\\" href=\\"mailto:jbloggs@example.com\\" style=\\"text-decoration: underline; color: #738A94; font-size: 11px;\\">jbloggs@example.com</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; padding-top: 2px\\">
|
||||
<p class=\\"small\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 18px; font-size: 11px; color: #738A94; font-weight: normal; margin: 0; margin-bottom: 2px;\\">Don’t want to receive these emails? Manage your preferences <a class=\\"small\\" href=\\"http://127.0.0.1:2369/ghost/#/settings-x/users/show/joe-bloggs\\" style=\\"text-decoration: underline; color: #738A94; font-size: 11px;\\">here</a>.</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- END FOOTER -->
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- END MAIN CONTENT AREA -->
|
||||
</table>
|
||||
|
||||
|
||||
<!-- END CENTERED CONTAINER -->
|
||||
</div>
|
||||
</td>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;\\"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Incoming Recommendation Emails Sends a different email if we receive a recommendation back 2: [text 1] 1`] = `
|
||||
"
|
||||
You have been recommended by Other Ghost Site.
|
||||
|
||||
---
|
||||
|
||||
Sent to jbloggs@example.com from 127.0.0.1.
|
||||
If you would no longer like to receive these notifications you can adjust your settings at http://127.0.0.1:2369/ghost/#/settings-x/users/show/joe-bloggs.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Incoming Recommendation Emails Sends a different email if we receive a recommendation back 3: [metadata 1] 1`] = `
|
||||
Object {
|
||||
"subject": "👍 New recommendation: Other Ghost Site",
|
||||
"to": "jbloggs@example.com",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Incoming Recommendation Emails Sends an email if we receive a recommendation 1: [html 1] 1`] = `
|
||||
"<!doctype html>
|
||||
<html>
|
||||
|
@ -1,7 +1,8 @@
|
||||
import {AllOptions, BookshelfRepository, ModelClass, ModelInstance} from '@tryghost/bookshelf-repository';
|
||||
import logger from '@tryghost/logging';
|
||||
import {Knex} from 'knex';
|
||||
import {Recommendation} from './Recommendation';
|
||||
import {RecommendationRepository} from './RecommendationRepository';
|
||||
import {BookshelfRepository, ModelClass, ModelInstance} from '@tryghost/bookshelf-repository';
|
||||
import logger from '@tryghost/logging';
|
||||
|
||||
type Sentry = {
|
||||
captureException(err: unknown): void;
|
||||
@ -24,6 +25,22 @@ export class BookshelfRecommendationRepository extends BookshelfRepository<strin
|
||||
this.sentry = deps.sentry;
|
||||
}
|
||||
|
||||
applyCustomQuery(query: Knex.QueryBuilder, options: AllOptions<Recommendation>) {
|
||||
query.select('recommendations.*');
|
||||
|
||||
if (options.include?.includes('clickCount') || options.order?.find(o => o.field === 'clickCount')) {
|
||||
query.select((knex: Knex.QueryBuilder) => {
|
||||
knex.count('*').from('recommendation_click_events').where('recommendation_click_events.recommendation_id', knex.client.raw('recommendations.id')).as('count__clicks');
|
||||
});
|
||||
}
|
||||
|
||||
if (options.include?.includes('subscriberCount') || options.order?.find(o => o.field === 'subscriberCount')) {
|
||||
query.select((knex: Knex.QueryBuilder) => {
|
||||
knex.count('*').from('recommendation_subscribe_events').where('recommendation_subscribe_events.recommendation_id', knex.client.raw('recommendations.id')).as('count__subscribers');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toPrimitive(entity: Recommendation): object {
|
||||
return {
|
||||
id: entity.id,
|
||||
@ -36,6 +53,7 @@ export class BookshelfRecommendationRepository extends BookshelfRepository<strin
|
||||
one_click_subscribe: entity.oneClickSubscribe,
|
||||
created_at: entity.createdAt,
|
||||
updated_at: entity.updatedAt
|
||||
// Count relations are not saveable: so don't set them here
|
||||
};
|
||||
}
|
||||
|
||||
@ -51,7 +69,9 @@ export class BookshelfRecommendationRepository extends BookshelfRepository<strin
|
||||
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
|
||||
updatedAt: model.get('updated_at') as Date | null,
|
||||
clickCount: (model.get('count__clicks') ?? undefined) as number | undefined,
|
||||
subscriberCount: (model.get('count__subscribers') ?? undefined) as number | undefined
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
@ -71,7 +91,9 @@ export class BookshelfRecommendationRepository extends BookshelfRepository<strin
|
||||
url: 'url',
|
||||
oneClickSubscribe: 'one_click_subscribe',
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
updatedAt: 'updated_at',
|
||||
clickCount: 'count__clicks',
|
||||
subscriberCount: 'count__subscribers'
|
||||
} as Record<keyof Recommendation, string>;
|
||||
}
|
||||
|
||||
|
@ -15,7 +15,13 @@ export type RecommendationPlain = {
|
||||
url: URL
|
||||
oneClickSubscribe: boolean,
|
||||
createdAt: Date,
|
||||
updatedAt: Date|null
|
||||
updatedAt: Date|null,
|
||||
|
||||
/**
|
||||
* These are read only, you cannot change them
|
||||
*/
|
||||
clickCount?: number
|
||||
subscriberCount?: number
|
||||
}
|
||||
|
||||
export type RecommendationCreateData = {
|
||||
@ -28,7 +34,13 @@ export type RecommendationCreateData = {
|
||||
url: URL|string
|
||||
oneClickSubscribe: boolean
|
||||
createdAt?: Date
|
||||
updatedAt?: Date|null
|
||||
updatedAt?: Date|null,
|
||||
|
||||
/**
|
||||
* These are read only, you cannot change them
|
||||
*/
|
||||
clickCount?: number
|
||||
subscriberCount?: number
|
||||
}
|
||||
|
||||
export type AddRecommendation = Omit<RecommendationCreateData, 'id'|'createdAt'|'updatedAt'>
|
||||
@ -45,6 +57,8 @@ export class Recommendation {
|
||||
oneClickSubscribe: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date|null;
|
||||
#clickCount: number|undefined;
|
||||
#subscriberCount: number|undefined;
|
||||
|
||||
#deleted: boolean;
|
||||
|
||||
@ -52,6 +66,14 @@ export class Recommendation {
|
||||
return this.#deleted;
|
||||
}
|
||||
|
||||
get clickCount() {
|
||||
return this.#clickCount;
|
||||
}
|
||||
|
||||
get subscriberCount() {
|
||||
return this.#subscriberCount;
|
||||
}
|
||||
|
||||
private constructor(data: RecommendationPlain) {
|
||||
this.id = data.id;
|
||||
this.title = data.title;
|
||||
@ -63,6 +85,8 @@ export class Recommendation {
|
||||
this.oneClickSubscribe = data.oneClickSubscribe;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
this.#clickCount = data.clickCount;
|
||||
this.#subscriberCount = data.subscriberCount;
|
||||
this.#deleted = false;
|
||||
}
|
||||
|
||||
@ -122,7 +146,9 @@ export class Recommendation {
|
||||
url: new UnsafeData(data.url).url,
|
||||
oneClickSubscribe: data.oneClickSubscribe,
|
||||
createdAt: data.createdAt ?? new Date(),
|
||||
updatedAt: data.updatedAt ?? null
|
||||
updatedAt: data.updatedAt ?? null,
|
||||
clickCount: data.clickCount,
|
||||
subscriberCount: data.subscriberCount
|
||||
};
|
||||
|
||||
this.validate(d);
|
||||
@ -143,7 +169,9 @@ export class Recommendation {
|
||||
url: this.url,
|
||||
oneClickSubscribe: this.oneClickSubscribe,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt
|
||||
updatedAt: this.updatedAt,
|
||||
clickCount: this.clickCount,
|
||||
subscriberCount: this.subscriberCount
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import errors from '@tryghost/errors';
|
||||
import {AddRecommendation, RecommendationPlain} from './Recommendation';
|
||||
import {RecommendationIncludeFields, RecommendationService, RecommendationWithIncludes} from './RecommendationService';
|
||||
import {AddRecommendation, Recommendation, RecommendationPlain} from './Recommendation';
|
||||
import {RecommendationService} from './RecommendationService';
|
||||
import {UnsafeData} from './UnsafeData';
|
||||
import {OrderOption} from '@tryghost/bookshelf-repository';
|
||||
|
||||
type Frame = {
|
||||
data: unknown,
|
||||
@ -11,6 +12,17 @@ type Frame = {
|
||||
member: unknown,
|
||||
};
|
||||
|
||||
const RecommendationIncludesMap = {
|
||||
'count.clicks': 'clickCount' as const,
|
||||
'count.subscribers': 'subscriberCount' as const
|
||||
};
|
||||
|
||||
const RecommendationOrderMap = {
|
||||
'count.clicks': 'clickCount' as const,
|
||||
'count.subscribers': 'subscriberCount' as const,
|
||||
created_at: 'createdAt' as const
|
||||
};
|
||||
|
||||
export class RecommendationController {
|
||||
service: RecommendationService;
|
||||
|
||||
@ -76,23 +88,63 @@ export class RecommendationController {
|
||||
await this.service.deleteRecommendation(id);
|
||||
}
|
||||
|
||||
#stringToOrder(str?: string) {
|
||||
if (!str) {
|
||||
// Default order
|
||||
return [
|
||||
{
|
||||
field: 'createdAt' as const,
|
||||
direction: 'desc' as const
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const parts = str.split(',');
|
||||
const order: OrderOption<Recommendation> = [];
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
const fieldData = new UnsafeData(trimmed.split(' ')[0].trim());
|
||||
const directionData = new UnsafeData(trimmed.split(' ')[1]?.trim() ?? 'asc');
|
||||
|
||||
const validatedField = fieldData.enum(
|
||||
Object.keys(RecommendationOrderMap) as (keyof typeof RecommendationOrderMap)[]
|
||||
);
|
||||
const direction = directionData.enum(['asc' as const, 'desc' as const]);
|
||||
|
||||
// Convert 'count.' and camelCase to snake_case
|
||||
const field = RecommendationOrderMap[validatedField];
|
||||
order.push({
|
||||
field,
|
||||
direction
|
||||
});
|
||||
}
|
||||
|
||||
if (order.length === 0) {
|
||||
// Default order
|
||||
return [
|
||||
{
|
||||
field: 'createdAt' as const,
|
||||
direction: 'desc' as const
|
||||
}
|
||||
];
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
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 include = options.optionalKey('withRelated')?.array.map((item) => {
|
||||
return RecommendationIncludesMap[item.enum(
|
||||
Object.keys(RecommendationIncludesMap) as (keyof typeof RecommendationIncludesMap)[]
|
||||
)];
|
||||
}) ?? [];
|
||||
const filter = options.optionalKey('filter')?.string;
|
||||
|
||||
const orderOption = options.optionalKey('order')?.regex(/^[a-zA-Z]+ (asc|desc)$/) ?? 'createdAt desc';
|
||||
const field = orderOption?.split(' ')[0] as keyof RecommendationPlain;
|
||||
const direction = orderOption?.split(' ')[1] as 'asc'|'desc';
|
||||
const order = [
|
||||
{
|
||||
field,
|
||||
direction
|
||||
}
|
||||
];
|
||||
const orderOption = options.optionalKey('order')?.string;
|
||||
const order = this.#stringToOrder(orderOption);
|
||||
|
||||
const count = await this.service.countRecommendations({});
|
||||
const recommendations = (await this.service.listRecommendations({page, limit, filter, include, order}));
|
||||
@ -154,7 +206,7 @@ export class RecommendationController {
|
||||
return null;
|
||||
}
|
||||
|
||||
#serialize(recommendations: RecommendationWithIncludes[], meta?: any) {
|
||||
#serialize(recommendations: RecommendationPlain[], meta?: any) {
|
||||
return {
|
||||
data: recommendations.map((entity) => {
|
||||
const d = {
|
||||
@ -168,33 +220,12 @@ export class RecommendationController {
|
||||
one_click_subscribe: entity.oneClickSubscribe,
|
||||
created_at: entity.createdAt,
|
||||
updated_at: entity.updatedAt,
|
||||
count: undefined as undefined|{clicks?: number, subscribers?: number}
|
||||
count: entity.clickCount !== undefined || entity.subscriberCount !== undefined ? {
|
||||
clicks: entity.clickCount,
|
||||
subscribers: entity.subscriberCount
|
||||
} : undefined
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(entity)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return d;
|
||||
}),
|
||||
meta
|
||||
|
@ -1,18 +1,13 @@
|
||||
import {OrderOption} from '@tryghost/bookshelf-repository';
|
||||
import {AllOptions} from '@tryghost/bookshelf-repository';
|
||||
import {Recommendation} from './Recommendation';
|
||||
|
||||
export interface RecommendationRepository {
|
||||
save(entity: Recommendation): Promise<void>;
|
||||
getById(id: string): Promise<Recommendation | null>;
|
||||
getByUrl(url: URL): Promise<Recommendation | null>;
|
||||
getAll({filter, order}?: {filter?: string, order?: OrderOption<Recommendation>}): Promise<Recommendation[]>;
|
||||
getPage({filter, order, page, limit}: {
|
||||
filter?: string;
|
||||
order?: OrderOption<Recommendation>;
|
||||
page: number;
|
||||
limit: number;
|
||||
}): Promise<Recommendation[]>;
|
||||
getCount({filter}?: {
|
||||
getByUrl(url: URL): Promise<Recommendation|null>;
|
||||
getAll(options: Omit<AllOptions<Recommendation>, 'page'|'limit'>): Promise<Recommendation[]>;
|
||||
getPage(options: AllOptions<Recommendation> & Required<Pick<AllOptions<Recommendation>, 'page'|'limit'>>): Promise<Recommendation[]>;
|
||||
getCount(options: {
|
||||
filter?: string;
|
||||
}): Promise<number>;
|
||||
};
|
||||
|
@ -1,27 +1,12 @@
|
||||
import {BookshelfRepository, OrderOption} from '@tryghost/bookshelf-repository';
|
||||
import {AddRecommendation, Recommendation, RecommendationPlain} from './Recommendation';
|
||||
import {RecommendationRepository} from './RecommendationRepository';
|
||||
import {WellknownService} from './WellknownService';
|
||||
import {BookshelfRepository, IncludeOption, OrderOption} from '@tryghost/bookshelf-repository';
|
||||
import errors from '@tryghost/errors';
|
||||
import logging from '@tryghost/logging';
|
||||
import tpl from '@tryghost/tpl';
|
||||
import {ClickEvent} from './ClickEvent';
|
||||
import {AddRecommendation, Recommendation, RecommendationPlain} from './Recommendation';
|
||||
import {RecommendationRepository} from './RecommendationRepository';
|
||||
import {SubscribeEvent} from './SubscribeEvent';
|
||||
import logging from '@tryghost/logging';
|
||||
|
||||
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]>;
|
||||
import {WellknownService} from './WellknownService';
|
||||
|
||||
type MentionSendingService = {
|
||||
sendAll(options: {url: URL, links: URL[]}): Promise<void>
|
||||
@ -164,85 +149,25 @@ 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'}): 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});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as #listRecommendations, but with includes and returns a plain object for external use
|
||||
* Sames as listRecommendations, but returns Entities instead of plain objects (Entities are only used internally)
|
||||
*/
|
||||
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 #listRecommendations(options: { filter?: string; order?: OrderOption<Recommendation>; page?: number; limit?: number|'all', include?: IncludeOption<Recommendation> } = {page: 1, limit: 'all'}): Promise<Recommendation[]> {
|
||||
if (options.limit === 'all') {
|
||||
return await this.repository.getAll({
|
||||
...options
|
||||
});
|
||||
}
|
||||
return await this.repository.getPage({
|
||||
...options,
|
||||
page: options.page || 1,
|
||||
limit: options.limit || 15
|
||||
});
|
||||
}
|
||||
|
||||
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 plainList as RecommendationWithIncludes<IncludeFields>[];
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
// Avoid doing queries with broken filters
|
||||
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.id).join(',')}]`});
|
||||
|
||||
// Set all to zero by default
|
||||
for (const entity of plainList) {
|
||||
entity[relation] = 0;
|
||||
}
|
||||
|
||||
for (const r of clickCounts) {
|
||||
const entity = plainList.find(e => e.id === r.recommendationId);
|
||||
if (entity) {
|
||||
entity[relation] = r.count;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'count.subscribers':
|
||||
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 plainList) {
|
||||
entity[relation] = 0;
|
||||
}
|
||||
|
||||
for (const r of subscribersCounts) {
|
||||
const entity = plainList.find(e => e.id === r.recommendationId);
|
||||
if (entity) {
|
||||
entity[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 plainList as RecommendationWithIncludes<IncludeFields>[];
|
||||
async listRecommendations(options: { filter?: string; order?: OrderOption<Recommendation>; page?: number; limit?: number|'all', include?: IncludeOption<Recommendation> } = {page: 1, limit: 'all', include: []}): Promise<RecommendationPlain[]> {
|
||||
const list = await this.#listRecommendations(options);
|
||||
return list.map(e => e.plain);
|
||||
}
|
||||
|
||||
async countRecommendations({filter}: { filter?: string }) {
|
||||
|
Loading…
Reference in New Issue
Block a user