Added BookshelfRepository and BookshelfRecommendationRepository

refs https://github.com/TryGhost/Product/issues/3800
This commit is contained in:
Simon Backx 2023-09-01 11:52:16 +02:00 committed by Simon Backx
parent d5c8804e23
commit 8600ccf387
21 changed files with 615 additions and 82 deletions

View File

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

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/ts'
]
};

View 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

View 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"
}
}

View 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[];
}
}

View File

@ -0,0 +1 @@
export * from './BookshelfRepository';

View File

@ -0,0 +1,7 @@
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

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

View File

@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"include": [
"src/**/*"
],
"compilerOptions": {
"outDir": "build"
}
}

View File

@ -0,0 +1,10 @@
const ghostBookshelf = require('./base');
const Recommendation = ghostBookshelf.Model.extend({
tableName: 'recommendations',
defaults: {}
}, {});
module.exports = {
Recommendation: ghostBookshelf.model('Recommendation', Recommendation)
};

View File

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

View File

@ -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",
}
`;

View File

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

View File

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

View File

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

View File

@ -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: "Shes 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 youll read, re-read, and read again over the years. Whether youre new to the field or an experienced practitioner, youll 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: "Lennys 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 youll read, re-read, and read again over the years. Whether youre new to the field or an experienced practitioner, youll 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 youll read, re-read, and read again over the years. Whether youre new to the field or an experienced practitioner, youll 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 youll read, re-read, and read again over the years. Whether youre new to the field or an experienced practitioner, youll 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 youll read, re-read, and read again over the years. Whether youre new to the field or an experienced practitioner, youll 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;
}

View File

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

View File

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

View File

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

View File

@ -4,3 +4,4 @@ export * from './RecommendationRepository';
export * from './InMemoryRecommendationRepository';
export * from './Recommendation';
export * from './WellknownService';
export * from './BookshelfRecommendationRepository';

View File

@ -1,2 +1,3 @@
declare module '@tryghost/errors';
declare module '@tryghost/tpl';
declare module '@tryghost/logging';