TryGhost/Ghost

View on GitHub
ghost/collections/src/CollectionsService.ts

Summary

Maintainability
F
4 days
Test Coverage
import logging from '@tryghost/logging';
import tpl from '@tryghost/tpl';
import isEqual from 'lodash/isEqual';
import {Knex} from 'knex';
import {
    PostsBulkUnpublishedEvent,
    PostsBulkFeaturedEvent,
    PostsBulkUnfeaturedEvent,
    PostsBulkAddTagsEvent
} from '@tryghost/post-events';
import debugModule from '@tryghost/debug';
import {Collection} from './Collection';
import {CollectionRepository} from './CollectionRepository';
import {CollectionPost} from './CollectionPost';
import {MethodNotAllowedError} from '@tryghost/errors';
import {PostAddedEvent} from './events/PostAddedEvent';
import {PostEditedEvent} from './events/PostEditedEvent';
import {RepositoryUniqueChecker} from './RepositoryUniqueChecker';
import {TagDeletedEvent} from './events/TagDeletedEvent';

const debug = debugModule('collections');

const messages = {
    cannotDeleteBuiltInCollectionError: {
        message: 'Cannot delete builtin collection',
        context: 'The collection {id} is a builtin collection and cannot be deleted'
    },
    collectionNotFound: {
        message: 'Collection not found',
        context: 'Collection with id: {id} does not exist'
    }
};

interface SlugService {
    generate(desired: string, options: {transaction: Knex.Transaction}): Promise<string>;
}

type CollectionsServiceDeps = {
    collectionsRepository: CollectionRepository;
    postsRepository: PostsRepository;
    slugService: SlugService;
    DomainEvents: {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        subscribe: (event: any, handler: (e: any) => void) => void;
    };
};

type CollectionPostDTO = {
    id: string;
    sort_order: number;
};

type CollectionPostListItemDTO = {
    id: string;
    url: string;
    slug: string;
    title: string;
    featured: boolean;
    featured_image?: string;
    created_at: Date;
    updated_at: Date;
    published_at: Date,
    tags: Array<{slug: string}>;
}

type ManualCollection = {
    title: string;
    type: 'manual';
    slug?: string;
    description?: string;
    feature_image?: string;
    filter?: null;
    deletable?: boolean;
};

type AutomaticCollection = {
    title: string;
    type: 'automatic';
    filter: string;
    slug?: string;
    description?: string;
    feature_image?: string;
    deletable?: boolean;
};

type CollectionInputDTO = ManualCollection | AutomaticCollection;

type CollectionDTO = {
    id: string;
    title: string | null;
    slug: string;
    description: string | null;
    feature_image: string | null;
    type: 'manual' | 'automatic';
    filter: string | null;
    created_at: Date;
    updated_at: Date | null;
    posts: CollectionPostDTO[];
};

type QueryOptions = {
    filter?: string;
    include?: string;
    page?: number;
    limit?: number;
    transaction?: Knex.Transaction;
}

interface PostsRepository {
    getAll(options: QueryOptions): Promise<CollectionPost[]>;
    getAllIds(options?: {transaction: Knex.Transaction}): Promise<string[]>;
}

export class CollectionsService {
    private collectionsRepository: CollectionRepository;
    private postsRepository: PostsRepository;
    private DomainEvents: {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        subscribe: (event: any, handler: (e: any) => void) => void;
    };
    private uniqueChecker: RepositoryUniqueChecker;
    private slugService: SlugService;

    constructor(deps: CollectionsServiceDeps) {
        this.collectionsRepository = deps.collectionsRepository;
        this.postsRepository = deps.postsRepository;
        this.DomainEvents = deps.DomainEvents;
        this.uniqueChecker = new RepositoryUniqueChecker(this.collectionsRepository);
        this.slugService = deps.slugService;
    }

    private async toDTO(collection: Collection, options?: {transaction: Knex.Transaction}): Promise<CollectionDTO> {
        const dto = {
            id: collection.id,
            title: collection.title,
            slug: collection.slug,
            description: collection.description || null,
            feature_image: collection.featureImage || null,
            type: collection.type,
            filter: collection.filter,
            created_at: collection.createdAt,
            updated_at: collection.updatedAt,
            posts: collection.posts.map((postId, index) => ({
                id: postId,
                sort_order: index
            }))
        };
        if (collection.slug === 'latest') {
            const allPostIds = await this.postsRepository.getAllIds(options);
            dto.posts = allPostIds.map((id, index) => ({
                id,
                sort_order: index
            }));
        }
        return dto;
    }

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    private fromDTO(data: any): any {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const mappedDTO: {[index: string]:any} = {
            title: data.title,
            slug: data.slug,
            description: data.description,
            featureImage: data.feature_image,
            filter: data.filter
        };

        // delete out keys that contain undefined values
        for (const key of Object.keys(mappedDTO)) {
            if (mappedDTO[key] === undefined) {
                delete mappedDTO[key];
            }
        }

        return mappedDTO;
    }

    /**
     * @description Subscribes to Domain events to update collections when posts are added, updated or deleted
     */
    subscribeToEvents() {
        // @NOTE: event handling should be moved to the client - Ghost app
        //        Leaving commented out handlers here to move them all together

        // this.DomainEvents.subscribe(PostDeletedEvent, async (event: PostDeletedEvent) => {
        //     logging.info(`PostDeletedEvent received, removing post ${event.id} from all collections`);
        //     try {
        //         await this.removePostFromAllCollections(event.id);
        //         /* c8 ignore next 3 */
        //     } catch (err) {
        //         logging.error({err, message: 'Error handling PostDeletedEvent'});
        //     }
        // });

        this.DomainEvents.subscribe(PostAddedEvent, async (event: PostAddedEvent) => {
            logging.info(`PostAddedEvent received, adding post ${event.data.id} to matching collections`);
            try {
                await this.addPostToMatchingCollections(event.data);
                /* c8 ignore next 3 */
            } catch (err) {
                logging.error({err, message: 'Error handling PostAddedEvent'});
            }
        });

        this.DomainEvents.subscribe(PostEditedEvent, async (event: PostEditedEvent) => {
            if (this.hasPostEditRelevantChanges(event.data) === false) {
                return;
            }

            logging.info(`PostEditedEvent received, updating post ${event.data.id} in matching collections`);
            try {
                await this.updatePostInMatchingCollections(event.data);
                /* c8 ignore next 3 */
            } catch (err) {
                logging.error({err, message: 'Error handling PostEditedEvent'});
            }
        });

        // this.DomainEvents.subscribe(PostsBulkDestroyedEvent, async (event: PostsBulkDestroyedEvent) => {
        //     logging.info(`BulkDestroyEvent received, removing posts ${event.data} from all collections`);
        //     try {
        //         await this.removePostsFromAllCollections(event.data);
        //         /* c8 ignore next 3 */
        //     } catch (err) {
        //         logging.error({err, message: 'Error handling PostsBulkDestroyedEvent'});
        //     }
        // });

        this.DomainEvents.subscribe(PostsBulkUnpublishedEvent, async (event: PostsBulkUnpublishedEvent) => {
            logging.info(`PostsBulkUnpublishedEvent received, updating collection posts ${event.data}`);
            try {
                await this.updateUnpublishedPosts(event.data);
                /* c8 ignore next 3 */
            } catch (err) {
                logging.error({err, message: 'Error handling PostsBulkUnpublishedEvent'});
            }
        });

        this.DomainEvents.subscribe(PostsBulkFeaturedEvent, async (event: PostsBulkFeaturedEvent) => {
            logging.info(`PostsBulkFeaturedEvent received, updating collection posts ${event.data}`);
            try {
                await this.updateFeaturedPosts(event.data);
                /* c8 ignore next 3 */
            } catch (err) {
                logging.error({err, message: 'Error handling PostsBulkFeaturedEvent'});
            }
        });

        this.DomainEvents.subscribe(PostsBulkUnfeaturedEvent, async (event: PostsBulkUnfeaturedEvent) => {
            logging.info(`PostsBulkUnfeaturedEvent received, updating collection posts ${event.data}`);
            try {
                await this.updateFeaturedPosts(event.data);
                /* c8 ignore next 3 */
            } catch (err) {
                logging.error({err, message: 'Error handling PostsBulkUnfeaturedEvent'});
            }
        });

        this.DomainEvents.subscribe(TagDeletedEvent, async (event: TagDeletedEvent) => {
            logging.info(`TagDeletedEvent received for ${event.data.id}, updating all collections`);
            try {
                await this.updateAllAutomaticCollections();
                /* c8 ignore next 3 */
            } catch (err) {
                logging.error({err, message: 'Error handling TagDeletedEvent'});
            }
        });

        this.DomainEvents.subscribe(PostsBulkAddTagsEvent, async (event: PostsBulkAddTagsEvent) => {
            logging.info(`PostsBulkAddTagsEvent received for ${event.data}, updating all collections`);
            try {
                await this.updateAllAutomaticCollections();
                /* c8 ignore next 3 */
            } catch (err) {
                logging.error({err, message: 'Error handling PostsBulkAddTagsEvent'});
            }
        });
    }

    private hasPostEditRelevantChanges(postEditEvent: PostEditedEvent['data']): boolean {
        const current = {
            id: postEditEvent.current.id,
            featured: postEditEvent.current.featured,
            published_at: postEditEvent.current.published_at,
            tags: postEditEvent.current.tags
        };
        const previous = {
            id: postEditEvent.previous.id,
            featured: postEditEvent.previous.featured,
            published_at: postEditEvent.previous.published_at,
            tags: postEditEvent.previous.tags
        };

        return !isEqual(current, previous);
    }

    async updateAllAutomaticCollections(): Promise<void> {
        return await this.collectionsRepository.createTransaction(async (transaction) => {
            const collections = await this.collectionsRepository.getAll({
                transaction
            });

            for (const collection of collections) {
                if (collection.type === 'automatic' && collection.filter) {
                    collection.removeAllPosts();

                    const posts = await this.postsRepository.getAll({
                        filter: collection.filter,
                        transaction
                    });

                    for (const post of posts) {
                        collection.addPost(post);
                    }

                    await this.collectionsRepository.save(collection, {transaction});
                }
            }
        });
    }

    async createCollection(data: CollectionInputDTO): Promise<CollectionDTO> {
        return await this.collectionsRepository.createTransaction(async (transaction) => {
            const slug = await this.slugService.generate(data.slug || data.title, {transaction});
            const collection = await Collection.create({
                title: data.title,
                slug: slug,
                description: data.description,
                type: data.type,
                filter: data.filter,
                featureImage: data.feature_image,
                deletable: data.deletable
            });

            if (collection.type === 'automatic' && collection.filter) {
                const posts = await this.postsRepository.getAll({
                    filter: collection.filter,
                    transaction: transaction
                });

                for (const post of posts) {
                    await collection.addPost(post);
                }
            }

            await this.collectionsRepository.save(collection, {transaction});

            return this.toDTO(collection);
        });
    }

    async addPostToCollection(collectionId: string, post: CollectionPostListItemDTO): Promise<CollectionDTO | null> {
        return await this.collectionsRepository.createTransaction(async (transaction) => {
            const collection = await this.collectionsRepository.getById(collectionId, {transaction});

            if (!collection) {
                return null;
            }

            await collection.addPost(post);

            await this.collectionsRepository.save(collection, {transaction});

            return this.toDTO(collection);
        });
    }

    async removePostFromAllCollections(postId: string) {
        return await this.collectionsRepository.createTransaction(async (transaction) => {
            // @NOTE: can be optimized by having a "getByPostId" method on the collections repository
            const collections = await this.collectionsRepository.getAll({transaction});

            for (const collection of collections) {
                if (collection.includesPost(postId)) {
                    collection.removePost(postId);
                    await this.collectionsRepository.save(collection, {transaction});
                }
            }
        });
    }

    async removePostsFromAllCollections(postIds: string[]) {
        return await this.collectionsRepository.createTransaction(async (transaction) => {
            const collections = await this.collectionsRepository.getAll({transaction});

            for (const collection of collections) {
                for (const postId of postIds) {
                    if (collection.includesPost(postId)) {
                        collection.removePost(postId);
                    }
                }
                await this.collectionsRepository.save(collection, {transaction});
            }
        });
    }

    private async addPostToMatchingCollections(post: CollectionPost) {
        return await this.collectionsRepository.createTransaction(async (transaction) => {
            const collections = await this.collectionsRepository.getAll({
                filter: 'type:automatic',
                transaction: transaction
            });

            for (const collection of collections) {
                const added = await collection.addPost(post);

                if (added) {
                    await this.collectionsRepository.save(collection, {transaction});
                }
            }
        });
    }

    async updatePostInMatchingCollections(postEdit: PostEditedEvent['data']) {
        return await this.collectionsRepository.createTransaction(async (transaction) => {
            const collections = await this.collectionsRepository.getAll({
                filter: 'type:automatic+slug:-latest',
                transaction
            });

            let collectionsChangeLog = '';
            for (const collection of collections) {
                if (collection.includesPost(postEdit.id) && !collection.postMatchesFilter(postEdit.current)) {
                    collection.removePost(postEdit.id);
                    await this.collectionsRepository.save(collection, {transaction});

                    collectionsChangeLog += `Post ${postEdit.id} was updated and removed from collection ${collection.slug} with filter ${collection.filter} \n`;
                } else if (!collection.includesPost(postEdit.id) && collection.postMatchesFilter(postEdit.current)) {
                    const added = await collection.addPost(postEdit.current);

                    if (added) {
                        await this.collectionsRepository.save(collection, {transaction});
                    }

                    collectionsChangeLog += `Post ${postEdit.id} was updated and added to collection ${collection.slug} with filter ${collection.filter}\n`;
                } else {
                    debug(`Post ${postEdit.id} was updated but did not update any collections`);
                }
            }

            if (collectionsChangeLog.length > 0) {
                logging.info(collectionsChangeLog);
            }
        });
    }

    async updateUnpublishedPosts(postIds: string[]) {
        return await this.collectionsRepository.createTransaction(async (transaction) => {
            let collections = await this.collectionsRepository.getAll({
                filter: 'type:automatic+slug:-latest+slug:-featured',
                transaction
            });

            // only process collections that have a filter that includes published_at
            collections = collections.filter(collection => collection.filter?.includes('published_at'));

            if (!collections.length) {
                return;
            }

            await this.updatePostsInCollections(postIds, collections, transaction);
        });
    }

    async updateFeaturedPosts(postIds: string[]) {
        return await this.collectionsRepository.createTransaction(async (transaction) => {
            let collections = await this.collectionsRepository.getAll({
                filter: 'type:automatic+slug:-latest',
                transaction
            });

            // only process collections that have a filter that includes featured
            collections = collections.filter(collection => collection.filter?.includes('featured'));

            if (!collections.length) {
                return;
            }

            await this.updatePostsInCollections(postIds, collections, transaction);
        });
    }

    async updatePostsInCollections(postIds: string[], collections: Collection[], transaction: Knex.Transaction) {
        const posts = await this.postsRepository.getAll({
            filter: `id:[${postIds.join(',')}]`,
            transaction: transaction
        });

        let collectionsChangeLog = '';
        for (const collection of collections) {
            let addedPostsCount = 0;
            let removedPostsCount = 0;

            for (const post of posts) {
                if (collection.includesPost(post.id) && !collection.postMatchesFilter(post)) {
                    collection.removePost(post.id);
                    removedPostsCount += 1;
                    debug(`Post ${post.id} was updated and removed from collection ${collection.id} with filter ${collection.filter}`);
                } else if (!collection.includesPost(post.id) && collection.postMatchesFilter(post)) {
                    await collection.addPost(post);
                    addedPostsCount += 1;
                    debug(`Post ${post.id} was unpublished and added to collection ${collection.id} with filter ${collection.filter}`);
                }
            }

            collectionsChangeLog += `Collection ${collection.slug} was updated with total ${posts.length} posts, added: ${addedPostsCount}, removed: ${removedPostsCount} \n`;
            await this.collectionsRepository.save(collection, {transaction});
        }

        if (collectionsChangeLog.length > 0) {
            logging.info(collectionsChangeLog);
        }
    }

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    async edit(data: any): Promise<CollectionDTO | null> {
        return await this.collectionsRepository.createTransaction(async (transaction) => {
            const collection = await this.collectionsRepository.getById(data.id, {transaction});

            if (!collection) {
                return null;
            }

            const collectionData = this.fromDTO(data);

            if (collectionData.title) {
                collection.title = collectionData.title;
            }

            if (data.slug !== undefined) {
                await collection.setSlug(data.slug, this.uniqueChecker);
            }

            if (data.description !== undefined) {
                collection.description = data.description;
            }

            if (data.filter !== undefined) {
                collection.filter = data.filter;
            }

            if (data.feature_image !== undefined) {
                collection.featureImage = data.feature_image;
            }

            if (collection.type === 'manual' && data.posts) {
                for (const post of data.posts) {
                    await collection.addPost(post);
                }
            }

            if (collection.type === 'automatic' && data.filter) {
                const posts = await this.postsRepository.getAll({
                    filter: data.filter,
                    transaction
                });

                collection.removeAllPosts();

                for (const post of posts) {
                    await collection.addPost(post);
                }
            }

            await this.collectionsRepository.save(collection, {transaction});

            return this.toDTO(collection);
        });
    }

    async getById(id: string, options?: {transaction: Knex.Transaction}): Promise<CollectionDTO | null> {
        const collection = await this.collectionsRepository.getById(id, options);
        if (!collection) {
            return null;
        }
        return this.toDTO(collection, options);
    }

    async getBySlug(slug: string, options?: {transaction: Knex.Transaction}): Promise<CollectionDTO | null> {
        const collection = await this.collectionsRepository.getBySlug(slug, options);
        if (!collection) {
            return null;
        }
        return this.toDTO(collection, options);
    }

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    async getAll(options?: QueryOptions): Promise<{data: CollectionDTO[], meta: any}> {
        const collections = await this.collectionsRepository.getAll(options);

        const collectionsDTOs: CollectionDTO[] = await Promise.all(
            collections.map(collection => this.toDTO(collection))
        );

        return {
            data: collectionsDTOs,
            meta: {
                pagination: {
                    page: 1,
                    pages: 1,
                    limit: collections.length,
                    total: collections.length,
                    prev: null,
                    next: null
                }
            }
        };
    }
    async getCollectionsForPost(postId: string): Promise<CollectionDTO[]> {
        const collections = await this.collectionsRepository.getAll({
            filter: `posts:'${postId}',slug:latest`
        });

        return Promise.all(collections.sort((a, b) => {
            // NOTE: sorting is here to keep DB engine ordering consistent
            return a.slug.localeCompare(b.slug);
        }).map(collection => this.toDTO(collection)));
    }

    async destroy(id: string): Promise<Collection | null> {
        const collection = await this.collectionsRepository.getById(id);

        if (collection) {
            if (collection.deletable === false) {
                throw new MethodNotAllowedError({
                    message: tpl(messages.cannotDeleteBuiltInCollectionError.message),
                    context: tpl(messages.cannotDeleteBuiltInCollectionError.context, {
                        id: collection.id
                    })
                });
            }

            collection.deleted = true;
            await this.collectionsRepository.save(collection);
        }

        return collection;
    }

    async removePostFromCollection(id: string, postId: string): Promise<CollectionDTO | null> {
        return await this.collectionsRepository.createTransaction(async (transaction) => {
            const collection = await this.collectionsRepository.getById(id, {transaction});

            if (!collection) {
                return null;
            }

            if (collection) {
                collection.removePost(postId);
                await this.collectionsRepository.save(collection, {transaction});
            }

            return this.toDTO(collection);
        });
    }
}