ghost/recommendations/src/RecommendationController.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import errors from '@tryghost/errors';
import {AddRecommendation, Recommendation, RecommendationPlain} from './Recommendation';
import {RecommendationService} from './RecommendationService';
import {UnsafeData} from './UnsafeData';
import {OrderOption} from '@tryghost/bookshelf-repository';
type Frame = {
data: unknown,
options: unknown,
user: unknown,
};
const RecommendationIncludesMap = {
'count.clicks': 'clickCount' as const,
'count.subscribers': 'subscriberCount' as const
};
const RecommendationOrderMap = {
title: 'title' as const,
description: 'description' as const,
excerpt: 'excerpt' as const,
one_click_subscribe: 'oneClickSubscribe' as const,
created_at: 'createdAt' as const,
updated_at: 'updatedAt' as const,
'count.clicks': 'clickCount' as const,
'count.subscribers': 'subscriberCount' as const
};
export class RecommendationController {
service: RecommendationService;
constructor(deps: {service: RecommendationService}) {
this.service = deps.service;
}
async read(frame: Frame) {
const options = new UnsafeData(frame.options);
const id = options.key('id').string;
const recommendation = await this.service.readRecommendation(id);
return this.#serialize(
[recommendation]
);
}
async add(frame: Frame) {
const data = new UnsafeData(frame.data);
const recommendation = data.key('recommendations').index(0);
const plain: AddRecommendation = {
title: recommendation.key('title').string,
url: recommendation.key('url').url,
// Optional fields
oneClickSubscribe: recommendation.optionalKey('one_click_subscribe')?.boolean ?? false,
description: recommendation.optionalKey('description')?.nullable.string ?? null,
excerpt: recommendation.optionalKey('excerpt')?.nullable.string ?? null,
featuredImage: recommendation.optionalKey('featured_image')?.nullable.url ?? null,
favicon: recommendation.optionalKey('favicon')?.nullable.url ?? null
};
return this.#serialize(
[await this.service.addRecommendation(plain)]
);
}
/**
* Given a recommendation URL, returns either an existing recommendation with that url and updated metadata,
* or the metadata from that URL as if it would create a new one (without creating a new one)
*
* This can be used in the frontend when creating a new recommendation (duplication checking + showing a preview before saving)
*/
async check(frame: Frame) {
const data = new UnsafeData(frame.data);
const recommendation = data.key('recommendations').index(0);
const url = recommendation.key('url').url;
return this.#serialize(
[await this.service.checkRecommendation(url)]
);
}
async edit(frame: Frame) {
const options = new UnsafeData(frame.options);
const data = new UnsafeData(frame.data);
const recommendation = data.key('recommendations').index(0);
const id = options.key('id').string;
const plain: Partial<RecommendationPlain> = {
title: recommendation.optionalKey('title')?.string,
url: recommendation.optionalKey('url')?.url,
oneClickSubscribe: recommendation.optionalKey('one_click_subscribe')?.boolean,
description: recommendation.optionalKey('description')?.nullable.string,
excerpt: recommendation.optionalKey('excerpt')?.nullable.string,
featuredImage: recommendation.optionalKey('featured_image')?.nullable.url,
favicon: recommendation.optionalKey('favicon')?.nullable.url
};
return this.#serialize(
[await this.service.editRecommendation(id, plain)]
);
}
async destroy(frame: Frame) {
const options = new UnsafeData(frame.options);
const id = options.key('id').string;
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 [index, part] of parts.entries()) {
const trimmed = part.trim();
const fieldData = new UnsafeData(trimmed.split(' ')[0].trim(), {field: ['order', index.toString(), 'field']});
const directionData = new UnsafeData(trimmed.split(' ')[1]?.trim() ?? 'desc', {field: ['order', index.toString(), 'direction']});
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
});
}
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) => {
return RecommendationIncludesMap[item.enum(
Object.keys(RecommendationIncludesMap) as (keyof typeof RecommendationIncludesMap)[]
)];
}) ?? [];
const filter = options.optionalKey('filter')?.string;
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}));
return this.#serialize(
recommendations,
{
pagination: this.#serializePagination({page, limit, count})
}
);
}
async trackClicked(frame: Frame) {
const member = this.#optionalAuthMember(frame);
const options = new UnsafeData(frame.options);
const id = options.key('id').string;
await this.service.trackClicked({
id,
memberId: member?.id
});
}
async trackSubscribed(frame: Frame) {
const member = this.#authMember(frame);
const options = new UnsafeData(frame.options);
const id = options.key('id').string;
await this.service.trackSubscribed({
id,
memberId: member.id
});
}
#authMember(frame: Frame): {id: string} {
const options = new UnsafeData(frame.options);
const memberId = options.key('context').optionalKey('member')?.nullable.key('id').string;
if (!memberId) {
// This is an internal server error because authentication should happen outside this service.
throw new errors.UnauthorizedError({
message: 'Member not found'
});
}
return {
id: memberId
};
}
#optionalAuthMember(frame: Frame): {id: string}|null {
try {
const member = this.#authMember(frame);
return member;
} catch (e) {
if (e instanceof errors.UnauthorizedError) {
// This is fine, this is not required
} else {
throw e;
}
}
return null;
}
#serialize(recommendations: Partial<RecommendationPlain>[], meta?: any) {
return {
data: recommendations.map((entity) => {
const d = {
id: entity.id ?? null,
title: entity.title ?? null,
description: entity.description ?? null,
excerpt: entity.excerpt ?? null,
featured_image: entity.featuredImage?.toString() ?? null,
favicon: entity.favicon?.toString() ?? null,
url: entity.url?.toString() ?? null,
one_click_subscribe: entity.oneClickSubscribe ?? null,
created_at: entity.createdAt?.toISOString() ?? null,
updated_at: entity.updatedAt?.toISOString() ?? null,
count: entity.clickCount !== undefined || entity.subscriberCount !== undefined ? {
clicks: entity.clickCount,
subscribers: entity.subscriberCount
} : undefined
};
return d;
}),
meta
};
}
#serializePagination({page, limit, count}: {page: number, limit: number, count: number}) {
const pages = Math.ceil(count / limit);
return {
page,
limit,
total: count,
pages,
prev: page > 1 ? page - 1 : null,
next: page < pages ? page + 1 : null
};
}
}