diff --git a/ghost/collections/src/CollectionsService.ts b/ghost/collections/src/CollectionsService.ts index a126836c0f..8aa8f8cdbb 100644 --- a/ghost/collections/src/CollectionsService.ts +++ b/ghost/collections/src/CollectionsService.ts @@ -4,7 +4,8 @@ import {Knex} from "knex"; import { PostsBulkUnpublishedEvent, PostsBulkFeaturedEvent, - PostsBulkUnfeaturedEvent + PostsBulkUnfeaturedEvent, + PostsBulkAddTagsEvent } from "@tryghost/post-events"; import {Collection} from './Collection'; import {CollectionRepository} from './CollectionRepository'; @@ -207,6 +208,11 @@ export class CollectionsService { logging.info(`TagDeletedEvent received for ${event.data.id}, updating all collections`); await this.updateAllAutomaticCollections(); }); + + this.DomainEvents.subscribe(PostsBulkAddTagsEvent, async (event: PostsBulkAddTagsEvent) => { + logging.info(`PostsBulkAddTagsEvent received for ${event.data}, updating all collections`); + await this.updateAllAutomaticCollections(); + }); } async updateAllAutomaticCollections(): Promise { diff --git a/ghost/collections/test/collections.test.ts b/ghost/collections/test/collections.test.ts index 9c2e664d0d..374addad1f 100644 --- a/ghost/collections/test/collections.test.ts +++ b/ghost/collections/test/collections.test.ts @@ -12,7 +12,8 @@ import { PostsBulkDestroyedEvent, PostsBulkUnpublishedEvent, PostsBulkFeaturedEvent, - PostsBulkUnfeaturedEvent + PostsBulkUnfeaturedEvent, + PostsBulkAddTagsEvent } from '@tryghost/post-events'; import {PostsRepositoryInMemory} from './fixtures/PostsRepositoryInMemory'; import {posts as postFixtures} from './fixtures/posts'; @@ -392,6 +393,81 @@ describe('CollectionsService', function () { assert.equal((await collectionsService.getById(automaticCollectionWithoutTag.id))?.posts.length, 2); }); + it('Updates all collections when post tags are added in bulk', async function () { + const collectionsRepository = new CollectionsRepositoryInMemory(); + postsRepository = await initPostsRepository([ + { + id: 'post-1', + url: 'http://localhost:2368/post-1/', + title: 'Post 1', + slug: 'post-1', + featured: false, + tags: [{slug: 'existing-tag'}], + created_at: new Date('2023-03-15T07:19:07.447Z'), + updated_at: new Date('2023-03-15T07:19:07.447Z'), + published_at: new Date('2023-03-15T07:19:07.447Z') + }, { + id: 'post-2', + url: 'http://localhost:2368/post-2/', + title: 'Post 2', + slug: 'post-2', + featured: false, + tags: [], + created_at: new Date('2023-04-05T07:20:07.447Z'), + updated_at: new Date('2023-04-05T07:20:07.447Z'), + published_at: new Date('2023-04-05T07:20:07.447Z') + } + ]); + + collectionsService = new CollectionsService({ + collectionsRepository, + postsRepository, + DomainEvents, + slugService: { + async generate(input) { + return input.replace(/\s+/g, '-').toLowerCase(); + } + } + }); + + const automaticCollectionWithExistingTag = await collectionsService.createCollection({ + title: 'Automatic Collection with Tag', + description: 'testing automatic collection with tag', + type: 'automatic', + filter: 'tags:existing-tag' + }); + + const automaticCollectionWithBulkAddedTag = await collectionsService.createCollection({ + title: 'Automatic Collection without Tag', + description: 'testing automatic collection without tag', + type: 'automatic', + filter: 'tags:to-be-created' + }); + + assert.equal((await collectionsService.getById(automaticCollectionWithExistingTag.id))?.posts.length, 1); + assert.equal((await collectionsService.getById(automaticCollectionWithBulkAddedTag.id))?.posts.length, 0); + + collectionsService.subscribeToEvents(); + + const posts = await postsRepository.getAll(); + + for (const post of posts) { + post.tags.push({slug: 'to-be-created'}); + await postsRepository.save(post); + } + + const postsBulkAddTagsEvent = PostsBulkAddTagsEvent.create([ + 'post-1', + 'post-2' + ]); + + DomainEvents.dispatch(postsBulkAddTagsEvent); + await DomainEvents.allSettled(); + + assert.equal((await collectionsService.getById(automaticCollectionWithExistingTag.id))?.posts.length, 1); + assert.equal((await collectionsService.getById(automaticCollectionWithBulkAddedTag.id))?.posts.length, 2); + }); + it('Updates all collections when post is deleted', async function () { assert.equal((await collectionsService.getById(automaticFeaturedCollection.id))?.posts?.length, 2); assert.equal((await collectionsService.getById(automaticNonFeaturedCollection.id))?.posts.length, 2); diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/collections.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/collections.test.js.snap index 9cd182cc8a..b8559c65d3 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/collections.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/collections.test.js.snap @@ -1365,6 +1365,120 @@ Object { } `; +exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk 1: [body] 1`] = ` +Object { + "collections": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feature_image": null, + "filter": "tags:['papaya']", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "slug": "papaya-madness", + "title": "Papaya madness", + "type": "automatic", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], +} +`; + +exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk 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": "266", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/collections\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk 3: [body] 1`] = ` +Object { + "collections": Array [ + Object { + "count": Object { + "posts": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feature_image": null, + "filter": "tags:['papaya']", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "slug": "papaya-madness", + "title": "Papaya madness", + "type": "automatic", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], +} +`; + +exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk 4: [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": "286", + "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[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk 5: [body] 1`] = ` +Object { + "bulk": Object { + "meta": Object { + "stats": Object { + "successful": 11, + "unsuccessful": 0, + }, + }, + }, +} +`; + +exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk 6: [body] 1`] = ` +Object { + "collections": Array [ + Object { + "count": Object { + "posts": 11, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feature_image": null, + "filter": "tags:['papaya']", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "slug": "papaya-madness", + "title": "Papaya madness", + "type": "automatic", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], +} +`; + +exports[`Collections API Collection Posts updates automatically Updates a collection with tag filter when tag is added to posts in bulk 7: [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": "287", + "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[`Collections API Collection Posts updates automatically Updates collections when a Post is added/edited/deleted 1: [body] 1`] = ` Object { "collections": Array [ diff --git a/ghost/core/test/e2e-api/admin/collections.test.js b/ghost/core/test/e2e-api/admin/collections.test.js index 17e6e668f4..a9204c42e2 100644 --- a/ghost/core/test/e2e-api/admin/collections.test.js +++ b/ghost/core/test/e2e-api/admin/collections.test.js @@ -1,11 +1,11 @@ const assert = require('assert/strict'); +const DomainEvents = require('@tryghost/domain-events'); const { agentProvider, fixtureManager, mockManager, matchers } = require('../../utils/e2e-framework'); -const DomainEvents = require('@tryghost/domain-events/lib/DomainEvents'); const { anyContentVersion, anyEtag, @@ -583,5 +583,94 @@ describe('Collections API', function () { }] }); }); + + it('Updates a collection with tag filter when tag is added to posts in bulk', async function (){ + const collection = { + title: 'Papaya madness', + type: 'automatic', + filter: 'tags:[\'papaya\']' + }; + + const {body: {collections: [{id: collectionId}]}} = await agent + .post('/collections/') + .body({ + collections: [collection] + }) + .expectStatus(201) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + location: anyLocationFor('collections') + }) + .matchBodySnapshot({ + collections: [matchCollection] + }); + + // should contain no posts + await agent + .get(`/collections/${collectionId}/?include=count.posts`) + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + collections: [{ + ...matchCollection, + count: { + posts: 0 + } + }] + }); + + const tag = { + name: 'Papaya', + slug: 'papaya' + }; + + const {body: {tags: [{id: tagId}]}} = await agent + .post('/tags/') + .body({ + tags: [tag] + }) + .expectStatus(201); + + // add papaya tag to all posts + await agent + .put('/posts/bulk/?filter=' + encodeURIComponent('status:[published]')) + .body({ + bulk: { + action: 'addTag', + meta: { + tags: [ + { + id: tagId + } + ] + } + } + }) + .expectStatus(200) + .matchBodySnapshot(); + + await DomainEvents.allSettled(); + + // should contain published posts with papaya tags + await agent + .get(`/collections/${collectionId}/?include=count.posts`) + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + collections: [{ + ...matchCollection, + count: { + posts: 11 + } + }] + }); + }); }); }); diff --git a/ghost/post-events/src/PostsBulkAddTagsEvent.ts b/ghost/post-events/src/PostsBulkAddTagsEvent.ts new file mode 100644 index 0000000000..785a45c638 --- /dev/null +++ b/ghost/post-events/src/PostsBulkAddTagsEvent.ts @@ -0,0 +1,13 @@ +export class PostsBulkAddTagsEvent { + data: string[]; + timestamp: Date; + + constructor(data: string[], timestamp: Date) { + this.data = data; + this.timestamp = timestamp; + } + + static create(data: string[], timestamp = new Date()) { + return new PostsBulkAddTagsEvent(data, timestamp); + } +} diff --git a/ghost/post-events/src/index.ts b/ghost/post-events/src/index.ts index d5b4a3f933..1c6edcb8ef 100644 --- a/ghost/post-events/src/index.ts +++ b/ghost/post-events/src/index.ts @@ -2,3 +2,4 @@ export * from './PostsBulkDestroyedEvent'; export * from './PostsBulkUnpublishedEvent'; export * from './PostsBulkFeaturedEvent'; export * from './PostsBulkUnfeaturedEvent'; +export * from './PostsBulkAddTagsEvent'; diff --git a/ghost/post-events/test/post-events.test.ts b/ghost/post-events/test/post-events.test.ts index e819ca36c2..3457e65cc6 100644 --- a/ghost/post-events/test/post-events.test.ts +++ b/ghost/post-events/test/post-events.test.ts @@ -3,7 +3,8 @@ import { PostsBulkDestroyedEvent, PostsBulkUnpublishedEvent, PostsBulkFeaturedEvent, - PostsBulkUnfeaturedEvent + PostsBulkUnfeaturedEvent, + PostsBulkAddTagsEvent } from '../src/index'; describe('Post Events', function () { @@ -30,4 +31,10 @@ describe('Post Events', function () { assert.ok(event); assert.equal(event.data.length, 3); }); + + it('Can instantiate PostsBulkAddTagsEvent', function () { + const event = PostsBulkAddTagsEvent.create(['1', '2', '3']); + assert.ok(event); + assert.equal(event.data.length, 3); + }); }); diff --git a/ghost/posts-service/lib/PostsService.js b/ghost/posts-service/lib/PostsService.js index 4ad441a7ec..73de223a16 100644 --- a/ghost/posts-service/lib/PostsService.js +++ b/ghost/posts-service/lib/PostsService.js @@ -4,12 +4,13 @@ const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); const ObjectId = require('bson-objectid').default; const pick = require('lodash/pick'); -const DomainEvents = require('@tryghost/domain-events/lib/DomainEvents'); +const DomainEvents = require('@tryghost/domain-events'); const { PostsBulkDestroyedEvent, PostsBulkUnpublishedEvent, PostsBulkFeaturedEvent, - PostsBulkUnfeaturedEvent + PostsBulkUnfeaturedEvent, + PostsBulkAddTagsEvent } = require('@tryghost/post-events'); const messages = { @@ -319,6 +320,9 @@ class PostsService { await options.transacting('posts_tags').insert(postTags); await this.models.Post.addActions('edited', postRows.map(p => p.id), options); + const event = PostsBulkAddTagsEvent.create(postTags.map(pt => pt.post_id)); + DomainEvents.dispatch(event); + return { successful: postRows.length, unsuccessful: 0