diff --git a/ghost/admin/app/components/settings/history/table.hbs b/ghost/admin/app/components/settings/history/table.hbs index 0ea2f2e9c3..1fe100a864 100644 --- a/ghost/admin/app/components/settings/history/table.hbs +++ b/ghost/admin/app/components/settings/history/table.hbs @@ -20,32 +20,34 @@
- {{capitalize-first-letter ev.action}}: + {{capitalize-first-letter ev.action}}{{#unless ev.isBulkAction}}:{{/unless}} - {{#if ev.contextResource}} - - {{capitalize-first-letter ev.contextResource.first}} - {{#if (not-eq ev.contextResource.first ev.contextResource.second)}} - ({{ev.contextResource.second}}) - {{/if}} - - {{else if (or ev.original.resource.title ev.original.resource.name ev.original.context.primary_name)}} - {{#if ev.linkTarget}} - {{#if ev.linkTarget.models}} - - {{or ev.original.resource.title ev.original.resource.name}} - + {{#unless ev.isBulkAction}} + {{#if ev.contextResource}} + + {{capitalize-first-letter ev.contextResource.first}} + {{#if (not-eq ev.contextResource.first ev.contextResource.second)}} + ({{ev.contextResource.second}}) + {{/if}} + + {{else if (or ev.original.resource.title ev.original.resource.name ev.original.context.primary_name)}} + {{#if ev.linkTarget}} + {{#if ev.linkTarget.models}} + + {{or ev.original.resource.title ev.original.resource.name}} + + {{else}} + + {{or ev.original.resource.title ev.original.resource.name}} + + {{/if}} {{else}} - - {{or ev.original.resource.title ev.original.resource.name}} - + {{or ev.original.resource.title ev.original.resource.name ev.original.context.primary_name}} {{/if}} {{else}} - {{or ev.original.resource.title ev.original.resource.name ev.original.context.primary_name}} + (unknown) {{/if}} - {{else}} - (unknown) - {{/if}} + {{/unless}} – by diff --git a/ghost/admin/app/helpers/parse-history-event.js b/ghost/admin/app/helpers/parse-history-event.js index 158f4665c1..af887eea93 100644 --- a/ghost/admin/app/helpers/parse-history-event.js +++ b/ghost/admin/app/helpers/parse-history-event.js @@ -24,7 +24,8 @@ export default class ParseHistoryEvent extends Helper { actor, actorIcon, actorLinkTarget, - original: ev + original: ev, + isBulkAction: !!ev.context.count }; } } @@ -88,7 +89,7 @@ function getLinkTarget(ev) { switch (ev.resource_type) { case 'page': case 'post': - if (!ev.resource.id) { + if (!ev.resource || !ev.resource.id) { return null; } @@ -103,7 +104,7 @@ function getLinkTarget(ev) { models: [resourceType, ev.resource.id] }; case 'integration': - if (!ev.resource.id) { + if (!ev.resource || !ev.resource.id) { return null; } @@ -112,7 +113,7 @@ function getLinkTarget(ev) { models: [ev.resource.id] }; case 'offer': - if (!ev.resource.id) { + if (!ev.resource || !ev.resource.id) { return null; } @@ -121,7 +122,7 @@ function getLinkTarget(ev) { models: [ev.resource.id] }; case 'tag': - if (!ev.resource.slug) { + if (!ev.resource || !ev.resource.slug) { return null; } @@ -135,7 +136,7 @@ function getLinkTarget(ev) { models: null }; case 'user': - if (!ev.resource.slug) { + if (!ev.resource || !ev.resource.slug) { return null; } @@ -181,7 +182,19 @@ function getAction(ev) { } } - return `${resourceType} ${ev.event}`; + let action = ev.event; + + if (ev.event === 'edited') { + if (ev.context.action_name) { + action = ev.context.action_name; + } + } + + if (ev.context.count && ev.context.count > 1) { + return `${ev.context.count} ${resourceType}s ${action}`; + } + + return `${resourceType} ${action}`; } function getContextResource(ev) { diff --git a/ghost/core/core/server/models/base/plugins/actions.js b/ghost/core/core/server/models/base/plugins/actions.js index 6e84b42999..c108a7820f 100644 --- a/ghost/core/core/server/models/base/plugins/actions.js +++ b/ghost/core/core/server/models/base/plugins/actions.js @@ -6,6 +6,54 @@ const logging = require('@tryghost/logging'); * @param {import('bookshelf')} Bookshelf */ module.exports = function (Bookshelf) { + const insertAction = (data, options) => { + // CASE: model does not support action for target event + if (!data) { + return; + } + + const insert = (action) => { + Bookshelf.model('Action') + .add(action, {autoRefresh: false}) + .catch((err) => { + if (_.isArray(err)) { + err = err[0]; + } + + logging.error(new errors.InternalServerError({ + err + })); + }); + }; + + if (options.transacting) { + options.transacting.once('committed', (committed) => { + if (!committed) { + return; + } + + insert(data); + }); + } else { + insert(data); + } + }; + + // We need this addAction accessible from the static model and instances + const addAction = (model, event, options) => { + if (!model.wasChanged()) { + return; + } + + // CASE: model does not support actions at all + if (!model.getAction) { + return; + } + + const data = model.getAction(event, options); + insertAction(data, options); + }; + Bookshelf.Model = Bookshelf.Model.extend({ /** * Constructs data to be stored in the database with info @@ -33,7 +81,9 @@ module.exports = function (Bookshelf) { return; } - let context = {}; + let context = { + action_name: options.actionName + }; if (this.actionsExtraContext && Array.isArray(this.actionsExtraContext)) { for (const c of this.actionsExtraContext) { @@ -74,48 +124,73 @@ module.exports = function (Bookshelf) { * * We could embed adding actions more nicely in the future e.g. plugin. */ - addAction: (model, event, options) => { - if (!model.wasChanged()) { + addAction + }, { + addAction, + async addActions(event, ids, options) { + if (ids.length === 1) { + // We want to store an event for a single model in the actions table + // This is so we can include the name + const model = await this.findOne({[options.column ?? 'id']: ids[0]}, {require: true, transacting: options.transacting, context: {internal: true}}); + this.addAction(model, event, options); return; } - // CASE: model does not support actions at all - if (!model.getAction) { + const existingAction = this.getBulkAction(event, ids.length, options); + insertAction(existingAction, options); + }, + + /** + * Constructs data to be stored in the database with info + * on particular actions + */ + getBulkAction(event, count, options) { + const actor = this.prototype.getActor(options); + + // @NOTE: we ignore internal updates (`options.context.internal`) for now + if (!actor) { return; } - const existingAction = model.getAction(event, options); - - // CASE: model does not support action for target event - if (!existingAction) { + if (!this.prototype.actionsCollectCRUD) { return; } - const insert = (action) => { - Bookshelf.model('Action') - .add(action, {autoRefresh: false}) - .catch((err) => { - if (_.isArray(err)) { - err = err[0]; - } + let resourceType = this.prototype.actionsResourceType; - logging.error(new errors.InternalServerError({ - err - })); - }); + if (typeof resourceType === 'function') { + resourceType = resourceType.bind(this)(); + } + + if (!resourceType) { + return; + } + + let context = { + count, + action_name: options.actionName }; - if (options.transacting) { - options.transacting.once('committed', (committed) => { - if (!committed) { - return; - } - - insert(existingAction); - }); - } else { - insert(existingAction); + if (this.getBulkActionExtraContext && typeof this.getBulkActionExtraContext === 'function') { + context = { + ...context, + ...this.getBulkActionExtraContext.bind(this)(options) + }; } + + const data = { + event, + resource_id: null, + resource_type: resourceType, + actor_id: actor.id, + actor_type: actor.type + }; + + if (context && Object.keys(context).length) { + data.context = context; + } + + return data; } }); }; diff --git a/ghost/core/core/server/models/base/plugins/bulk-operations.js b/ghost/core/core/server/models/base/plugins/bulk-operations.js index 644569acc6..ef3cd60edd 100644 --- a/ghost/core/core/server/models/base/plugins/bulk-operations.js +++ b/ghost/core/core/server/models/base/plugins/bulk-operations.js @@ -60,7 +60,7 @@ async function editSingle(knex, table, id, options) { if (options.transacting) { k = k.transacting(options.transacting); } - await k.where('id', id).update(options.data); + await k.where(options.column ?? 'id', id).update(options.data); } async function editMultiple(knex, table, chunk, options) { @@ -68,7 +68,7 @@ async function editMultiple(knex, table, chunk, options) { if (options.transacting) { k = k.transacting(options.transacting); } - await k.whereIn('id', chunk).update(options.data); + await k.whereIn(options.column ?? 'id', chunk).update(options.data); } async function delSingle(knex, table, id, options) { @@ -112,23 +112,44 @@ module.exports = function (Bookshelf) { return insert(Bookshelf.knex, tableName, data, options); }, - bulkEdit: async function bulkEdit(data, tableName, options = {}) { + /** + * + * @param {*} ids + * @param {*} tableName + * @param {object} options + * @param {object} [options.data] Data change you want to apply to the rows + * @param {string} [options.column] Update the rows where this column equals the ids (defaults to 'id') + * @returns + */ + bulkEdit: async function bulkEdit(ids, tableName, options = {}) { tableName = tableName || this.prototype.tableName; - return await edit(Bookshelf.knex, tableName, data, options); + const result = await edit(Bookshelf.knex, tableName, ids, options); + + if (result.successful > 0 && tableName === this.prototype.tableName) { + await this.addActions('edited', ids, options); + } + + return result; }, /** * - * @param {string[]} data List of ids to delete + * @param {string[]} ids List of ids to delete * @param {*} tableName * @param {Object} [options] * @param {string} [options.column] Delete the rows where this column equals the ids in `data` (defaults to 'id') * @returns */ - bulkDestroy: async function bulkDestroy(data, tableName, options = {}) { + bulkDestroy: async function bulkDestroy(ids, tableName, options = {}) { tableName = tableName || this.prototype.tableName; - return await del(Bookshelf.knex, tableName, data, options); + + if (tableName === this.prototype.tableName) { + // Needs to happen before, otherwise we cannot fetch the names of the deleted items + await this.addActions('deleted', ids, options); + } + + return await del(Bookshelf.knex, tableName, ids, options); } }); }; diff --git a/ghost/core/core/server/models/post.js b/ghost/core/core/server/models/post.js index 7d19b6f719..d6fa6f4d9b 100644 --- a/ghost/core/core/server/models/post.js +++ b/ghost/core/core/server/models/post.js @@ -1129,6 +1129,16 @@ Post = ghostBookshelf.Model.extend({ return filter; } }, { + getBulkActionExtraContext: function (options) { + if (options && options.filter && options.filter.includes('type:page')) { + return { + type: 'page' + }; + } + return { + type: 'post' + }; + }, allowedFormats: ['mobiledoc', 'lexical', 'html', 'plaintext'], orderDefaultOptions: function orderDefaultOptions() { @@ -1236,9 +1246,11 @@ Post = ghostBookshelf.Model.extend({ * **See:** [ghostBookshelf.Model.findOne](base.js.html#Find%20One) */ findOne: function findOne(data = {}, options = {}) { - // @TODO: remove when we drop v0.1 - if (!options.filter && !data.status) { - data.status = 'published'; + if (!options.context || !options.context.internal) { + // @TODO: remove when we drop v0.1 + if (!options.filter && !data.status) { + data.status = 'published'; + } } if (data.status === 'all') { diff --git a/ghost/posts-service/lib/PostsService.js b/ghost/posts-service/lib/PostsService.js index 474da1cf1e..f48296c0c5 100644 --- a/ghost/posts-service/lib/PostsService.js +++ b/ghost/posts-service/lib/PostsService.js @@ -70,13 +70,13 @@ class PostsService { async bulkEdit(data, options) { if (data.action === 'unpublish') { - return await this.#updatePosts({status: 'draft'}, {filter: this.#mergeFilters('status:published', options.filter)}); + return await this.#updatePosts({status: 'draft'}, {filter: this.#mergeFilters('status:published', options.filter), context: options.context, actionName: 'unpublished'}); } if (data.action === 'feature') { - return await this.#updatePosts({featured: true}, {filter: options.filter}); + return await this.#updatePosts({featured: true}, {filter: options.filter, context: options.context, actionName: 'featured'}); } if (data.action === 'unfeature') { - return await this.#updatePosts({featured: false}, {filter: options.filter}); + return await this.#updatePosts({featured: false}, {filter: options.filter, context: options.context, actionName: 'unfeatured'}); } if (data.action === 'access') { if (!['public', 'members', 'paid', 'tiers'].includes(data.meta.visibility)) { @@ -93,7 +93,7 @@ class PostsService { } tiers = data.meta.tiers; } - return await this.#updatePosts({visibility: data.meta.visibility, tiers}, {filter: options.filter}); + return await this.#updatePosts({visibility: data.meta.visibility, tiers}, {filter: options.filter, context: options.context}); } if (data.action === 'addTag') { if (!Array.isArray(data.meta.tags)) { @@ -113,7 +113,7 @@ class PostsService { }); } } - return await this.#bulkAddTags({tags: data.meta.tags}, {filter: options.filter}); + return await this.#bulkAddTags({tags: data.meta.tags}, {filter: options.filter, context: options.context}); } throw new errors.IncorrectUsageError({ message: tpl(messages.unsupportedBulkAction) @@ -125,6 +125,8 @@ class PostsService { * @param {string[]} data.tags - Array of tag ids to add to the post * @param {object} options * @param {string} options.filter - An NQL Filter + * @param {object} options.context + * @param {object} [options.transacting] */ async #bulkAddTags(data, options) { if (!options.transacting) { @@ -139,7 +141,7 @@ class PostsService { // Create tags that don't exist for (const tag of data.tags) { if (!tag.id) { - const createdTag = await this.models.Tag.add(tag, {transacting: options.transacting}); + const createdTag = await this.models.Tag.add(tag, {transacting: options.transacting, context: options.context}); tag.id = createdTag.id; } } @@ -162,6 +164,7 @@ class PostsService { }, []); await options.transacting('posts_tags').insert(postTags); + await this.models.Post.addActions('edited', postRows.map(p => p.id), options); return { successful: postRows.length, @@ -236,7 +239,7 @@ class PostsService { // Posts and emails await this.models.Post.bulkDestroy(deleteEmailIds, 'emails', {transacting: options.transacting, throwErrors: true}); - return await this.models.Post.bulkDestroy(deleteIds, 'posts', {transacting: options.transacting, throwErrors: true}); + return await this.models.Post.bulkDestroy(deleteIds, 'posts', {...options, throwErrors: true}); } async export(frame) { @@ -268,8 +271,8 @@ class PostsService { } const result = await this.models.Post.bulkEdit(editIds, 'posts', { + ...options, data, - transacting: options.transacting, throwErrors: true });