// post.js // Copyright (C) 2021 Digital Telepresence, LLC // License: Apache-2.0 'use strict'; const striptags = require('striptags'); const slug = require('slug'); const { SiteService, SiteError } = require('../../lib/site-lib'); const mongoose = require('mongoose'); const ObjectId = mongoose.Types.ObjectId; const Post = mongoose.model('Post'); const Comment = mongoose.model('Comment'); // jshint ignore:line const moment = require('moment'); class PostService extends SiteService { constructor (dtp) { super(dtp, module.exports); this.populatePost = [ { path: 'author', select: 'core coreUserId username username_lc displayName bio picture', populate: [ { path: 'core', select: 'created updated flags meta', strictPopulate: false, }, ], }, { path: 'image', }, ]; } async createPlaceholder (author) { const NOW = new Date(); if (!author.flags.isAdmin){ if (!author.permissions.canAuthorPosts) { throw new SiteError(403, 'You are not permitted to author posts'); } } let post = new Post(); post.created = NOW; post.authorType = author.type; post.author = author._id; post.title = "New Draft Post"; post.slug = `draft-post-${post._id}`; await post.save(); post = post.toObject(); post.author = author; // self-populate instead of calling db return post; } async create (author, postDefinition) { const NOW = new Date(); if (!author.flags.isAdmin){ if (!author.permissions.canAuthorPosts) { throw new SiteError(403, 'You are not permitted to author posts'); } if ((postDefinition.status === 'published') && !author.permissions.canPublishPosts) { throw new SiteError(403, 'You are not permitted to publish posts'); } } if (postDefinition.tags) { postDefinition.tags = postDefinition.tags.split(',').map((tag) => striptags(tag.trim())); } else { postDefinition.tags = [ ]; } const post = new Post(); post.created = NOW; post.authorType = author.type; post.author = author._id; post.title = striptags(postDefinition.title.trim()); post.slug = this.createPostSlug(post._id, post.title); post.summary = striptags(postDefinition.summary.trim()); post.content = postDefinition.content.trim(); post.tags = postDefinition.tags; post.status = postDefinition.status || 'draft'; post.flags = { enableComments: postDefinition.enableComments === 'on', isFeatured: postDefinition.isFeatured === 'on', }; await post.save(); return post.toObject(); } async update (user, post, postDefinition) { const { coreNode: coreNodeService } = this.dtp.services; if (!user.flags.isAdmin){ if (!user.permissions.canAuthorPosts) { throw new SiteError(403, 'You are not permitted to author posts'); } } const NOW = new Date(); const updateOp = { $setOnInsert: { created: NOW, }, $set: { updated: NOW, }, }; if (postDefinition.title) { updateOp.$set.title = striptags(postDefinition.title.trim()); updateOp.$set.slug = this.createPostSlug(post._id, updateOp.$set.title); } if (postDefinition.slug) { let postSlug = striptags(slug(postDefinition.slug.trim())).split('-'); while (ObjectId.isValid(postSlug[postSlug.length - 1])) { postSlug.pop(); } postSlug = postSlug.splice(0, 4); postSlug.push(post._id.toString()); updateOp.$set.slug = `${postSlug.join('-')}`; } if (postDefinition.summary) { updateOp.$set.summary = striptags(postDefinition.summary.trim()); } if (postDefinition.content) { updateOp.$set.content = postDefinition.content.trim(); } await this.updateTags(post._id, postDefinition.tags); if (!postDefinition.status) { throw new SiteError(406, 'Must include post status'); } // const postWillBeUnpublished = post.status === 'published' && postDefinition.status !== 'published'; const postWillBePublished = post.status !== 'published' && postDefinition.status === 'published'; if (postWillBePublished) { if (!user.permissions.canPublishPosts && !user.flags.isAdmin) { throw new SiteError(403, 'You are not permitted to publish posts'); } } updateOp.$set.status = striptags(postDefinition.status.trim()); updateOp.$set['flags.enableComments'] = postDefinition.enableComments === 'on'; updateOp.$set['flags.isFeatured'] = postDefinition.isFeatured === 'on'; const OLD_STATUS = post.status; const postAuthor = post.author; post = await Post.findOneAndUpdate( { _id: post._id }, updateOp, { upsert: true, new: true }, ); const CORE_SCHEME = coreNodeService.getCoreRequestScheme(); const { site } = this.dtp.config; if ((OLD_STATUS === 'draft') && (updateOp.$set.status === 'published')) { const event = { action: 'post-create', emitter: postAuthor, label: updateOp.$set.title, content: updateOp.$set.summary, href: `${CORE_SCHEME}://${site.domain}/post/${updateOp.$set.slug}`, }; if (post.image) { event.thumbnail = `${CORE_SCHEME}://${site.domain}/image/${post.image}`; } await coreNodeService.sendKaleidoscopeEvent(event); } } // pass the post._id and its tags to function async updateTags (id, tags) { if (tags) { tags = tags.split(',').map((tag) => striptags(tag.trim().toLowerCase())); } else { tags = [ ]; } const NOW = new Date(); const updateOp = { $setOnInsert: { created: NOW, }, $set: { updated: NOW, }, }; updateOp.$set.tags = tags; await Post.findOneAndUpdate( { _id: id }, updateOp, ); } async getByTags (tag, pagination, status = ['published']) { if (!Array.isArray(status)) { status = [status]; } const posts = await Post.find( { status: { $in: status }, tags: tag } ) .sort({ created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populatePost); return posts; } async updateImage (user, post, file) { const { image: imageService } = this.dtp.services; if (!user.permissions.canAuthorPosts) { throw new SiteError(403, 'You are not permitted to change posts'); } const images = [ { width: 960, height: 540, format: 'jpeg', formatParameters: { quality: 80, }, }, ]; await imageService.processImageFile(user, file, images); await Post.updateOne( { _id: post._id }, { $set: { image: images[0].image._id, }, }, ); } async getPosts (pagination, status = ['published'], count = false) { if (!Array.isArray(status)) { status = [status]; } var search = { status: { $in: status }, 'flags.isFeatured': false }; if ( count ) { search = { status: { $in: status }, }; } const posts = await Post .find(search) .sort({ created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populatePost) .lean(); posts.forEach((post) => { post.author.type = post.authorType; }); if (count) { const totalPostCount = await Post .countDocuments(search); return { posts, totalPostCount }; } return posts; } async getFeaturedPosts (maxCount = 3) { const posts = await Post .find({ status: 'published', 'flags.isFeatured': true }) .sort({ created: -1 }) .limit(maxCount) .lean(); return posts; } async getAllPosts (pagination) { const posts = await Post .find() .sort({ created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populatePost) .lean(); return posts; } async getForAuthor (author, status, pagination) { if (!Array.isArray(status)) { status = [status]; } pagination = Object.assign({ skip: 0, cpp: 5 }, pagination); const search = { authorType: author.type, author: author._id, status: { $in: status }, }; const posts = await Post .find(search) .sort({ created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populatePost) .lean(); posts.forEach((post) => { post.author.type = post.authorType; }); const totalPostCount = await Post.countDocuments(search); return { posts, totalPostCount }; } async getCommentsForAuthor (author, pagination) { const { comment: commentService } = this.dtp.services; const NOW = new Date(); const START_DATE = moment(NOW).subtract(3, 'days').toDate(); const published = await this.getForAuthor(author, 'published', 5); if (!published || (published.length === 0)) { return [ ]; } const postIds = published.posts.map((post) => post._id); const search = { // index: 'comment_replies' created: { $gt: START_DATE }, status: 'published', resource: { $in: postIds }, }; let q = Comment .find(search) .sort({ created: -1 }); if (pagination) { q = q.skip(pagination.skip).limit(pagination.cpp); } else { q = q.limit(20); } const comments = await q .populate(commentService.populateCommentWithResource) .lean(); const totalCommentCount = await Comment.countDocuments(search); return { comments, totalCommentCount }; } async getById (postId) { const post = await Post .findById(postId) .select('+content') .populate(this.populatePost) .lean(); post.author.type = post.authorType; return post; } async getBySlug (postSlug) { const slugParts = postSlug.split('-'); const postId = slugParts[slugParts.length - 1]; return this.getById(postId); } async deletePost (post) { const { comment: commentService, contentReport: contentReportService, } = this.dtp.services; await commentService.deleteForResource(post); await contentReportService.removeForResource(post); this.log.info('deleting post', { postId: post._id }); await Post.deleteOne({ _id: post._id }); } createPostSlug (postId, postTitle) { if ((typeof postTitle !== 'string') || (postTitle.length < 1)) { throw new Error('Invalid input for making a post slug'); } const postSlug = slug(postTitle.trim().toLowerCase()).split('-').slice(0, 4).join('-'); return `${postSlug}-${postId}`; } } module.exports = { slug: 'post', name: 'post', create: (dtp) => { return new PostService(dtp); }, };