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
});