TryGhost/Ghost

View on GitHub
ghost/recommendations/src/Recommendation.ts

Summary

Maintainability
A
0 mins
Test Coverage
import ObjectId from 'bson-objectid';
import errors from '@tryghost/errors';
import {UnsafeData} from './UnsafeData';

/**
 * We never expose Entities outside of services. Because we should never expose the bussiness logic methods. The plain objects are used for that
 */
export type RecommendationPlain = {
    id: string,
    title: string
    description: string|null
    excerpt: 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,
    updatedAt: Date|null,

    /**
     * These are read only, you cannot change them
     */
    clickCount?: number
    subscriberCount?: number
}

export type RecommendationCreateData = {
    id?: string
    title: string
    description: string|null
    excerpt: string|null // Fetched from the site meta data
    featuredImage: URL|string|null // Fetched from the site meta data
    favicon: URL|string|null // Fetched from the site meta data
    url: URL|string
    oneClickSubscribe: boolean
    createdAt?: Date
    updatedAt?: Date|null,

    /**
     * These are read only, you cannot change them
     */
    clickCount?: number
    subscriberCount?: number
}

export type AddRecommendation = Omit<RecommendationCreateData, 'id'|'createdAt'|'updatedAt'>
export type EditRecommendation = Partial<AddRecommendation>

export class Recommendation {
    id: string;
    title: string;
    description: string|null;
    excerpt: 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;
    updatedAt: Date|null;
    #clickCount: number|undefined;
    #subscriberCount: number|undefined;

    #deleted: boolean;

    get deleted() {
        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;
        this.description = data.description;
        this.excerpt = data.excerpt;
        this.featuredImage = data.featuredImage;
        this.favicon = data.favicon;
        this.url = data.url;
        this.oneClickSubscribe = data.oneClickSubscribe;
        this.createdAt = data.createdAt;
        this.updatedAt = data.updatedAt;
        this.#clickCount = data.clickCount;
        this.#subscriberCount = data.subscriberCount;
        this.#deleted = false;
    }

    static validate(properties: AddRecommendation) {
        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.description && properties.description.length > 200) {
            throw new errors.ValidationError({
                message: 'Description must be less than 200 characters'
            });
        }
    }

    clean() {
        if (this.description !== null && this.description.length === 0) {
            this.description = null;
        }

        if (this.excerpt !== null && this.excerpt.length === 0) {
            this.excerpt = null;
        }

        if (this.excerpt !== null && this.excerpt.length > 2000) {
            this.excerpt = this.excerpt.slice(0, 1997) + '...';
        }

        this.createdAt.setMilliseconds(0);
        this.updatedAt?.setMilliseconds(0);
    }

    static create(data: RecommendationCreateData) {
        const id = data.id ?? ObjectId().toString();

        const d = {
            id,
            title: data.title,
            description: data.description,
            excerpt: data.excerpt,
            featuredImage: new UnsafeData(data.featuredImage, {field: ['featuredImage']}).nullable.url,
            favicon: new UnsafeData(data.favicon, {field: ['favicon']}).nullable.url,
            url: new UnsafeData(data.url, {field: ['url']}).url,
            oneClickSubscribe: data.oneClickSubscribe,
            createdAt: data.createdAt ?? new Date(),
            updatedAt: data.updatedAt ?? null,
            clickCount: data.clickCount,
            subscriberCount: data.subscriberCount
        };

        this.validate(d);
        const recommendation = new Recommendation(d);
        recommendation.clean();

        return recommendation;
    }

    get plain(): RecommendationPlain {
        return {
            id: this.id,
            title: this.title,
            description: this.description,
            excerpt: this.excerpt,
            featuredImage: this.featuredImage,
            favicon: this.favicon,
            url: this.url,
            oneClickSubscribe: this.oneClickSubscribe,
            createdAt: this.createdAt,
            updatedAt: this.updatedAt,
            clickCount: this.clickCount,
            subscriberCount: this.subscriberCount
        };
    }

    /**
     * Change the specified properties. Properties that are set to undefined will not be changed
     */
    edit(properties: EditRecommendation) {
        // Delete undefined properties
        const newProperties = this.plain;
        let didChange = false;

        for (const key of Object.keys(properties) as (keyof EditRecommendation)[]) {
            if (Object.prototype.hasOwnProperty.call(properties, key) && properties[key] !== undefined && properties[key] !== newProperties[key]) {
                (newProperties as Record<string, unknown>)[key] = properties[key] as unknown;
                didChange = true;
            }
        }

        if (!didChange) {
            return;
        }
        newProperties.updatedAt = new Date();

        const created = Recommendation.create(newProperties);
        Object.assign(this, created);
    }

    delete() {
        this.#deleted = true;
    }
}