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:
Simon Backx 2023-10-02 16:51:03 +02:00 committed by GitHub
parent 1f251f63ac
commit b5fc527f8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 790 additions and 177 deletions

View File

@ -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",

View File

@ -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
});
}

View File

@ -1 +1,3 @@
declare module '@tryghost/mongo-utils';
declare module '@tryghost/errors';
declare module '@tryghost/nql';

View File

@ -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

View File

@ -11,7 +11,8 @@ module.exports = {
'limit',
'page',
'include',
'filter'
'filter',
'order'
],
permissions: true,
validation: {},

View File

@ -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 {

View File

@ -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');

View File

@ -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;\\">&nbsp;</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;\\">Dont 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;\\">&nbsp;</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>

View File

@ -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>;
}

View File

@ -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
};
}

View File

@ -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

View File

@ -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>;
};

View File

@ -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 }) {