You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
357 lines
9.8 KiB
357 lines
9.8 KiB
// 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;
|
|
}
|
|
|
|
/**
|
|
* Meant for use in admin tools.
|
|
*/
|
|
async getRecent (pagination) {
|
|
const comments = await Comment
|
|
.find()
|
|
.sort({ created: -1 })
|
|
.skip(pagination.skip)
|
|
.limit(pagination.cpp)
|
|
.populate(this.populateComment)
|
|
.lean();
|
|
const totalCommentCount = await Comment.estimatedDocumentCount();
|
|
return { comments, totalCommentCount };
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
async removeForAuthor (author) {
|
|
await Comment
|
|
.find({ // index: 'comment_author_by_type'
|
|
authorType: author.type,
|
|
author: author._id,
|
|
})
|
|
.cursor()
|
|
.eachAsync(async (comment) => {
|
|
await this.remove(comment);
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
logId: 'svc:comment',
|
|
index: 'comment',
|
|
className: 'CommentService',
|
|
create: (dtp) => { return new CommentService(dtp); },
|
|
};
|