diff --git a/ghost/collections/src/CollectionsService.ts b/ghost/collections/src/CollectionsService.ts index c5ebbce3a4..81fe595326 100644 --- a/ghost/collections/src/CollectionsService.ts +++ b/ghost/collections/src/CollectionsService.ts @@ -1,5 +1,9 @@ import logging from '@tryghost/logging'; import tpl from '@tryghost/tpl'; +import {Knex} from "knex"; +import { + PostsBulkUnpublishedEvent, +} from "@tryghost/post-events"; import {Collection} from './Collection'; import {CollectionRepository} from './CollectionRepository'; import {CollectionPost} from './CollectionPost'; @@ -183,6 +187,11 @@ export class CollectionsService { await this.removePostsFromAllCollections(event.data); }); + this.DomainEvents.subscribe(PostsBulkUnpublishedEvent, async (event: PostsBulkUnpublishedEvent) => { + logging.info(`PostsBulkUnpublishedEvent received, updating collection posts ${event.data}`); + await this.updateUnpublishedPosts(event.data); + }); + this.DomainEvents.subscribe(TagDeletedEvent, async (event: TagDeletedEvent) => { logging.info(`TagDeletedEvent received for ${event.data.id}, updating all collections`); await this.updateAllAutomaticCollections(); @@ -334,6 +343,43 @@ export class CollectionsService { }); } + 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; + } + + this.updatePostsInCollections(postIds, collections, transaction); + }); + } + + async updatePostsInCollections(postIds: string[], collections: Collection[], transaction: Knex.Transaction) { + const posts = await this.postsRepository.getBulk(postIds, transaction); + + + for (const collection of collections) { + for (const post of posts) { + if (collection.includesPost(post.id) && !collection.postMatchesFilter(post)) { + collection.removePost(post.id); + logging.info(`[Collections] 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); + logging.info(`[Collections] Post ${post.id} was unpublished and added to collection ${collection.id} with filter ${collection.filter}`); + } + } + + await this.collectionsRepository.save(collection, {transaction}); + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any async edit(data: any): Promise { return await this.collectionsRepository.createTransaction(async (transaction) => { diff --git a/ghost/collections/test/collections.test.ts b/ghost/collections/test/collections.test.ts index 8078ad34a8..88e9bfbccb 100644 --- a/ghost/collections/test/collections.test.ts +++ b/ghost/collections/test/collections.test.ts @@ -8,7 +8,10 @@ import { PostEditedEvent, TagDeletedEvent } from '../src/index'; -import {PostsBulkDestroyedEvent} from '@tryghost/post-events'; +import { + PostsBulkDestroyedEvent, + PostsBulkUnpublishedEvent +} from '@tryghost/post-events'; import {PostsRepositoryInMemory} from './fixtures/PostsRepositoryInMemory'; import {posts as postFixtures} from './fixtures/posts'; import {CollectionPost} from '../src/CollectionPost'; @@ -22,7 +25,7 @@ const initPostsRepository = (posts: any): PostsRepositoryInMemory => { title: post.title, slug: post.slug, featured: post.featured, - published_at: post.published_at, + published_at: post.published_at?.toISOString(), tags: post.tags, deleted: false }; @@ -411,8 +414,8 @@ describe('CollectionsService', function () { collectionsService.subscribeToEvents(); const postDeletedEvent = PostsBulkDestroyedEvent.create([ - posts[0].id, - posts[1].id + postFixtures[0].id, + postFixtures[1].id ]); DomainEvents.dispatch(postDeletedEvent); @@ -423,6 +426,39 @@ describe('CollectionsService', function () { assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 0); }); + it('Updates collections with publish filter when PostsBulkUnpublishedEvent event is produced', async function () { + const publishedPostsCollection = await collectionsService.createCollection({ + title: 'Published Posts', + slug: 'published-posts', + type: 'automatic', + filter: 'published_at:>=2023-05-00T00:00:00.000Z' + }); + + assert.equal((await collectionsService.getById(publishedPostsCollection.id))?.posts.length, 2, 'Only two post fixtures are published on the 5th month of 2023'); + + assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 2); + assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 2); + assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 2); + + collectionsService.subscribeToEvents(); + + postsRepository.save(Object.assign(postFixtures[2], { + published_at: null + })); + const postsBulkUnpublishedEvent = PostsBulkUnpublishedEvent.create([ + postFixtures[2].id + ]); + + DomainEvents.dispatch(postsBulkUnpublishedEvent); + await DomainEvents.allSettled(); + + assert.equal((await collectionsService.getById(publishedPostsCollection.id))?.posts.length, 1, 'Only one post left as published on the 5th month of 2023'); + + assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts.length, 2, 'There should be no change to the featured filter collection'); + assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 2, 'There should be no change to the non-featured filter collection'); + assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 2, 'There should be no change to the manual collection'); + }); + it('Updates only index collection when a non-featured post is added', async function () { assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 2); assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 2); @@ -442,7 +478,7 @@ describe('CollectionsService', function () { assert.equal((await collectionsService.getById(manualCollection.id))?.posts.length, 2); }); - it('Moves post form featured to non featured collection when the featured attribute is changed', async function () { + it('Moves post from featured to non featured collection when the featured attribute is changed', async function () { collectionsService.subscribeToEvents(); const newFeaturedPost: CollectionPost & {deleted: false} = { id: 'post-featured', diff --git a/ghost/collections/test/fixtures/PostsRepositoryInMemory.ts b/ghost/collections/test/fixtures/PostsRepositoryInMemory.ts index 944ff415e4..bd8b28f9eb 100644 --- a/ghost/collections/test/fixtures/PostsRepositoryInMemory.ts +++ b/ghost/collections/test/fixtures/PostsRepositoryInMemory.ts @@ -10,4 +10,10 @@ export class PostsRepositoryInMemory extends InMemoryRepository tag.slug) }; } + + async getBulk(ids: string[]) { + return this.getAll({ + filter: `id:[${ids.join(',')}]` + }); + } }