more work on comments; reeeper updated

- Moved the responsibility of expiring Announcements from MongoDB into
the Reeeper
- Added logic to clean up comments attached to an expiring Announcement
- ResourceStats are now much more universal and common
- CommentStats are for comments only
- More routines to comment on and vote on "content resources"
master
rob 2 years ago
parent 5e90fca353
commit 91fe2ab01b

@ -87,7 +87,7 @@ class AnnouncementAdminController extends SiteController {
try { try {
const displayList = this.createDisplayList('delete-announcement'); const displayList = this.createDisplayList('delete-announcement');
await announcementService.remove(res.locals.announcement); await announcementService.remove(res.locals.announcement);
displayList.reloadView(); displayList.reload();
res.status(200).json({ success: true, displayList }); res.status(200).json({ success: true, displayList });
} catch (error) { } catch (error) {
this.log.error('failed to delete announcement', { error }); this.log.error('failed to delete announcement', { error });

@ -61,14 +61,14 @@ class CommentController extends SiteController {
const { contentVote: contentVoteService } = this.dtp.services; const { contentVote: contentVoteService } = this.dtp.services;
try { try {
const displayList = this.createDisplayList('comment-vote'); const displayList = this.createDisplayList('comment-vote');
const { message, stats } = await contentVoteService.recordVote(req.user, 'Comment', res.locals.comment, req.body.vote); const { message, resourceStats } = await contentVoteService.recordVote(req.user, 'Comment', res.locals.comment, req.body.vote);
displayList.setTextContent( displayList.setTextContent(
`button[data-comment-id="${res.locals.comment._id}"][data-vote="up"] span.dtp-item-value`, `button[data-comment-id="${res.locals.comment._id}"][data-vote="up"] span.dtp-item-value`,
numeral(stats.upvoteCount).format(stats.upvoteCount > 1000 ? '0,0.0a' : '0,0'), numeral(resourceStats.upvoteCount).format(resourceStats.upvoteCount > 1000 ? '0,0.0a' : '0,0'),
); );
displayList.setTextContent( displayList.setTextContent(
`button[data-comment-id="${res.locals.comment._id}"][data-vote="down"] span.dtp-item-value`, `button[data-comment-id="${res.locals.comment._id}"][data-vote="down"] span.dtp-item-value`,
numeral(stats.downvoteCount).format(stats.upvoteCount > 1000 ? '0,0.0a' : '0,0'), numeral(resourceStats.downvoteCount).format(resourceStats.upvoteCount > 1000 ? '0,0.0a' : '0,0'),
); );
displayList.showNotification(message, 'success', 'bottom-center', 3000); displayList.showNotification(message, 'success', 'bottom-center', 3000);
res.status(200).json({ success: true, displayList }); res.status(200).json({ success: true, displayList });

@ -4,10 +4,18 @@
'use strict'; 'use strict';
const path = require('path');
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const Schema = mongoose.Schema; const Schema = mongoose.Schema;
const {
ResourceStats,
ResourceStatsDefaults,
} = require(path.join(__dirname, 'lib', 'resource-stats.js'));
const AnnouncementSchema = new Schema({ const AnnouncementSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1 }, created: { type: Date, default: Date.now, required: true, index: -1 },
title: { title: {
@ -18,6 +26,7 @@ const AnnouncementSchema = new Schema({
content: { type: String, required: true }, content: { type: String, required: true },
}, },
content: { type: String, required: true }, content: { type: String, required: true },
resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true },
}); });
module.exports = mongoose.model('Announcement', AnnouncementSchema); module.exports = mongoose.model('Announcement', AnnouncementSchema);

@ -16,10 +16,10 @@ const CommentHistorySchema = new Schema({
const { const {
RESOURCE_TYPE_LIST, RESOURCE_TYPE_LIST,
CommentStats,
CommentStatsDefaults,
ResourceStats, ResourceStats,
ResourceStatsDefaults, ResourceStatsDefaults,
CommentStats,
CommentStatsDefaults,
} = require(path.join(__dirname, 'lib', 'resource-stats.js')); } = require(path.join(__dirname, 'lib', 'resource-stats.js'));
const COMMENT_STATUS_LIST = [ const COMMENT_STATUS_LIST = [

@ -33,7 +33,7 @@ const CoreUserSchema = new Schema({
permissions: { type: UserPermissionsSchema, select: false }, permissions: { type: UserPermissionsSchema, select: false },
optIn: { type: UserOptInSchema, required: true, select: false }, optIn: { type: UserOptInSchema, required: true, select: false },
theme: { type: String, enum: DTP_THEME_LIST, default: 'dtp-light', required: true }, theme: { type: String, enum: DTP_THEME_LIST, default: 'dtp-light', required: true },
stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true },
}); });
CoreUserSchema.index({ CoreUserSchema.index({

@ -16,21 +16,21 @@ module.exports.RESOURCE_TYPE_LIST = [
module.exports.ResourceStats = new Schema({ module.exports.ResourceStats = new Schema({
uniqueVisitCount: { type: Number, default: 0, required: true, index: -1 }, uniqueVisitCount: { type: Number, default: 0, required: true, index: -1 },
totalVisitCount: { type: Number, default: 0, required: true }, totalVisitCount: { type: Number, default: 0, required: true },
upvoteCount: { type: Number, default: 0, required: true },
downvoteCount: { type: Number, default: 0, required: true },
}); });
module.exports.ResourceStatsDefaults = { module.exports.ResourceStatsDefaults = {
uniqueVisitCount: 0, uniqueVisitCount: 0,
totalVisitCount: 0, totalVisitCount: 0,
upvoteCount: 0,
downvoteCount: 0,
}; };
module.exports.CommentStats = new Schema({ module.exports.CommentStats = new Schema({
upvoteCount: { type: Number, default: 0, required: true },
downvoteCount: { type: Number, default: 0, required: true },
replyCount: { type: Number, default: 0, required: true }, replyCount: { type: Number, default: 0, required: true },
}); });
module.exports.CommentStatsDefaults = { module.exports.CommentStatsDefaults = {
upvoteCount: 0,
downvoteCount: 0,
replyCount: 0, replyCount: 0,
}; };

@ -38,7 +38,7 @@ const UserSchema = new Schema({
permissions: { type: UserPermissionsSchema, select: false }, permissions: { type: UserPermissionsSchema, select: false },
optIn: { type: UserOptInSchema, required: true, select: false }, optIn: { type: UserOptInSchema, required: true, select: false },
theme: { type: String, enum: DTP_THEME_LIST, default: 'dtp-light', required: true }, theme: { type: String, enum: DTP_THEME_LIST, default: 'dtp-light', required: true },
stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true },
lastAnnouncement: { type: Date }, lastAnnouncement: { type: Date },
}); });

@ -108,6 +108,9 @@ class AnnouncementService extends SiteService {
} }
async remove (announcement) { async remove (announcement) {
const { comment: commentService } = this.dtp.services;
await commentService.deleteForResource(announcement);
await Announcement.deleteOne({ _id: announcement._id }); await Announcement.deleteOne({ _id: announcement._id });
} }
} }

@ -161,7 +161,7 @@ class CommentService extends SiteService {
await Comment.updateOne( await Comment.updateOne(
{ _id: replyTo }, { _id: replyTo },
{ {
$inc: { 'stats.replyCount': 1 }, $inc: { 'commentStats.replyCount': 1 },
}, },
); );
let parent = await Comment.findById(replyTo).select('replyTo').lean(); let parent = await Comment.findById(replyTo).select('replyTo').lean();
@ -303,7 +303,8 @@ class CommentService extends SiteService {
} }
/** /**
* Deletes all comments filed against a given resource. * 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 * @param {Resource} resource The resource for which all comments are to be
* deleted (physically removed from database). * deleted (physically removed from database).
*/ */

@ -47,10 +47,10 @@ class ContentVoteService extends SiteService {
vote vote
}); });
if (vote === 'up') { if (vote === 'up') {
updateOp.$inc['stats.upvoteCount'] = 1; updateOp.$inc['resourceStats.upvoteCount'] = 1;
message = 'Comment upvote recorded'; message = 'Comment upvote recorded';
} else { } else {
updateOp.$inc['stats.downvoteCount'] = 1; updateOp.$inc['resourceStats.downvoteCount'] = 1;
message = 'Comment downvote recorded'; message = 'Comment downvote recorded';
} }
} else { } else {
@ -58,8 +58,8 @@ class ContentVoteService extends SiteService {
* If vote not changed, do no further work. * If vote not changed, do no further work.
*/ */
if (contentVote.vote === vote) { if (contentVote.vote === vote) {
const updatedResource = await ResourceModel.findById(resource._id).select('stats'); const updatedResource = await ResourceModel.findById(resource._id).select('resourceStats');
return { message: "Comment vote unchanged", stats: updatedResource.stats }; return { message: "Comment vote unchanged", resourceStats: updatedResource.resourceStats };
} }
/* /*
@ -74,12 +74,12 @@ class ContentVoteService extends SiteService {
* Adjust resource's stats based on the changed vote * Adjust resource's stats based on the changed vote
*/ */
if (vote === 'up') { if (vote === 'up') {
updateOp.$inc['stats.upvoteCount'] = 1; updateOp.$inc['resourceStats.upvoteCount'] = 1;
updateOp.$inc['stats.downvoteCount'] = -1; updateOp.$inc['resourceStats.downvoteCount'] = -1;
message = 'Comment vote changed to upvote'; message = 'Comment vote changed to upvote';
} else { } else {
updateOp.$inc['stats.upvoteCount'] = -1; updateOp.$inc['resourceStats.upvoteCount'] = -1;
updateOp.$inc['stats.downvoteCount'] = 1; updateOp.$inc['resourceStats.downvoteCount'] = 1;
message = 'Comment vote changed to downvote'; message = 'Comment vote changed to downvote';
} }
} }
@ -87,8 +87,8 @@ class ContentVoteService extends SiteService {
this.log.info('updating resource stats', { resourceType, resource, updateOp }); this.log.info('updating resource stats', { resourceType, resource, updateOp });
await ResourceModel.updateOne({ _id: resource._id }, updateOp); await ResourceModel.updateOne({ _id: resource._id }, updateOp);
const updatedResource = await ResourceModel.findById(resource._id).select('stats'); const updatedResource = await ResourceModel.findById(resource._id).select('resourceStats');
return { message, stats: updatedResource.stats }; return { message, resourceStats: updatedResource.resourceStats };
} }
} }

@ -21,6 +21,7 @@ const OAuth2Strategy = require('passport-oauth2');
const striptags = require('striptags'); const striptags = require('striptags');
const { SiteService, SiteError, SiteAsync } = require('../../lib/site-lib'); const { SiteService, SiteError, SiteAsync } = require('../../lib/site-lib');
const { ResourceStatsDefaults } = require('../models/lib/resource-stats');
class CoreAddress { class CoreAddress {
@ -573,10 +574,7 @@ class CoreNodeService extends SiteService {
marketing: false, marketing: false,
}, },
theme: 'dtp-light', theme: 'dtp-light',
stats: { resourceStats: ResourceStatsDefaults,
uniqueVisitCount: 0,
totalVisitCount: 0,
},
}, },
$set: { $set: {
updated: NOW, updated: NOW,

@ -85,7 +85,7 @@ mixin renderComment (comment, options)
onclick=`return dtp.app.comments.submitCommentVote(event);`, onclick=`return dtp.app.comments.submitCommentVote(event);`,
title="Upvote this comment", title="Upvote this comment",
).uk-button.uk-button-link ).uk-button.uk-button-link
+renderLabeledIcon('fa-chevron-up', formatCount(comment.stats.upvoteCount)) +renderLabeledIcon('fa-chevron-up', formatCount(comment.resourceStats.upvoteCount))
.uk-width-auto .uk-width-auto
button( button(
type="button", type="button",
@ -94,7 +94,7 @@ mixin renderComment (comment, options)
onclick=`return dtp.app.comments.submitCommentVote(event);`, onclick=`return dtp.app.comments.submitCommentVote(event);`,
title="Downvote this comment", title="Downvote this comment",
).uk-button.uk-button-link ).uk-button.uk-button-link
+renderLabeledIcon('fa-chevron-down', formatCount(comment.stats.downvoteCount)) +renderLabeledIcon('fa-chevron-down', formatCount(comment.resourceStats.downvoteCount))
.uk-width-auto .uk-width-auto
button( button(
type="button", type="button",
@ -102,7 +102,7 @@ mixin renderComment (comment, options)
onclick=`return dtp.app.comments.openReplies(event);`, onclick=`return dtp.app.comments.openReplies(event);`,
title="Load replies to this comment", title="Load replies to this comment",
).uk-button.uk-button-link ).uk-button.uk-button-link
+renderLabeledIcon('fa-comment', formatCount(comment.stats.replyCount)) +renderLabeledIcon('fa-comment', formatCount(comment.commentStats.replyCount))
.uk-width-auto .uk-width-auto
button( button(
type="button", type="button",

@ -35,6 +35,7 @@ class ReeeperWorker extends SiteWorker {
await super.start(); await super.start();
await this.loadProcessor(path.join(__dirname, 'reeeper', 'cron', 'expire-crashed-hosts.js')); await this.loadProcessor(path.join(__dirname, 'reeeper', 'cron', 'expire-crashed-hosts.js'));
await this.loadProcessor(path.join(__dirname, 'reeeper', 'cron', 'expire-announcements.js'));
await this.startProcessors(); await this.startProcessors();
} }

@ -0,0 +1,94 @@
// reeeper/cron/expire-announcements.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const path = require('path');
const moment = require('moment');
const mongoose = require('mongoose');
const Announcement = mongoose.model('Announcement');
const { CronJob } = require('cron');
const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib'));
/**
* Announcements used to auto-expire from the MongoDB database after 21 days,
* but then I added commenting. Now, an auto-expiring Announcement would orphan
* all those comments. That's bad.
*
* The solution, therefore, is to have a cron that wakes up daily and expires
* all Announcements older than 21 days. Same policy, it just also cleans up
* the comments and whatever else gets bolted onto an Announcement over time.
*
* This is how you do that.
*/
class ExpiredAnnouncementsCron extends SiteWorkerProcess {
static get COMPONENT ( ) {
return {
name: 'expiredAnnouncementsCron',
slug: 'expired-announcements-cron',
};
}
constructor (worker) {
super(worker, ExpiredAnnouncementsCron.COMPONENT);
}
async start ( ) {
await super.start();
this.log.info('performing startup expiration of announcements');
await this.expireAnnouncements();
this.log.info('starting daily cron to expire announcements');
this.job = new CronJob(
'0 0 0 * * *', // at midnight every day
this.expireAnnouncements.bind(this),
null,
true,
process.env.DTP_CRON_TIMEZONE || 'America/New_York',
);
}
async stop ( ) {
if (this.job) {
this.log.info('stopping announcement expire job');
this.job.stop();
delete this.job;
}
await super.stop();
}
async expireAnnouncements ( ) {
const { announcement: announcementService } = this.dtp.services;
const NOW = new Date();
const OLDEST_DATE = moment(NOW).subtract(21, 'days').toDate();
try {
await Announcement
.find({ created: { $lt: OLDEST_DATE } })
.lean()
.cursor()
.eachAsync(async (announcement) => {
try {
await announcementService.remove(announcement);
} catch (error) {
this.log.error('failed to remove expired Announcement', {
announcementId: announcement._id,
error,
});
// fall through, we'll get it in a future run
}
});
} catch (error) {
this.log.error('failed to expire crashed hosts', { error });
}
}
}
module.exports = ExpiredAnnouncementsCron;

@ -14,10 +14,15 @@ const { CronJob } = require('cron');
const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib'));
/** /**
* DTP Core Chat sticker processor can receive requests to ingest and delete * Hosts on the DTP network register themselves and periodically report various
* stickers to be executed as background jobs in a queue. This processor * metrics. They clean up after themselves when exiting gracefully. But, hosts
* attaches to the `media` queue and registers processors for `sticker-ingest` * lose power or get un-plugged or get caught in a sharknado or whatever.
* and `sticker-delete`. *
* When that happens, the Reeeper ensures those hosts don't become Night of the
* Living Dead.
*
* That is the formal technical explanation of what's going on in here. We're
* preventing DTP host processes from becoming The Night of the Living Dead.
*/ */
class CrashedHostsCron extends SiteWorkerProcess { class CrashedHostsCron extends SiteWorkerProcess {

@ -286,27 +286,23 @@ export default class DtpSiteAdminHostStatsApp extends DtpApp {
} }
} }
async deletePost (event) { async deleteAnnouncement (event) {
const postId = event.currentTarget.getAttribute('data-post-id'); const target = event.currentTarget || event.target;
const postTitle = event.currentTarget.getAttribute('data-post-title'); const announcementId = target.getAttribute('data-announcement-id');
console.log(postId, postTitle);
try { try {
await UIkit.modal.confirm(`Are you sure you want to delete "${postTitle}"`); await UIkit.modal.confirm('Are you sure you want to delete the announcement?');
} catch (error) { } catch (error) {
this.log.info('deletePost', 'aborted');
return; return;
} }
try { try {
const response = await fetch(`/admin/post/${postId}`, { const actionUrl = `/admin/announcement/${announcementId}`;
method: 'DELETE', const response = await fetch(actionUrl, { method: 'DELETE' });
});
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to delete post'); throw new Error('Server error');
} }
await this.processResponse(response); await this.processResponse(response);
} catch (error) { } catch (error) {
this.log.error('deletePost', 'failed to delete post', { postId, postTitle, error }); UIkit.modal.alert(`Failed to delete announcement: ${error.message}`);
UIkit.modal.alert(`Failed to delete post: ${error.message}`);
} }
} }

Loading…
Cancel
Save