mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-19 08:31:43 +03:00
ab1ca90779
fixes https://github.com/TryGhost/Team/issues/2924 This change adds a new bulk edit action for posts to update their visibility. It also implements a modal to change the post access level for multiple posts at once using this new API. It also fixes a pattern that was used when modifying the Ember models in memory. They previously were marked as dirty, this is fixed now. So when going to the editor after modifying posts, you won't get a confirmation dialog any longer.
289 lines
10 KiB
JavaScript
289 lines
10 KiB
JavaScript
const nql = require('@tryghost/nql');
|
|
const {BadRequestError} = require('@tryghost/errors');
|
|
const tpl = require('@tryghost/tpl');
|
|
const errors = require('@tryghost/errors');
|
|
const ObjectId = require('bson-objectid').default;
|
|
|
|
const messages = {
|
|
invalidVisibilityFilter: 'Invalid visibility filter.',
|
|
invalidVisibility: 'Invalid visibility value.',
|
|
invalidTiers: 'Invalid tiers value.',
|
|
invalidEmailSegment: 'The email segment parameter doesn\'t contain a valid filter',
|
|
unsupportedBulkAction: 'Unsupported bulk action'
|
|
};
|
|
|
|
class PostsService {
|
|
constructor({urlUtils, models, isSet, stats, emailService, postsExporter}) {
|
|
this.urlUtils = urlUtils;
|
|
this.models = models;
|
|
this.isSet = isSet;
|
|
this.stats = stats;
|
|
this.emailService = emailService;
|
|
this.postsExporter = postsExporter;
|
|
}
|
|
|
|
async editPost(frame) {
|
|
// Make sure the newsletter is matching an active newsletter
|
|
// Note that this option is simply ignored if the post isn't published or scheduled
|
|
if (frame.options.newsletter && frame.options.email_segment) {
|
|
if (frame.options.email_segment !== 'all') {
|
|
// check filter is valid
|
|
try {
|
|
await this.models.Member.findPage({filter: frame.options.email_segment, limit: 1});
|
|
} catch (err) {
|
|
return Promise.reject(new BadRequestError({
|
|
message: tpl(messages.invalidEmailSegment),
|
|
context: err.message
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
const model = await this.models.Post.edit(frame.data.posts[0], frame.options);
|
|
|
|
/**Handle newsletter email */
|
|
if (model.get('newsletter_id')) {
|
|
const sendEmail = model.wasChanged() && this.shouldSendEmail(model.get('status'), model.previous('status'));
|
|
|
|
if (sendEmail) {
|
|
let postEmail = model.relations.email;
|
|
let email;
|
|
|
|
if (!postEmail) {
|
|
email = await this.emailService.createEmail(model);
|
|
} else if (postEmail && postEmail.get('status') === 'failed') {
|
|
email = await this.emailService.retryEmail(postEmail);
|
|
}
|
|
if (email) {
|
|
model.set('email', email);
|
|
}
|
|
}
|
|
}
|
|
|
|
return model;
|
|
}
|
|
|
|
async bulkEdit(data, options) {
|
|
if (data.action === 'feature') {
|
|
return await this.#updatePosts({featured: true}, {filter: options.filter});
|
|
}
|
|
if (data.action === 'unfeature') {
|
|
return await this.#updatePosts({featured: false}, {filter: options.filter});
|
|
}
|
|
if (data.action === 'access') {
|
|
if (!['public', 'members', 'paid', 'tiers'].includes(data.meta.visibility)) {
|
|
throw new errors.IncorrectUsageError({
|
|
message: tpl(messages.invalidVisibility)
|
|
});
|
|
}
|
|
let tiers = undefined;
|
|
if (data.meta.visibility === 'tiers') {
|
|
if (!Array.isArray(data.meta.tiers)) {
|
|
throw new errors.IncorrectUsageError({
|
|
message: tpl(messages.invalidTiers)
|
|
});
|
|
}
|
|
tiers = data.meta.tiers;
|
|
}
|
|
return await this.#updatePosts({visibility: data.meta.visibility, tiers}, {filter: options.filter});
|
|
}
|
|
throw new errors.IncorrectUsageError({
|
|
message: tpl(messages.unsupportedBulkAction)
|
|
});
|
|
}
|
|
|
|
async bulkDestroy(options) {
|
|
if (!options.transacting) {
|
|
return await this.models.Post.transaction(async (transacting) => {
|
|
return await this.bulkDestroy({
|
|
...options,
|
|
transacting
|
|
});
|
|
});
|
|
}
|
|
|
|
const postRows = await this.models.Post.getFilteredCollectionQuery({
|
|
filter: options.filter,
|
|
status: 'all'
|
|
}).leftJoin('emails', 'posts.id', 'emails.post_id').select('posts.id', 'emails.id as email_id');
|
|
const deleteIds = postRows.map(row => row.id);
|
|
|
|
// We also need to collect the email ids because the email relation doesn't have cascase, and we need to delete the related relations of the post
|
|
const deleteEmailIds = postRows.map(row => row.email_id).filter(id => !!id);
|
|
|
|
const postTablesToDelete = [
|
|
'posts_authors',
|
|
'posts_tags',
|
|
'posts_meta',
|
|
'mobiledoc_revisions',
|
|
'post_revisions',
|
|
'posts_products'
|
|
];
|
|
const emailTablesToDelete = [
|
|
'email_recipient_failures',
|
|
'email_recipients',
|
|
'email_batches',
|
|
'email_spam_complaint_events'
|
|
];
|
|
|
|
// Don't clear, but set relation to null
|
|
const emailTablesToSetNull = [
|
|
'suppressions'
|
|
];
|
|
|
|
for (const table of postTablesToDelete) {
|
|
await this.models.Post.bulkDestroy(deleteIds, table, {
|
|
column: 'post_id',
|
|
transacting: options.transacting,
|
|
throwErrors: true
|
|
});
|
|
}
|
|
|
|
for (const table of emailTablesToDelete) {
|
|
await this.models.Post.bulkDestroy(deleteEmailIds, table, {
|
|
column: 'email_id',
|
|
transacting: options.transacting,
|
|
throwErrors: true
|
|
});
|
|
}
|
|
|
|
for (const table of emailTablesToSetNull) {
|
|
await this.models.Post.bulkEdit(deleteEmailIds, table, {
|
|
data: {email_id: null},
|
|
column: 'email_id',
|
|
transacting: options.transacting,
|
|
throwErrors: true
|
|
});
|
|
}
|
|
|
|
// 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});
|
|
}
|
|
|
|
async export(frame) {
|
|
return await this.postsExporter.export(frame.options);
|
|
}
|
|
|
|
async #updatePosts(data, options) {
|
|
if (!options.transacting) {
|
|
return await this.models.Post.transaction(async (transacting) => {
|
|
return await this.#updatePosts(data, {
|
|
...options,
|
|
transacting
|
|
});
|
|
});
|
|
}
|
|
|
|
const postRows = await this.models.Post.getFilteredCollectionQuery({
|
|
filter: options.filter,
|
|
status: 'all'
|
|
}).select('posts.id');
|
|
|
|
const editIds = postRows.map(row => row.id);
|
|
|
|
let tiers = undefined;
|
|
if (data.tiers) {
|
|
tiers = data.tiers;
|
|
delete data.tiers;
|
|
}
|
|
|
|
const result = await this.models.Post.bulkEdit(editIds, 'posts', {
|
|
data,
|
|
transacting: options.transacting,
|
|
throwErrors: true
|
|
});
|
|
|
|
// Update tiers
|
|
if (tiers) {
|
|
// First delete all
|
|
await this.models.Post.bulkDestroy(editIds, 'posts_products', {
|
|
column: 'post_id',
|
|
transacting: options.transacting,
|
|
throwErrors: true
|
|
});
|
|
|
|
// Then add again
|
|
const toInsert = [];
|
|
for (const postId of editIds) {
|
|
for (const [index, tier] of tiers.entries()) {
|
|
if (typeof tier.id === 'string') {
|
|
toInsert.push({
|
|
id: ObjectId().toHexString(),
|
|
post_id: postId,
|
|
product_id: tier.id,
|
|
sort_order: index
|
|
});
|
|
}
|
|
}
|
|
}
|
|
await this.models.Post.bulkAdd(toInsert, 'posts_products', {
|
|
transacting: options.transacting,
|
|
throwErrors: true
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async getProductsFromVisibilityFilter(visibilityFilter) {
|
|
try {
|
|
const allProducts = await this.models.Product.findAll();
|
|
const visibilityFilterJson = nql(visibilityFilter).toJSON();
|
|
const productsData = (visibilityFilterJson.product ? [visibilityFilterJson] : visibilityFilterJson.$or) || [];
|
|
const tiers = productsData
|
|
.map((data) => {
|
|
return allProducts.find((p) => {
|
|
return p.get('slug') === data.product;
|
|
});
|
|
}).filter(p => !!p).map((d) => {
|
|
return d.toJSON();
|
|
});
|
|
return tiers;
|
|
} catch (err) {
|
|
return Promise.reject(new BadRequestError({
|
|
message: tpl(messages.invalidVisibilityFilter),
|
|
context: err.message
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates if the email should be tried to be sent out
|
|
* @private
|
|
* @param {String} currentStatus current status from the post model
|
|
* @param {String} previousStatus previous status from the post model
|
|
* @returns {Boolean}
|
|
*/
|
|
shouldSendEmail(currentStatus, previousStatus) {
|
|
return (['published', 'sent'].includes(currentStatus))
|
|
&& (!['published', 'sent'].includes(previousStatus));
|
|
}
|
|
|
|
handleCacheInvalidation(model) {
|
|
let cacheInvalidate;
|
|
|
|
if (
|
|
model.get('status') === 'published' && model.wasChanged() ||
|
|
model.get('status') === 'draft' && model.previous('status') === 'published'
|
|
) {
|
|
cacheInvalidate = true;
|
|
} else if (
|
|
model.get('status') === 'draft' && model.previous('status') !== 'published' ||
|
|
model.get('status') === 'scheduled' && model.wasChanged()
|
|
) {
|
|
cacheInvalidate = {
|
|
value: this.urlUtils.urlFor({
|
|
relativeUrl: this.urlUtils.urlJoin('/p', model.get('uuid'), '/')
|
|
})
|
|
};
|
|
} else {
|
|
cacheInvalidate = false;
|
|
}
|
|
|
|
return cacheInvalidate;
|
|
}
|
|
}
|
|
|
|
module.exports = PostsService;
|