DTP Social Engine
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.
 
 
 
 
 

328 lines
9.1 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',
},
],
},
];
}
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); },
};