Added BookshelfRepository and BookshelfRecommendationRepository
refs https://github.com/TryGhost/Product/issues/3800
This commit is contained in:
parent
d5c8804e23
commit
8600ccf387
2
.github/scripts/dev.js
vendored
2
.github/scripts/dev.js
vendored
@ -41,7 +41,7 @@ const COMMAND_ADMIN = {
|
||||
|
||||
const COMMAND_TYPESCRIPT = {
|
||||
name: 'ts',
|
||||
command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations -- nx run \\$NX_PROJECT_NAME:build:ts',
|
||||
command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/bookshelf-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations -- nx run \\$NX_PROJECT_NAME:build:ts',
|
||||
cwd: path.resolve(__dirname, '../../'),
|
||||
prefixColor: 'cyan',
|
||||
env: {}
|
||||
|
6
ghost/bookshelf-repository/.eslintrc.js
Normal file
6
ghost/bookshelf-repository/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/ts'
|
||||
]
|
||||
};
|
21
ghost/bookshelf-repository/README.md
Normal file
21
ghost/bookshelf-repository/README.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Bookshelf Repository
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
## Develop
|
||||
|
||||
This is a monorepo package.
|
||||
|
||||
Follow the instructions for the top-level repo.
|
||||
1. `git clone` this repo & `cd` into it as usual
|
||||
2. Run `yarn` to install top-level dependencies.
|
||||
|
||||
|
||||
|
||||
## Test
|
||||
|
||||
- `yarn lint` run just eslint
|
||||
- `yarn test` run lint and tests
|
||||
|
30
ghost/bookshelf-repository/package.json
Normal file
30
ghost/bookshelf-repository/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@tryghost/bookshelf-repository",
|
||||
"version": "0.0.0",
|
||||
"repository": "https://github.com/TryGhost/Ghost/tree/main/ghost/bookshelf-repository",
|
||||
"author": "Ghost Foundation",
|
||||
"private": true,
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build:ts": "yarn build",
|
||||
"test:unit": "NODE_ENV=testing c8 --src src --all --check-coverage --100 --reporter text --reporter cobertura mocha -r ts-node/register './test/**/*.test.ts'",
|
||||
"test": "yarn test:types && yarn test:unit",
|
||||
"test:types": "tsc --noEmit",
|
||||
"lint:code": "eslint src/ --ext .ts --cache",
|
||||
"lint": "yarn lint:code && yarn lint:test",
|
||||
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .ts --cache"
|
||||
},
|
||||
"files": [
|
||||
"build"
|
||||
],
|
||||
"devDependencies": {
|
||||
"c8": "7.14.0",
|
||||
"mocha": "10.2.0",
|
||||
"sinon": "15.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tryghost/nql": "0.11.0"
|
||||
}
|
||||
}
|
62
ghost/bookshelf-repository/src/BookshelfRepository.ts
Normal file
62
ghost/bookshelf-repository/src/BookshelfRepository.ts
Normal file
@ -0,0 +1,62 @@
|
||||
type Entity<T> = {
|
||||
id: T;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
type Order<T> = {
|
||||
field: keyof T;
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export type ModelClass<T> = {
|
||||
destroy: (data: {id: T}) => Promise<void>;
|
||||
findOne: (data: {id: T}, options?: {require?: boolean}) => Promise<ModelInstance<T> | null>;
|
||||
findAll: (options: {filter?: string; order?: OrderOption}) => Promise<ModelInstance<T>[]>;
|
||||
add: (data: object) => Promise<ModelInstance<T>>;
|
||||
}
|
||||
|
||||
export type ModelInstance<T> = {
|
||||
id: T;
|
||||
get(field: string): unknown;
|
||||
set(data: object|string, value?: unknown): void;
|
||||
save(properties: object, options?: {autoRefresh?: boolean; method?: 'update' | 'insert'}): Promise<ModelInstance<T>>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type OrderOption<T extends Entity<any> = any> = Order<T>[];
|
||||
|
||||
export abstract class BookshelfRepository<IDType, T extends Entity<IDType>> {
|
||||
protected Model: ModelClass<IDType>;
|
||||
|
||||
constructor(Model: ModelClass<IDType>) {
|
||||
this.Model = Model;
|
||||
}
|
||||
|
||||
protected abstract toPrimitive(entity: T): object;
|
||||
protected abstract modelToEntity(model: ModelInstance<IDType>): Promise<T|null> | T | null;
|
||||
|
||||
async save(entity: T): Promise<void> {
|
||||
if (entity.deleted) {
|
||||
await this.Model.destroy({id: entity.id});
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await this.Model.findOne({id: entity.id}, {require: false});
|
||||
if (existing) {
|
||||
existing.set(this.toPrimitive(entity))
|
||||
await existing.save({}, {autoRefresh: false, method: 'update'});
|
||||
} else {
|
||||
await this.Model.add(this.toPrimitive(entity))
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async getAll({filter, order}: { filter?: string; order?: OrderOption<T> } = {}): Promise<T[]> {
|
||||
const models = await this.Model.findAll({filter, order}) as ModelInstance<IDType>[];
|
||||
return (await Promise.all(models.map(model => this.modelToEntity(model)))).filter(entity => !!entity) as T[];
|
||||
}
|
||||
}
|
1
ghost/bookshelf-repository/src/index.ts
Normal file
1
ghost/bookshelf-repository/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './BookshelfRepository';
|
7
ghost/bookshelf-repository/test/.eslintrc.js
Normal file
7
ghost/bookshelf-repository/test/.eslintrc.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
202
ghost/bookshelf-repository/test/BookshelfRepository.test.ts
Normal file
202
ghost/bookshelf-repository/test/BookshelfRepository.test.ts
Normal file
@ -0,0 +1,202 @@
|
||||
import assert from 'assert';
|
||||
import {BookshelfRepository, ModelClass, ModelInstance} from '../src/index';
|
||||
|
||||
type SimpleEntity = {
|
||||
id: string;
|
||||
deleted: boolean;
|
||||
name: string;
|
||||
age: number;
|
||||
birthday: string;
|
||||
}
|
||||
|
||||
class SimpleBookshelfRepository extends BookshelfRepository<string, SimpleEntity> {
|
||||
protected modelToEntity(model: ModelInstance<string>): SimpleEntity {
|
||||
return {
|
||||
id: model.id,
|
||||
deleted: false,
|
||||
name: model.get('name') as string,
|
||||
age: model.get('age') as number,
|
||||
birthday: model.get('birthday') as string
|
||||
};
|
||||
}
|
||||
|
||||
protected toPrimitive(entity: SimpleEntity): object {
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
age: entity.age,
|
||||
birthday: entity.birthday
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class Model implements ModelClass<string> {
|
||||
items: ModelInstance<string>[] = [];
|
||||
|
||||
constructor() {
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
destroy(data: {id: string;}): Promise<void> {
|
||||
this.items = this.items.filter(item => item.id !== data.id);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
findOne(data: {id: string;}, options?: {require?: boolean | undefined;} | undefined): Promise<ModelInstance<string> | null> {
|
||||
const item = this.items.find(i => i.id === data.id);
|
||||
if (!item && options?.require) {
|
||||
throw new Error('Not found');
|
||||
}
|
||||
return Promise.resolve(item ?? null);
|
||||
}
|
||||
findAll(options: {filter?: string | undefined; order?: {field: string | number | symbol; direction: 'desc' | 'asc';}[] | undefined;}): Promise<ModelInstance<string>[]> {
|
||||
const sorted = this.items.slice().sort((a, b) => {
|
||||
for (const order of options.order ?? []) {
|
||||
const aValue = a.get(order.field as string) as number;
|
||||
const bValue = b.get(order.field as string) as number;
|
||||
if (aValue < bValue) {
|
||||
return order.direction === 'asc' ? -1 : 1;
|
||||
} else if (aValue > bValue) {
|
||||
return order.direction === 'asc' ? 1 : -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return Promise.resolve(sorted);
|
||||
}
|
||||
|
||||
add(data: object): Promise<ModelInstance<string>> {
|
||||
const item = {
|
||||
id: (data as any).id,
|
||||
get(field: string): unknown {
|
||||
return (data as any)[field];
|
||||
},
|
||||
set(d: object|string, value?: unknown): void {
|
||||
if (typeof d === 'string') {
|
||||
(data as any)[d] = value;
|
||||
} else {
|
||||
Object.assign(data, d);
|
||||
}
|
||||
},
|
||||
save(properties: object): Promise<ModelInstance<string>> {
|
||||
Object.assign(data, properties);
|
||||
return Promise.resolve(item);
|
||||
}
|
||||
};
|
||||
this.items.push(item);
|
||||
return Promise.resolve(item);
|
||||
}
|
||||
}
|
||||
|
||||
describe('BookshelfRepository', function () {
|
||||
it('Can save, retrieve, update and delete entities', async function () {
|
||||
const repository = new SimpleBookshelfRepository(new Model());
|
||||
|
||||
checkRetrieving: {
|
||||
const entity = {
|
||||
id: '1',
|
||||
deleted: false,
|
||||
name: 'John',
|
||||
age: 30,
|
||||
birthday: new Date('2000-01-01').toISOString()
|
||||
};
|
||||
|
||||
await repository.save(entity);
|
||||
const result = await repository.getById('1');
|
||||
|
||||
assert(result);
|
||||
assert(result.name === 'John');
|
||||
assert(result.age === 30);
|
||||
assert(result.id === '1');
|
||||
|
||||
break checkRetrieving;
|
||||
}
|
||||
|
||||
checkUpdating: {
|
||||
const entity = {
|
||||
id: '2',
|
||||
deleted: false,
|
||||
name: 'John',
|
||||
age: 24,
|
||||
birthday: new Date('2000-01-01').toISOString()
|
||||
};
|
||||
|
||||
await repository.save(entity);
|
||||
|
||||
entity.name = 'Kym';
|
||||
|
||||
await repository.save(entity);
|
||||
|
||||
const result = await repository.getById('2');
|
||||
|
||||
assert(result);
|
||||
assert.equal(result.name, 'Kym');
|
||||
assert.equal(result.age, 24);
|
||||
assert.equal(result.id, '2');
|
||||
|
||||
break checkUpdating;
|
||||
}
|
||||
|
||||
checkDeleting: {
|
||||
const entity = {
|
||||
id: '3',
|
||||
deleted: false,
|
||||
name: 'Egg',
|
||||
age: 180,
|
||||
birthday: new Date('2010-01-01').toISOString()
|
||||
};
|
||||
|
||||
await repository.save(entity);
|
||||
|
||||
assert(await repository.getById('3'));
|
||||
|
||||
entity.deleted = true;
|
||||
|
||||
await repository.save(entity);
|
||||
|
||||
assert(!await repository.getById('3'));
|
||||
|
||||
break checkDeleting;
|
||||
}
|
||||
});
|
||||
|
||||
it('Can save and retrieve all entities', async function () {
|
||||
const repository = new SimpleBookshelfRepository(new Model());
|
||||
const entities = [{
|
||||
id: '1',
|
||||
deleted: false,
|
||||
name: 'Kym',
|
||||
age: 24,
|
||||
birthday: new Date('2000-01-01').toISOString()
|
||||
}, {
|
||||
id: '2',
|
||||
deleted: false,
|
||||
name: 'John',
|
||||
age: 30,
|
||||
birthday: new Date('2000-01-01').toISOString()
|
||||
}, {
|
||||
id: '3',
|
||||
deleted: false,
|
||||
name: 'Kevin',
|
||||
age: 5,
|
||||
birthday: new Date('2000-01-01').toISOString()
|
||||
}];
|
||||
|
||||
for (const entity of entities) {
|
||||
await repository.save(entity);
|
||||
}
|
||||
|
||||
const result = await repository.getAll({
|
||||
order: [{
|
||||
field: 'age',
|
||||
direction: 'desc'
|
||||
}]
|
||||
});
|
||||
|
||||
assert(result);
|
||||
assert(result.length === 3);
|
||||
assert(result[0].age === 30);
|
||||
assert(result[1].age === 24);
|
||||
assert(result[2].age === 5);
|
||||
});
|
||||
});
|
9
ghost/bookshelf-repository/tsconfig.json
Normal file
9
ghost/bookshelf-repository/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"outDir": "build"
|
||||
}
|
||||
}
|
10
ghost/core/core/server/models/recommendation.js
Normal file
10
ghost/core/core/server/models/recommendation.js
Normal file
@ -0,0 +1,10 @@
|
||||
const ghostBookshelf = require('./base');
|
||||
|
||||
const Recommendation = ghostBookshelf.Model.extend({
|
||||
tableName: 'recommendations',
|
||||
defaults: {}
|
||||
}, {});
|
||||
|
||||
module.exports = {
|
||||
Recommendation: ghostBookshelf.model('Recommendation', Recommendation)
|
||||
};
|
@ -21,7 +21,9 @@ class RecommendationServiceWrapper {
|
||||
|
||||
const config = require('../../../shared/config');
|
||||
const urlUtils = require('../../../shared/url-utils');
|
||||
const {InMemoryRecommendationRepository, RecommendationService, RecommendationController, WellknownService} = require('@tryghost/recommendations');
|
||||
const models = require('../../models');
|
||||
const sentry = require('../../../shared/sentry');
|
||||
const {BookshelfRecommendationRepository, RecommendationService, RecommendationController, WellknownService} = require('@tryghost/recommendations');
|
||||
|
||||
const mentions = require('../mentions');
|
||||
|
||||
@ -35,7 +37,9 @@ class RecommendationServiceWrapper {
|
||||
urlUtils
|
||||
});
|
||||
|
||||
this.repository = new InMemoryRecommendationRepository();
|
||||
this.repository = new BookshelfRecommendationRepository(models.Recommendation, {
|
||||
sentry
|
||||
});
|
||||
this.service = new RecommendationService({
|
||||
repository: this.repository,
|
||||
wellknownService,
|
||||
|
@ -112,7 +112,7 @@ Object {
|
||||
"one_click_subscribe": true,
|
||||
"reason": "Because dogs are cute",
|
||||
"title": "Dog Pictures",
|
||||
"updated_at": null,
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://dogpictures.com/",
|
||||
},
|
||||
],
|
||||
@ -123,7 +123,7 @@ exports[`Recommendations Admin API Can browse 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "354",
|
||||
"content-length": "376",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
@ -178,3 +178,65 @@ Object {
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Admin API Cannot edit to invalid recommendation state 1: [body] 1`] = `
|
||||
Object {
|
||||
"errors": Array [
|
||||
Object {
|
||||
"code": null,
|
||||
"context": "Featured image must be a valid URL",
|
||||
"details": null,
|
||||
"ghostErrorCode": null,
|
||||
"help": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
"message": "Validation error, cannot edit recommendation.",
|
||||
"property": null,
|
||||
"type": "ValidationError",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Admin API Cannot edit to invalid recommendation state 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": "265",
|
||||
"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 Cannot use invalid protocols when editing 1: [body] 1`] = `
|
||||
Object {
|
||||
"errors": Array [
|
||||
Object {
|
||||
"code": null,
|
||||
"context": "Featured image must be a valid URL",
|
||||
"details": null,
|
||||
"ghostErrorCode": null,
|
||||
"help": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
"message": "Validation error, cannot edit recommendation.",
|
||||
"property": null,
|
||||
"type": "ValidationError",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Admin API Cannot use invalid protocols when editing 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": "265",
|
||||
"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",
|
||||
}
|
||||
`;
|
||||
|
@ -1,5 +1,5 @@
|
||||
const {agentProvider, fixtureManager, mockManager, matchers} = require('../../utils/e2e-framework');
|
||||
const {anyObjectId, anyISODateTime, anyContentVersion, anyLocationFor, anyEtag} = matchers;
|
||||
const {anyObjectId, anyErrorId, anyISODateTime, anyContentVersion, anyLocationFor, anyEtag} = matchers;
|
||||
const assert = require('assert/strict');
|
||||
const recommendationsService = require('../../../core/server/services/recommendations');
|
||||
|
||||
@ -133,6 +133,34 @@ describe('Recommendations Admin API', function () {
|
||||
assert.equal(body.recommendations[0].one_click_subscribe, false);
|
||||
});
|
||||
|
||||
it('Cannot use invalid protocols when editing', async function () {
|
||||
const id = (await recommendationsService.repository.getAll())[0].id;
|
||||
await agent.put(`recommendations/${id}/`)
|
||||
.body({
|
||||
recommendations: [{
|
||||
title: 'Cat Pictures',
|
||||
url: 'https://catpictures.com',
|
||||
reason: 'Because cats are cute',
|
||||
excerpt: 'Cats are cute',
|
||||
featured_image: 'ftp://catpictures.com/cat.jpg',
|
||||
favicon: 'ftp://catpictures.com/favicon.ico',
|
||||
one_click_subscribe: false
|
||||
}]
|
||||
})
|
||||
.expectStatus(422)
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
errors: [
|
||||
{
|
||||
id: anyErrorId
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('Can delete recommendation', async function () {
|
||||
const id = (await recommendationsService.repository.getAll())[0].id;
|
||||
await agent.delete(`recommendations/${id}/`)
|
||||
@ -155,7 +183,8 @@ describe('Recommendations Admin API', function () {
|
||||
recommendations: [
|
||||
{
|
||||
id: anyObjectId,
|
||||
created_at: anyISODateTime
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime
|
||||
}
|
||||
]
|
||||
});
|
||||
|
@ -31,6 +31,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tryghost/tpl": "0.1.25",
|
||||
"@tryghost/errors": "1.2.25"
|
||||
"@tryghost/errors": "1.2.25",
|
||||
"@tryghost/in-memory-repository": "0.0.0",
|
||||
"@tryghost/bookshelf-repository": "0.0.0",
|
||||
"@tryghost/logging": "2.4.5"
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,53 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export class BookshelfRecommendationRepository extends BookshelfRepository<string, Recommendation> implements RecommendationRepository {
|
||||
sentry?: Sentry;
|
||||
|
||||
constructor(Model: ModelClass<string>, deps: {sentry?: Sentry} = {}) {
|
||||
super(Model);
|
||||
this.sentry = deps.sentry;
|
||||
}
|
||||
|
||||
toPrimitive(entity: Recommendation): object {
|
||||
return {
|
||||
id: entity.id,
|
||||
title: entity.title,
|
||||
reason: entity.reason,
|
||||
excerpt: entity.excerpt,
|
||||
featured_image: entity.featuredImage?.toString(),
|
||||
favicon: entity.favicon?.toString(),
|
||||
url: entity.url.toString(),
|
||||
one_click_subscribe: entity.oneClickSubscribe,
|
||||
created_at: entity.createdAt,
|
||||
updated_at: entity.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
modelToEntity(model: ModelInstance<string>): Recommendation | null {
|
||||
try {
|
||||
return Recommendation.create({
|
||||
id: model.id,
|
||||
title: model.get('title') as string,
|
||||
reason: model.get('reason') as string | null,
|
||||
excerpt: model.get('excerpt') as string | null,
|
||||
featuredImage: (model.get('featured_image') as string | null) !== null ? new URL(model.get('featured_image') as string) : null,
|
||||
favicon: (model.get('favicon') as string | null) !== null ? new URL(model.get('favicon') as string) : null,
|
||||
url: new 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,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
this.sentry?.captureException(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
@ -3,54 +3,6 @@ import {RecommendationRepository} from "./RecommendationRepository";
|
||||
import {InMemoryRepository} from '@tryghost/in-memory-repository';
|
||||
|
||||
export class InMemoryRecommendationRepository extends InMemoryRepository<string, Recommendation> implements RecommendationRepository {
|
||||
store: Recommendation[] = [
|
||||
new Recommendation({
|
||||
title: "She‘s A Beast",
|
||||
reason: "She helped me get back into the gym after 8 years of chilling",
|
||||
excerpt: "The Pragmatic Programmer is one of those rare tech books you’ll read, re-read, and read again over the years. Whether you’re new to the field or an experienced practitioner, you’ll come away with fresh insights each and every time.",
|
||||
featuredImage: "https://www.thepragmaticprogrammer.com/image.png",
|
||||
favicon: "https://www.shesabeast.co/content/images/size/w256h256/2022/08/transparent-icon-black-copy-gray-bar.png",
|
||||
url: new URL("https://www.thepragmaticprogrammer.com/"),
|
||||
oneClickSubscribe: false
|
||||
}),
|
||||
new Recommendation({
|
||||
title: "Lenny‘s Newsletter",
|
||||
reason: "He knows his stuff about product management and gives away lots of content for free. Highly recommended!",
|
||||
excerpt: "The Pragmatic Programmer is one of those rare tech books you’ll read, re-read, and read again over the years. Whether you’re new to the field or an experienced practitioner, you’ll come away with fresh insights each and every time.",
|
||||
featuredImage: "https://www.thepragmaticprogrammer.com/image.png",
|
||||
favicon: "https://substackcdn.com/image/fetch/f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2Fc7cde267-8f9e-47fa-9aef-5be03bad95ed%2Fapple-touch-icon-1024x1024.png",
|
||||
url: new URL("https://www.thepragmaticprogrammer.com/"),
|
||||
oneClickSubscribe: false
|
||||
}),
|
||||
new Recommendation({
|
||||
title: "Clickhole",
|
||||
reason: "Funny",
|
||||
excerpt: "The Pragmatic Programmer is one of those rare tech books you’ll read, re-read, and read again over the years. Whether you’re new to the field or an experienced practitioner, you’ll come away with fresh insights each and every time.",
|
||||
featuredImage: "https://www.thepragmaticprogrammer.com/image.png",
|
||||
favicon: "https://clickhole.com/wp-content/uploads/2020/05/cropped-clickhole-icon-180x180.png",
|
||||
url: new URL("https://www.thepragmaticprogrammer.com/"),
|
||||
oneClickSubscribe: false
|
||||
}),
|
||||
new Recommendation({
|
||||
title: "The Verge",
|
||||
reason: "Consistently best tech news, I read it every day!",
|
||||
excerpt: "The Pragmatic Programmer is one of those rare tech books you’ll read, re-read, and read again over the years. Whether you’re new to the field or an experienced practitioner, you’ll come away with fresh insights each and every time.",
|
||||
featuredImage: "https://www.thepragmaticprogrammer.com/image.png",
|
||||
favicon: "https://www.theverge.com/icons/apple_touch_icon.png",
|
||||
url: new URL("https://www.thepragmaticprogrammer.com/"),
|
||||
oneClickSubscribe: false
|
||||
}),
|
||||
new Recommendation({
|
||||
title: "The Counteroffensive with Tim Mak",
|
||||
reason: "On-the-ground war reporting from Ukraine.",
|
||||
excerpt: "The Pragmatic Programmer is one of those rare tech books you’ll read, re-read, and read again over the years. Whether you’re new to the field or an experienced practitioner, you’ll come away with fresh insights each and every time.",
|
||||
featuredImage: "https://www.thepragmaticprogrammer.com/image.png",
|
||||
favicon: "https://substackcdn.com/image/fetch/w_96,h_96,c_fill,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ff3f2b2ad-681f-45e1-9496-db80f45e853d_403x403.png",
|
||||
url: new URL("https://www.thepragmaticprogrammer.com/"),
|
||||
oneClickSubscribe: true
|
||||
})
|
||||
];
|
||||
|
||||
toPrimitive(entity: Recommendation): object {
|
||||
return entity;
|
||||
}
|
||||
|
@ -1,24 +1,27 @@
|
||||
import ObjectId from "bson-objectid";
|
||||
import errors from "@tryghost/errors";
|
||||
|
||||
export type AddRecommendation = {
|
||||
title: string
|
||||
reason: string|null
|
||||
excerpt: string|null // Fetched from the site meta data
|
||||
featuredImage: string|null // Fetched from the site meta data
|
||||
favicon: string|null // Fetched from the site meta data
|
||||
featuredImage: URL|null // Fetched from the site meta data
|
||||
favicon: URL|null // Fetched from the site meta data
|
||||
url: URL
|
||||
oneClickSubscribe: boolean
|
||||
}
|
||||
|
||||
export type EditRecommendation = Partial<AddRecommendation>
|
||||
type RecommendationConstructorData = AddRecommendation & {id: string, createdAt: Date, updatedAt: Date|null}
|
||||
export type RecommendationCreateData = AddRecommendation & {id?: string, createdAt?: Date, updatedAt?: Date|null}
|
||||
|
||||
export class Recommendation {
|
||||
id: string
|
||||
title: string
|
||||
reason: string|null
|
||||
excerpt: string|null // Fetched from the site meta data
|
||||
featuredImage: string|null // Fetched from the site meta data
|
||||
favicon: string|null // Fetched from the site meta data
|
||||
featuredImage: URL|null // Fetched from the site meta data
|
||||
favicon: URL|null // Fetched from the site meta data
|
||||
url: URL
|
||||
oneClickSubscribe: boolean
|
||||
createdAt: Date
|
||||
@ -30,8 +33,8 @@ export class Recommendation {
|
||||
return this.#deleted;
|
||||
}
|
||||
|
||||
constructor(data: {id?: string, title: string, reason: string|null, excerpt: string|null, featuredImage: string|null, favicon: string|null, url: URL, oneClickSubscribe: boolean, createdAt?: Date, updatedAt?: Date|null}) {
|
||||
this.id = data.id ?? ObjectId().toString();
|
||||
private constructor(data: RecommendationConstructorData) {
|
||||
this.id = data.id;
|
||||
this.title = data.title;
|
||||
this.reason = data.reason;
|
||||
this.excerpt = data.excerpt;
|
||||
@ -39,19 +42,95 @@ export class Recommendation {
|
||||
this.favicon = data.favicon;
|
||||
this.url = data.url;
|
||||
this.oneClickSubscribe = data.oneClickSubscribe;
|
||||
this.createdAt = data.createdAt ?? new Date();
|
||||
this.createdAt.setMilliseconds(0);
|
||||
this.updatedAt = data.updatedAt ?? null;
|
||||
this.updatedAt?.setMilliseconds(0);
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
this.#deleted = false;
|
||||
}
|
||||
|
||||
edit(properties: Partial<Recommendation>) {
|
||||
Object.assign(this, properties);
|
||||
this.createdAt.setMilliseconds(0);
|
||||
static validate(properties: AddRecommendation) {
|
||||
if (properties.url.protocol !== 'http:' && properties.url.protocol !== 'https:') {
|
||||
throw new errors.ValidationError({
|
||||
message: 'url must be a valid URL',
|
||||
});
|
||||
}
|
||||
|
||||
this.updatedAt = new Date();
|
||||
this.updatedAt.setMilliseconds(0);
|
||||
if (properties.featuredImage !== null) {
|
||||
if (properties.featuredImage.protocol !== 'http:' && properties.featuredImage.protocol !== 'https:') {
|
||||
throw new errors.ValidationError({
|
||||
message: 'Featured image must be a valid URL',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (properties.favicon !== null) {
|
||||
if (properties.favicon.protocol !== 'http:' && properties.favicon.protocol !== 'https:') {
|
||||
throw new errors.ValidationError({
|
||||
message: 'Favicon must be a valid URL',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (properties.title.length === 0) {
|
||||
throw new errors.ValidationError({
|
||||
message: 'Title must not be empty',
|
||||
});
|
||||
}
|
||||
|
||||
if (properties.title.length > 2000) {
|
||||
throw new errors.ValidationError({
|
||||
message: 'Title must be less than 2000 characters',
|
||||
});
|
||||
}
|
||||
|
||||
if (properties.reason && properties.reason.length > 2000) {
|
||||
throw new errors.ValidationError({
|
||||
message: 'Reason must be less than 2000 characters',
|
||||
});
|
||||
}
|
||||
|
||||
if (properties.excerpt && properties.excerpt.length > 2000) {
|
||||
throw new errors.ValidationError({
|
||||
message: 'Excerpt must be less than 2000 characters',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clean() {
|
||||
if (this.reason !== null && this.reason.length === 0) {
|
||||
this.reason = null;
|
||||
}
|
||||
|
||||
this.createdAt.setMilliseconds(0);
|
||||
this.updatedAt?.setMilliseconds(0);
|
||||
}
|
||||
|
||||
static create(data: RecommendationCreateData) {
|
||||
const id = data.id ?? ObjectId().toString();
|
||||
|
||||
const d = {
|
||||
id,
|
||||
title: data.title,
|
||||
reason: data.reason,
|
||||
excerpt: data.excerpt,
|
||||
featuredImage: data.featuredImage,
|
||||
favicon: data.favicon,
|
||||
url: data.url,
|
||||
oneClickSubscribe: data.oneClickSubscribe,
|
||||
createdAt: data.createdAt ?? new Date(),
|
||||
updatedAt: data.updatedAt ?? null,
|
||||
};
|
||||
|
||||
this.validate(d);
|
||||
const recommendation = new Recommendation(d);
|
||||
recommendation.clean();
|
||||
return recommendation;
|
||||
}
|
||||
|
||||
edit(properties: EditRecommendation) {
|
||||
Recommendation.validate({...this, ...properties});
|
||||
|
||||
Object.assign(this, properties);
|
||||
this.clean();
|
||||
}
|
||||
|
||||
delete() {
|
||||
|
@ -70,7 +70,7 @@ export class RecommendationController {
|
||||
return id;
|
||||
}
|
||||
|
||||
#getFrameRecommendation(frame: Frame): Recommendation {
|
||||
#getFrameRecommendation(frame: Frame): AddRecommendation {
|
||||
if (!frame.data || !frame.data.recommendations || !frame.data.recommendations[0]) {
|
||||
throw new errors.BadRequestError();
|
||||
}
|
||||
@ -85,12 +85,12 @@ export class RecommendationController {
|
||||
oneClickSubscribe: validateBoolean(recommendation, "one_click_subscribe", {required: false}) ?? false,
|
||||
reason: validateString(recommendation, "reason", {required: false}) ?? null,
|
||||
excerpt: validateString(recommendation, "excerpt", {required: false}) ?? null,
|
||||
featuredImage: validateString(recommendation, "featured_image", {required: false}) ?? null,
|
||||
favicon: validateString(recommendation, "favicon", {required: false}) ?? null,
|
||||
featuredImage: validateURL(recommendation, "featured_image", {required: false}) ?? null,
|
||||
favicon: validateURL(recommendation, "favicon", {required: false}) ?? null,
|
||||
};
|
||||
|
||||
// Create a new recommendation
|
||||
return new Recommendation(cleanedRecommendation);
|
||||
return cleanedRecommendation;
|
||||
}
|
||||
|
||||
#getFrameRecommendationEdit(frame: Frame): Partial<EditRecommendation> {
|
||||
@ -105,8 +105,8 @@ export class RecommendationController {
|
||||
oneClickSubscribe: validateBoolean(recommendation, "one_click_subscribe", {required: false}),
|
||||
reason: validateString(recommendation, "reason", {required: false}),
|
||||
excerpt: validateString(recommendation, "excerpt", {required: false}),
|
||||
featuredImage: validateString(recommendation, "featured_image", {required: false}),
|
||||
favicon: validateString(recommendation, "favicon", {required: false}),
|
||||
featuredImage: validateURL(recommendation, "featured_image", {required: false}),
|
||||
favicon: validateURL(recommendation, "favicon", {required: false}),
|
||||
};
|
||||
|
||||
// Create a new recommendation
|
||||
@ -122,8 +122,8 @@ export class RecommendationController {
|
||||
title: r.title,
|
||||
reason: r.reason,
|
||||
excerpt: r.excerpt,
|
||||
featured_image: r.featuredImage,
|
||||
favicon: r.favicon,
|
||||
featured_image: r.featuredImage?.toString() ?? null,
|
||||
favicon: r.favicon?.toString() ?? null,
|
||||
url: r.url.toString(),
|
||||
one_click_subscribe: r.oneClickSubscribe,
|
||||
created_at: r.createdAt,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Recommendation} from "./Recommendation";
|
||||
import {AddRecommendation, Recommendation} from "./Recommendation";
|
||||
import {RecommendationRepository} from "./RecommendationRepository";
|
||||
import {WellknownService} from "./WellknownService";
|
||||
import errors from "@tryghost/errors";
|
||||
@ -32,7 +32,7 @@ export class RecommendationService {
|
||||
await this.wellknownService.set(recommendations);
|
||||
}
|
||||
|
||||
sendMentionToRecommendation(recommendation: Recommendation) {
|
||||
private sendMentionToRecommendation(recommendation: Recommendation) {
|
||||
this.mentionSendingService.sendAll({
|
||||
url: this.wellknownService.getURL(),
|
||||
links: [
|
||||
@ -41,7 +41,8 @@ export class RecommendationService {
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
async addRecommendation(recommendation: Recommendation) {
|
||||
async addRecommendation(addRecommendation: AddRecommendation) {
|
||||
const recommendation = Recommendation.create(addRecommendation);
|
||||
this.repository.save(recommendation);
|
||||
await this.updateWellknown();
|
||||
|
||||
|
@ -4,3 +4,4 @@ export * from './RecommendationRepository';
|
||||
export * from './InMemoryRecommendationRepository';
|
||||
export * from './Recommendation';
|
||||
export * from './WellknownService';
|
||||
export * from './BookshelfRecommendationRepository';
|
||||
|
1
ghost/recommendations/src/libraries.d.ts
vendored
1
ghost/recommendations/src/libraries.d.ts
vendored
@ -1,2 +1,3 @@
|
||||
declare module '@tryghost/errors';
|
||||
declare module '@tryghost/tpl';
|
||||
declare module '@tryghost/logging';
|
||||
|
Loading…
Reference in New Issue
Block a user