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": {
|
"devDependencies": {
|
||||||
"c8": "7.14.0",
|
"c8": "7.14.0",
|
||||||
"mocha": "10.2.0",
|
"mocha": "10.2.0",
|
||||||
"sinon": "15.2.0"
|
"sinon": "15.2.0",
|
||||||
|
"@tryghost/nql": "0.11.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tryghost/mongo-utils": "0.5.0",
|
"@tryghost/mongo-utils": "0.5.0",
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {Knex} from 'knex';
|
import {Knex} from 'knex';
|
||||||
import {mapKeys, chainTransformers} from '@tryghost/mongo-utils';
|
import {mapKeys, chainTransformers} from '@tryghost/mongo-utils';
|
||||||
|
import errors from '@tryghost/errors';
|
||||||
|
|
||||||
type Entity<T> = {
|
type Entity<T> = {
|
||||||
id: T;
|
id: T;
|
||||||
@ -29,8 +30,18 @@ export type ModelInstance<T> = {
|
|||||||
save(properties: object, options?: {autoRefresh?: boolean; method?: 'update' | 'insert'}): Promise<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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type OrderOption<T extends Entity<any> = any> = Order<T>[];
|
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>> {
|
export abstract class BookshelfRepository<IDType, T extends Entity<IDType>> {
|
||||||
protected Model: ModelClass<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 modelToEntity (model: ModelInstance<IDType>): Promise<T|null> | T | null
|
||||||
protected abstract getFieldToColumnMap(): Record<keyof T, string>;
|
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 {
|
#entityFieldToColumn(field: keyof T): string {
|
||||||
const mapping = this.getFieldToColumnMap();
|
const mapping = this.getFieldToColumnMap();
|
||||||
return mapping[field];
|
return mapping[field];
|
||||||
@ -78,50 +97,71 @@ export abstract class BookshelfRepository<IDType, T extends Entity<IDType>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getById(id: IDType): Promise<T | null> {
|
async getById(id: IDType): Promise<T | null> {
|
||||||
const model = await this.Model.findOne({id}, {require: false}) as ModelInstance<IDType> | null;
|
const models = await this.#fetchAll({
|
||||||
return model ? this.modelToEntity(model) : null;
|
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({
|
const collection = this.Model.getFilteredCollection({
|
||||||
filter,
|
filter,
|
||||||
mongoTransformer: this.#getNQLKeyTransformer()
|
mongoTransformer: this.#getNQLKeyTransformer()
|
||||||
});
|
});
|
||||||
const orderString = this.#orderToString(order);
|
const orderString = this.#orderToString(order);
|
||||||
|
|
||||||
if ((limit && page) || orderString) {
|
collection
|
||||||
collection
|
.query((q) => {
|
||||||
.query((q) => {
|
this.applyCustomQuery(q, options);
|
||||||
if (limit && page) {
|
|
||||||
q.limit(limit);
|
|
||||||
q.offset(limit * (page - 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (orderString) {
|
if (limit) {
|
||||||
q.orderByRaw(
|
q.limit(limit);
|
||||||
orderString
|
}
|
||||||
);
|
if (limit && page) {
|
||||||
}
|
q.limit(limit);
|
||||||
});
|
q.offset(limit * (page - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (orderString) {
|
||||||
|
q.orderByRaw(
|
||||||
|
orderString
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const models = await collection.fetchAll();
|
const models = await collection.fetchAll();
|
||||||
return (await Promise.all(models.map(model => this.modelToEntity(model)))).filter(entity => !!entity) as T[];
|
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({
|
return this.#fetchAll({
|
||||||
filter,
|
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({
|
return this.#fetchAll({
|
||||||
filter,
|
filter,
|
||||||
order,
|
order,
|
||||||
page,
|
page,
|
||||||
limit
|
limit,
|
||||||
|
include
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1 +1,3 @@
|
|||||||
declare module '@tryghost/mongo-utils';
|
declare module '@tryghost/mongo-utils';
|
||||||
|
declare module '@tryghost/errors';
|
||||||
|
declare module '@tryghost/nql';
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
import {BookshelfRepository, ModelClass, ModelInstance} from '../src/index';
|
import {BookshelfRepository, ModelClass, ModelInstance} from '../src/index';
|
||||||
import {Knex} from 'knex';
|
import {Knex} from 'knex';
|
||||||
|
import nql from '@tryghost/nql';
|
||||||
|
|
||||||
type SimpleEntity = {
|
type SimpleEntity = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -87,6 +88,7 @@ class Model implements ModelClass<string> {
|
|||||||
add(data: object): Promise<ModelInstance<string>> {
|
add(data: object): Promise<ModelInstance<string>> {
|
||||||
const item = {
|
const item = {
|
||||||
id: (data as any).id,
|
id: (data as any).id,
|
||||||
|
...data,
|
||||||
get(field: string): unknown {
|
get(field: string): unknown {
|
||||||
return (data as any)[field];
|
return (data as any)[field];
|
||||||
},
|
},
|
||||||
@ -106,8 +108,18 @@ class Model implements ModelClass<string> {
|
|||||||
return Promise.resolve(item);
|
return Promise.resolve(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
getFilteredCollection() {
|
getFilteredCollection({filter, mongoTransformer}: {filter?: string, mongoTransformer?: unknown}) {
|
||||||
return this;
|
// 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() {
|
count() {
|
||||||
@ -343,6 +355,76 @@ describe('BookshelfRepository', function () {
|
|||||||
assert(result.length === 3);
|
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 () {
|
it('Can retrieve count', async function () {
|
||||||
const repository = new SimpleBookshelfRepository(new Model());
|
const repository = new SimpleBookshelfRepository(new Model());
|
||||||
const entities = [{
|
const entities = [{
|
||||||
|
@ -1 +1 @@
|
|||||||
Subproject commit 276e2c9d0140c902e1c8d3760bc194790722fa71
|
Subproject commit 4d3319d05ce92e7b0244e5608d3fc6cc9c86e735
|
@ -11,7 +11,8 @@ module.exports = {
|
|||||||
'limit',
|
'limit',
|
||||||
'page',
|
'page',
|
||||||
'include',
|
'include',
|
||||||
'filter'
|
'filter',
|
||||||
|
'order'
|
||||||
],
|
],
|
||||||
permissions: true,
|
permissions: true,
|
||||||
validation: {},
|
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`] = `
|
exports[`Recommendations Admin API browse Can include only clicks 1: [body] 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"meta": 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`] = `
|
exports[`Recommendations Admin API browse Can request pages 1: [body] 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"meta": Object {
|
"meta": Object {
|
||||||
|
@ -246,6 +246,58 @@ describe('Recommendations Admin API', function () {
|
|||||||
assert.equal(page1.recommendations[2].count.subscribers, 2);
|
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 () {
|
it('Can fetch recommendations with relations when there are no recommendations', async function () {
|
||||||
const recommendations = await recommendationsService.repository.getCount();
|
const recommendations = await recommendationsService.repository.getCount();
|
||||||
assert.equal(recommendations, 0, 'This test expects there to be no recommendations');
|
assert.equal(recommendations, 0, 'This test expects there to be no recommendations');
|
||||||
|
@ -1,5 +1,219 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// 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`] = `
|
exports[`Incoming Recommendation Emails Sends an email if we receive a recommendation 1: [html 1] 1`] = `
|
||||||
"<!doctype html>
|
"<!doctype html>
|
||||||
<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 {Recommendation} from './Recommendation';
|
||||||
import {RecommendationRepository} from './RecommendationRepository';
|
import {RecommendationRepository} from './RecommendationRepository';
|
||||||
import {BookshelfRepository, ModelClass, ModelInstance} from '@tryghost/bookshelf-repository';
|
|
||||||
import logger from '@tryghost/logging';
|
|
||||||
|
|
||||||
type Sentry = {
|
type Sentry = {
|
||||||
captureException(err: unknown): void;
|
captureException(err: unknown): void;
|
||||||
@ -24,6 +25,22 @@ export class BookshelfRecommendationRepository extends BookshelfRepository<strin
|
|||||||
this.sentry = deps.sentry;
|
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 {
|
toPrimitive(entity: Recommendation): object {
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
@ -36,6 +53,7 @@ export class BookshelfRecommendationRepository extends BookshelfRepository<strin
|
|||||||
one_click_subscribe: entity.oneClickSubscribe,
|
one_click_subscribe: entity.oneClickSubscribe,
|
||||||
created_at: entity.createdAt,
|
created_at: entity.createdAt,
|
||||||
updated_at: entity.updatedAt
|
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,
|
url: model.get('url') as string,
|
||||||
oneClickSubscribe: model.get('one_click_subscribe') as boolean,
|
oneClickSubscribe: model.get('one_click_subscribe') as boolean,
|
||||||
createdAt: model.get('created_at') as Date,
|
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) {
|
} catch (err) {
|
||||||
logger.error(err);
|
logger.error(err);
|
||||||
@ -71,7 +91,9 @@ export class BookshelfRecommendationRepository extends BookshelfRepository<strin
|
|||||||
url: 'url',
|
url: 'url',
|
||||||
oneClickSubscribe: 'one_click_subscribe',
|
oneClickSubscribe: 'one_click_subscribe',
|
||||||
createdAt: 'created_at',
|
createdAt: 'created_at',
|
||||||
updatedAt: 'updated_at'
|
updatedAt: 'updated_at',
|
||||||
|
clickCount: 'count__clicks',
|
||||||
|
subscriberCount: 'count__subscribers'
|
||||||
} as Record<keyof Recommendation, string>;
|
} as Record<keyof Recommendation, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,7 +15,13 @@ export type RecommendationPlain = {
|
|||||||
url: URL
|
url: URL
|
||||||
oneClickSubscribe: boolean,
|
oneClickSubscribe: boolean,
|
||||||
createdAt: Date,
|
createdAt: Date,
|
||||||
updatedAt: Date|null
|
updatedAt: Date|null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These are read only, you cannot change them
|
||||||
|
*/
|
||||||
|
clickCount?: number
|
||||||
|
subscriberCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RecommendationCreateData = {
|
export type RecommendationCreateData = {
|
||||||
@ -28,7 +34,13 @@ export type RecommendationCreateData = {
|
|||||||
url: URL|string
|
url: URL|string
|
||||||
oneClickSubscribe: boolean
|
oneClickSubscribe: boolean
|
||||||
createdAt?: Date
|
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'>
|
export type AddRecommendation = Omit<RecommendationCreateData, 'id'|'createdAt'|'updatedAt'>
|
||||||
@ -45,6 +57,8 @@ export class Recommendation {
|
|||||||
oneClickSubscribe: boolean;
|
oneClickSubscribe: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date|null;
|
updatedAt: Date|null;
|
||||||
|
#clickCount: number|undefined;
|
||||||
|
#subscriberCount: number|undefined;
|
||||||
|
|
||||||
#deleted: boolean;
|
#deleted: boolean;
|
||||||
|
|
||||||
@ -52,6 +66,14 @@ export class Recommendation {
|
|||||||
return this.#deleted;
|
return this.#deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get clickCount() {
|
||||||
|
return this.#clickCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get subscriberCount() {
|
||||||
|
return this.#subscriberCount;
|
||||||
|
}
|
||||||
|
|
||||||
private constructor(data: RecommendationPlain) {
|
private constructor(data: RecommendationPlain) {
|
||||||
this.id = data.id;
|
this.id = data.id;
|
||||||
this.title = data.title;
|
this.title = data.title;
|
||||||
@ -63,6 +85,8 @@ export class Recommendation {
|
|||||||
this.oneClickSubscribe = data.oneClickSubscribe;
|
this.oneClickSubscribe = data.oneClickSubscribe;
|
||||||
this.createdAt = data.createdAt;
|
this.createdAt = data.createdAt;
|
||||||
this.updatedAt = data.updatedAt;
|
this.updatedAt = data.updatedAt;
|
||||||
|
this.#clickCount = data.clickCount;
|
||||||
|
this.#subscriberCount = data.subscriberCount;
|
||||||
this.#deleted = false;
|
this.#deleted = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,7 +146,9 @@ export class Recommendation {
|
|||||||
url: new UnsafeData(data.url).url,
|
url: new UnsafeData(data.url).url,
|
||||||
oneClickSubscribe: data.oneClickSubscribe,
|
oneClickSubscribe: data.oneClickSubscribe,
|
||||||
createdAt: data.createdAt ?? new Date(),
|
createdAt: data.createdAt ?? new Date(),
|
||||||
updatedAt: data.updatedAt ?? null
|
updatedAt: data.updatedAt ?? null,
|
||||||
|
clickCount: data.clickCount,
|
||||||
|
subscriberCount: data.subscriberCount
|
||||||
};
|
};
|
||||||
|
|
||||||
this.validate(d);
|
this.validate(d);
|
||||||
@ -143,7 +169,9 @@ export class Recommendation {
|
|||||||
url: this.url,
|
url: this.url,
|
||||||
oneClickSubscribe: this.oneClickSubscribe,
|
oneClickSubscribe: this.oneClickSubscribe,
|
||||||
createdAt: this.createdAt,
|
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 */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import errors from '@tryghost/errors';
|
import errors from '@tryghost/errors';
|
||||||
import {AddRecommendation, RecommendationPlain} from './Recommendation';
|
import {AddRecommendation, Recommendation, RecommendationPlain} from './Recommendation';
|
||||||
import {RecommendationIncludeFields, RecommendationService, RecommendationWithIncludes} from './RecommendationService';
|
import {RecommendationService} from './RecommendationService';
|
||||||
import {UnsafeData} from './UnsafeData';
|
import {UnsafeData} from './UnsafeData';
|
||||||
|
import {OrderOption} from '@tryghost/bookshelf-repository';
|
||||||
|
|
||||||
type Frame = {
|
type Frame = {
|
||||||
data: unknown,
|
data: unknown,
|
||||||
@ -11,6 +12,17 @@ type Frame = {
|
|||||||
member: unknown,
|
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 {
|
export class RecommendationController {
|
||||||
service: RecommendationService;
|
service: RecommendationService;
|
||||||
|
|
||||||
@ -76,23 +88,63 @@ export class RecommendationController {
|
|||||||
await this.service.deleteRecommendation(id);
|
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) {
|
async browse(frame: Frame) {
|
||||||
const options = new UnsafeData(frame.options);
|
const options = new UnsafeData(frame.options);
|
||||||
|
|
||||||
const page = options.optionalKey('page')?.integer ?? 1;
|
const page = options.optionalKey('page')?.integer ?? 1;
|
||||||
const limit = options.optionalKey('limit')?.integer ?? 5;
|
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 filter = options.optionalKey('filter')?.string;
|
||||||
|
|
||||||
const orderOption = options.optionalKey('order')?.regex(/^[a-zA-Z]+ (asc|desc)$/) ?? 'createdAt desc';
|
const orderOption = options.optionalKey('order')?.string;
|
||||||
const field = orderOption?.split(' ')[0] as keyof RecommendationPlain;
|
const order = this.#stringToOrder(orderOption);
|
||||||
const direction = orderOption?.split(' ')[1] as 'asc'|'desc';
|
|
||||||
const order = [
|
|
||||||
{
|
|
||||||
field,
|
|
||||||
direction
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const count = await this.service.countRecommendations({});
|
const count = await this.service.countRecommendations({});
|
||||||
const recommendations = (await this.service.listRecommendations({page, limit, filter, include, order}));
|
const recommendations = (await this.service.listRecommendations({page, limit, filter, include, order}));
|
||||||
@ -154,7 +206,7 @@ export class RecommendationController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
#serialize(recommendations: RecommendationWithIncludes[], meta?: any) {
|
#serialize(recommendations: RecommendationPlain[], meta?: any) {
|
||||||
return {
|
return {
|
||||||
data: recommendations.map((entity) => {
|
data: recommendations.map((entity) => {
|
||||||
const d = {
|
const d = {
|
||||||
@ -168,33 +220,12 @@ export class RecommendationController {
|
|||||||
one_click_subscribe: entity.oneClickSubscribe,
|
one_click_subscribe: entity.oneClickSubscribe,
|
||||||
created_at: entity.createdAt,
|
created_at: entity.createdAt,
|
||||||
updated_at: entity.updatedAt,
|
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;
|
return d;
|
||||||
}),
|
}),
|
||||||
meta
|
meta
|
||||||
|
@ -1,18 +1,13 @@
|
|||||||
import {OrderOption} from '@tryghost/bookshelf-repository';
|
import {AllOptions} from '@tryghost/bookshelf-repository';
|
||||||
import {Recommendation} from './Recommendation';
|
import {Recommendation} from './Recommendation';
|
||||||
|
|
||||||
export interface RecommendationRepository {
|
export interface RecommendationRepository {
|
||||||
save(entity: Recommendation): Promise<void>;
|
save(entity: Recommendation): Promise<void>;
|
||||||
getById(id: string): Promise<Recommendation | null>;
|
getById(id: string): Promise<Recommendation | null>;
|
||||||
getByUrl(url: URL): Promise<Recommendation | null>;
|
getByUrl(url: URL): Promise<Recommendation|null>;
|
||||||
getAll({filter, order}?: {filter?: string, order?: OrderOption<Recommendation>}): Promise<Recommendation[]>;
|
getAll(options: Omit<AllOptions<Recommendation>, 'page'|'limit'>): Promise<Recommendation[]>;
|
||||||
getPage({filter, order, page, limit}: {
|
getPage(options: AllOptions<Recommendation> & Required<Pick<AllOptions<Recommendation>, 'page'|'limit'>>): Promise<Recommendation[]>;
|
||||||
filter?: string;
|
getCount(options: {
|
||||||
order?: OrderOption<Recommendation>;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
}): Promise<Recommendation[]>;
|
|
||||||
getCount({filter}?: {
|
|
||||||
filter?: string;
|
filter?: string;
|
||||||
}): Promise<number>;
|
}): Promise<number>;
|
||||||
};
|
};
|
||||||
|
@ -1,27 +1,12 @@
|
|||||||
import {BookshelfRepository, OrderOption} from '@tryghost/bookshelf-repository';
|
import {BookshelfRepository, IncludeOption, OrderOption} from '@tryghost/bookshelf-repository';
|
||||||
import {AddRecommendation, Recommendation, RecommendationPlain} from './Recommendation';
|
|
||||||
import {RecommendationRepository} from './RecommendationRepository';
|
|
||||||
import {WellknownService} from './WellknownService';
|
|
||||||
import errors from '@tryghost/errors';
|
import errors from '@tryghost/errors';
|
||||||
|
import logging from '@tryghost/logging';
|
||||||
import tpl from '@tryghost/tpl';
|
import tpl from '@tryghost/tpl';
|
||||||
import {ClickEvent} from './ClickEvent';
|
import {ClickEvent} from './ClickEvent';
|
||||||
|
import {AddRecommendation, Recommendation, RecommendationPlain} from './Recommendation';
|
||||||
|
import {RecommendationRepository} from './RecommendationRepository';
|
||||||
import {SubscribeEvent} from './SubscribeEvent';
|
import {SubscribeEvent} from './SubscribeEvent';
|
||||||
import logging from '@tryghost/logging';
|
import {WellknownService} from './WellknownService';
|
||||||
|
|
||||||
export type RecommendationIncludeTypes = {
|
|
||||||
'count.clicks': number,
|
|
||||||
'count.subscribers': number
|
|
||||||
};
|
|
||||||
export type RecommendationIncludeFields = keyof RecommendationIncludeTypes;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All includes are optional, but if they are explicitly loaded, they will not be optional in the result.
|
|
||||||
*
|
|
||||||
* E.g. RecommendationWithIncludes['count.clicks'|'count.subscribers'].
|
|
||||||
*
|
|
||||||
* When using methods like listRecommendations with the include option, the result will automatically return the correct relations
|
|
||||||
*/
|
|
||||||
export type RecommendationWithIncludes<IncludeFields extends RecommendationIncludeFields = never> = RecommendationPlain & Partial<RecommendationIncludeTypes> & Record<IncludeFields, RecommendationIncludeTypes[IncludeFields]>;
|
|
||||||
|
|
||||||
type MentionSendingService = {
|
type MentionSendingService = {
|
||||||
sendAll(options: {url: URL, links: URL[]}): Promise<void>
|
sendAll(options: {url: URL, links: URL[]}): Promise<void>
|
||||||
@ -164,85 +149,25 @@ export class RecommendationService {
|
|||||||
this.sendMentionToRecommendation(existing);
|
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>[]> {
|
async #listRecommendations(options: { filter?: string; order?: OrderOption<Recommendation>; page?: number; limit?: number|'all', include?: IncludeOption<Recommendation> } = {page: 1, limit: 'all'}): Promise<Recommendation[]> {
|
||||||
const list = await this.#listRecommendations({page, limit, filter, order});
|
if (options.limit === 'all') {
|
||||||
return await this.loadRelations(list, include);
|
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>[]> {
|
async listRecommendations(options: { filter?: string; order?: OrderOption<Recommendation>; page?: number; limit?: number|'all', include?: IncludeOption<Recommendation> } = {page: 1, limit: 'all', include: []}): Promise<RecommendationPlain[]> {
|
||||||
const plainList: RecommendationWithIncludes[] = list.map(e => e.plain);
|
const list = await this.#listRecommendations(options);
|
||||||
|
return 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 countRecommendations({filter}: { filter?: string }) {
|
async countRecommendations({filter}: { filter?: string }) {
|
||||||
|
Loading…
Reference in New Issue
Block a user