59fe794b0c
refs: https://github.com/TryGhost/Team/issues/3139 https://github.com/TryGhost/Team/issues/3140 - Added duplicate post functionality to post list context menu - Currently only a single post can be duplicated at a time - Currently only enabled via the `Making it rain` flag - Added admin API endpoint to copy a post - `POST ghost/api/admin/posts/<post_id>/copy/` - Added admin API endpoint to copy a page - `POST ghost/api/admin/pages/<page_id>/copy/`
441 lines
15 KiB
JavaScript
441 lines
15 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 omit = require('lodash/omit');
|
|
|
|
const messages = {
|
|
invalidVisibilityFilter: 'Invalid visibility filter.',
|
|
invalidVisibility: 'Invalid visibility value.',
|
|
invalidTiers: 'Invalid tiers value.',
|
|
invalidTags: 'Invalid tags 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;
|
|
}
|
|
|
|
#mergeFilters(...filters) {
|
|
return filters.filter(filter => filter).map(f => `(${f})`).join('+');
|
|
}
|
|
|
|
async bulkEdit(data, options) {
|
|
if (data.action === 'unpublish') {
|
|
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, context: options.context, actionName: 'featured'});
|
|
}
|
|
if (data.action === 'unfeature') {
|
|
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)) {
|
|
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, context: options.context});
|
|
}
|
|
if (data.action === 'addTag') {
|
|
if (!Array.isArray(data.meta.tags)) {
|
|
throw new errors.IncorrectUsageError({
|
|
message: tpl(messages.invalidTags)
|
|
});
|
|
}
|
|
for (const tag of data.meta.tags) {
|
|
if (typeof tag !== 'object') {
|
|
throw new errors.IncorrectUsageError({
|
|
message: tpl(messages.invalidTags)
|
|
});
|
|
}
|
|
if (!tag.id && !tag.name) {
|
|
throw new errors.IncorrectUsageError({
|
|
message: tpl(messages.invalidTags)
|
|
});
|
|
}
|
|
}
|
|
return await this.#bulkAddTags({tags: data.meta.tags}, {filter: options.filter, context: options.context});
|
|
}
|
|
throw new errors.IncorrectUsageError({
|
|
message: tpl(messages.unsupportedBulkAction)
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {object} data
|
|
* @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) {
|
|
return await this.models.Post.transaction(async (transacting) => {
|
|
return await this.#bulkAddTags(data, {
|
|
...options,
|
|
transacting
|
|
});
|
|
});
|
|
}
|
|
|
|
// 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, context: options.context});
|
|
tag.id = createdTag.id;
|
|
}
|
|
}
|
|
|
|
const postRows = await this.models.Post.getFilteredCollectionQuery({
|
|
filter: options.filter,
|
|
status: 'all',
|
|
transacting: options.transacting
|
|
}).select('posts.id');
|
|
|
|
const postTags = data.tags.reduce((pt, tag) => {
|
|
return pt.concat(postRows.map((post) => {
|
|
return {
|
|
id: (new ObjectId()).toHexString(),
|
|
post_id: post.id,
|
|
tag_id: tag.id,
|
|
sort_order: 0
|
|
};
|
|
}));
|
|
}, []);
|
|
|
|
await options.transacting('posts_tags').insert(postTags);
|
|
await this.models.Post.addActions('edited', postRows.map(p => p.id), options);
|
|
|
|
return {
|
|
successful: postRows.length,
|
|
unsuccessful: 0
|
|
};
|
|
}
|
|
|
|
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',
|
|
transacting: options.transacting
|
|
}).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', {...options, 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',
|
|
transacting: options.transacting
|
|
}).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', {
|
|
...options,
|
|
data,
|
|
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;
|
|
}
|
|
|
|
async copyPost(frame) {
|
|
const existingPost = await this.models.Post.findOne({
|
|
id: frame.options.id,
|
|
status: 'all'
|
|
}, frame.options);
|
|
|
|
const newPostData = omit(
|
|
existingPost.attributes,
|
|
[
|
|
'id',
|
|
'uuid',
|
|
'slug',
|
|
'comment_id',
|
|
'created_at',
|
|
'created_by',
|
|
'updated_at',
|
|
'updated_by',
|
|
'published_at',
|
|
'published_by',
|
|
'canonical_url',
|
|
'count__clicks'
|
|
]
|
|
);
|
|
|
|
newPostData.title = `${existingPost.attributes.title} (Copy)`;
|
|
newPostData.status = 'draft';
|
|
newPostData.authors = existingPost.related('authors')
|
|
.map(author => ({id: author.get('id')}));
|
|
newPostData.tags = existingPost.related('tags')
|
|
.map(tag => ({id: tag.get('id')}));
|
|
|
|
const existingPostMeta = existingPost.related('posts_meta');
|
|
|
|
if (existingPostMeta.isNew() === false) {
|
|
newPostData.posts_meta = omit(
|
|
existingPostMeta.attributes,
|
|
[
|
|
'id',
|
|
'post_id'
|
|
]
|
|
);
|
|
}
|
|
|
|
const existingPostTiers = existingPost.related('tiers');
|
|
|
|
if (existingPostTiers.length > 0) {
|
|
newPostData.tiers = existingPostTiers.map(tier => ({id: tier.get('id')}));
|
|
}
|
|
|
|
return this.models.Post.add(newPostData, frame.options);
|
|
}
|
|
|
|
/**
|
|
* Generates a location url for a copied post based on the original url generated by the API framework
|
|
*
|
|
* @param {string} url
|
|
* @returns {string}
|
|
*/
|
|
generateCopiedPostLocationFromUrl(url) {
|
|
const urlParts = url.split('/');
|
|
const pageId = urlParts[urlParts.length - 2];
|
|
|
|
return urlParts
|
|
.slice(0, -4)
|
|
.concat(pageId)
|
|
.concat('')
|
|
.join('/');
|
|
}
|
|
}
|
|
|
|
module.exports = PostsService;
|