// comment.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 'use strict'; const path = require('path'); const mongoose = require('mongoose'); const Comment = mongoose.model('Comment'); // jshint ignore:line const pug = require('pug'); const striptags = require('striptags'); const { SiteService, SiteError } = require('../../lib/site-lib'); class CommentService extends SiteService { constructor (dtp) { super(dtp, module.exports); this.populateComment = [ { path: 'author', select: '_id username username_lc displayName picture', }, { path: 'replyTo', }, ]; this.populateCommentWithResource = [ { path: 'author', select: '_id username username_lc displayName picture', }, { path: 'replyTo', }, { path: 'resource', populate: [ { path: 'author', select: '_id username username_lc displayName picture', strictPopulate: false, }, ], }, ]; } async start ( ) { await super.start(); this.templates = { }; this.templates.comment = pug.compileFile(path.join(this.dtp.config.root, 'app', 'views', 'comment', 'components', 'comment-standalone.pug')); this.templates.commentList = pug.compileFile(path.join(this.dtp.config.root, 'app', 'views', 'comment', 'components', 'comment-list-standalone.pug')); this.templates.replyList = pug.compileFile(path.join(this.dtp.config.root, 'app', 'views', 'comment', 'components', 'reply-list-standalone.pug')); } async renderTemplate (templateName, viewModel) { return this.templates[templateName](viewModel); } async populateCommentId (req, res, next, commentId) { try { res.locals.comment = await this.getById(commentId); if (!res.locals.comment) { throw new SiteError(404, 'Comment not found'); } return next(); } catch (error) { this.log.error('failed to populate comment', { commentId, error }); return next(error); } } commentCreateHandler (resourceType, resourceKey) { const { displayEngine: displayEngineService } = this.dtp.services; return async (req, res, next) => { try { res.locals.comment = await this.create( req.user, resourceType, res.locals[resourceKey], req.body, ); let viewModel = Object.assign({ }, req.app.locals); viewModel = Object.assign(viewModel, res.locals); const html = await this.renderTemplate('comment', viewModel); const displayList = displayEngineService.createDisplayList('announcement-comment'); displayList.setInputValue('textarea#content', ''); displayList.setTextContent('#comment-character-count', '0'); if (req.body.replyTo) { const replyListSelector = `.dtp-reply-list-container[data-comment-id="${req.body.replyTo}"]`; displayList.addElement(replyListSelector, 'afterBegin', html); displayList.removeAttribute(replyListSelector, 'hidden'); } else { displayList.addElement('ul#post-comment-list', 'afterBegin', html); } displayList.showNotification( 'Comment created', 'success', 'bottom-center', 4000, ); res.status(200).json({ success: true, displayList }); } catch (error) { this.log.error('failed to process comment', { resourceType, resourceId: res.locals[resourceKey]._id, error, }); return next(error); } }; } async create (author, resourceType, resource, commentDefinition) { const NOW = new Date(); let comment = new Comment(); comment.created = NOW; comment.resourceType = resourceType; comment.resource = resource._id; comment.authorType = author.type; comment.author = author._id; if (commentDefinition.replyTo) { comment.replyTo = mongoose.Types.ObjectId(commentDefinition.replyTo); } if (commentDefinition.content) { comment.content = striptags(commentDefinition.content.trim()); } comment.flags = { isNSFW: commentDefinition.isNSFW === 'on', }; await comment.save(); const model = mongoose.model(resourceType); await model.updateOne( { _id: resource._id }, { $inc: { 'stats.commentCount': 1 }, }, ); /* * increment the reply count of every parent comment until you reach a * comment with no parent. */ let replyTo = comment.replyTo; while (replyTo) { await Comment.updateOne( { _id: replyTo }, { $inc: { 'commentStats.replyCount': 1 }, }, ); let parent = await Comment.findById(replyTo).select('replyTo').lean(); if (parent.replyTo) { replyTo = parent.replyTo; } else { replyTo = false; } } comment = comment.toObject(); comment.author = author; return comment; } async update (comment, commentDefinition) { const updateOp = { $set: { } }; if (!commentDefinition.content || (commentDefinition.content.length === 0)) { throw new SiteError(406, 'The comment cannot be empty'); } updateOp.$set.content = striptags(commentDefinition.content.trim()); updateOp.$push = { contentHistory: { created: new Date(), content: comment.content, }, }; this.log.info('updating comment content', { commentId: comment._id }); await Comment.updateOne({ _id: comment._id }, updateOp); } async setStatus (comment, status) { await Comment.updateOne({ _id: comment._id }, { $set: { status } }); } /** * Pushes the current comment content to the contentHistory array, sets the * content field to 'Content removed' and updates the comment status to the * status provided. This preserves the comment content, but removes it from * public view. * @param {Document} comment * @param {String} status */ async remove (comment, status = 'removed') { const { contentReport: contentReportService } = this.dtp.services; await Comment.updateOne( { _id: comment._id }, { $set: { status, content: 'Comment removed', }, $push: { contentHistory: { created: new Date(), content: comment.content, }, }, }, ); await contentReportService.removeForResource(comment); } async getById (commentId) { const comment = await Comment .findById(commentId) .populate(this.populateComment) .lean(); return comment; } async getForResource (resource, statuses, pagination) { const comments = await Comment .find({ // index: 'comment_replies' resource: resource._id, replyTo: { $exists: false }, status: { $in: statuses }, }) .sort({ created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populateComment) .lean(); return comments; } async getForAuthor (author, pagination) { const comments = await Comment .find({ // index: 'comment_author_by_type' authorType: author.type, author: author._id, status: { $in: ['published', 'mod-warn'] }, }) .sort({ created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populateCommentWithResource) .lean(); return comments; } async getReplies (comment, pagination) { const replies = await Comment .find({ replyTo: comment._id, status: { $in: ['published', 'mod-warn', 'mod-removed'] } }) .sort({ created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populateComment) .lean(); return replies; } async getContentHistory (comment, pagination) { /* * Extract a page from the contentHistory using $slice on the array */ const fullComment = await Comment .findOne( { _id: comment._id }, { contentHistory: { $sort: { created: 1 }, $slice: [pagination.skip, pagination.cpp], }, } ) .select('contentHistory').lean(); if (!fullComment) { throw new SiteError(404, 'Comment not found'); } return fullComment.contentHistory || [ ]; } /** * Deletes all comments filed against a given resource. Will also get their * replies as those are also filed against a resource and will match. * @param {Resource} resource The resource for which all comments are to be * deleted (physically removed from database). */ async deleteForResource (resource) { const { contentReport: contentReportService } = this.dtp.services; this.log.info('deleting all comments for resource', { resourceId: resource._id }); await Comment .find({ resource: resource._id }) .cursor() .eachAsync(async (comment) => { await contentReportService.removeForResource(comment); await Comment.deleteOne({ _id: comment._id }); }, 4); } } module.exports = { slug: 'comment', name: 'comment', create: (dtp) => { return new CommentService(dtp); }, };