Browse Source

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"
develop
Rob Colbert 2 years ago
parent
commit
91fe2ab01b
  1. 2
      app/controllers/admin/announcement.js
  2. 6
      app/controllers/comment.js
  3. 9
      app/models/announcement.js
  4. 4
      app/models/comment.js
  5. 2
      app/models/core-user.js
  6. 8
      app/models/lib/resource-stats.js
  7. 2
      app/models/user.js
  8. 3
      app/services/announcement.js
  9. 5
      app/services/comment.js
  10. 20
      app/services/content-vote.js
  11. 6
      app/services/core-node.js
  12. 6
      app/views/comment/components/comment.pug
  13. 1
      app/workers/reeeper.js
  14. 94
      app/workers/reeeper/cron/expire-announcements.js
  15. 13
      app/workers/reeeper/cron/expire-crashed-hosts.js
  16. 22
      client/js/site-admin-app.js

2
app/controllers/admin/announcement.js

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

6
app/controllers/comment.js

@ -61,14 +61,14 @@ class CommentController extends SiteController {
const { contentVote: contentVoteService } = this.dtp.services;
try {
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(
`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(
`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);
res.status(200).json({ success: true, displayList });

9
app/models/announcement.js

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

4
app/models/comment.js

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

2
app/models/core-user.js

@ -33,7 +33,7 @@ const CoreUserSchema = new Schema({
permissions: { type: UserPermissionsSchema, select: false },
optIn: { type: UserOptInSchema, required: true, select: false },
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({

8
app/models/lib/resource-stats.js

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

2
app/models/user.js

@ -38,7 +38,7 @@ const UserSchema = new Schema({
permissions: { type: UserPermissionsSchema, select: false },
optIn: { type: UserOptInSchema, required: true, select: false },
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 },
});

3
app/services/announcement.js

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

5
app/services/comment.js

@ -161,7 +161,7 @@ class CommentService extends SiteService {
await Comment.updateOne(
{ _id: replyTo },
{
$inc: { 'stats.replyCount': 1 },
$inc: { 'commentStats.replyCount': 1 },
},
);
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
* deleted (physically removed from database).
*/

20
app/services/content-vote.js

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

6
app/services/core-node.js

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

6
app/views/comment/components/comment.pug

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

1
app/workers/reeeper.js

@ -35,6 +35,7 @@ class ReeeperWorker extends SiteWorker {
await super.start();
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();
}

94
app/workers/reeeper/cron/expire-announcements.js

@ -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;

13
app/workers/reeeper/cron/expire-crashed-hosts.js

@ -14,10 +14,15 @@ const { CronJob } = require('cron');
const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib'));
/**
* DTP Core Chat sticker processor can receive requests to ingest and delete
* stickers to be executed as background jobs in a queue. This processor
* attaches to the `media` queue and registers processors for `sticker-ingest`
* and `sticker-delete`.
* Hosts on the DTP network register themselves and periodically report various
* metrics. They clean up after themselves when exiting gracefully. But, hosts
* lose power or get un-plugged or get caught in a sharknado or whatever.
*
* 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 {

22
client/js/site-admin-app.js

@ -286,27 +286,23 @@ export default class DtpSiteAdminHostStatsApp extends DtpApp {
}
}
async deletePost (event) {
const postId = event.currentTarget.getAttribute('data-post-id');
const postTitle = event.currentTarget.getAttribute('data-post-title');
console.log(postId, postTitle);
async deleteAnnouncement (event) {
const target = event.currentTarget || event.target;
const announcementId = target.getAttribute('data-announcement-id');
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) {
this.log.info('deletePost', 'aborted');
return;
}
try {
const response = await fetch(`/admin/post/${postId}`, {
method: 'DELETE',
});
const actionUrl = `/admin/announcement/${announcementId}`;
const response = await fetch(actionUrl, { method: 'DELETE' });
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) {
this.log.error('deletePost', 'failed to delete post', { postId, postTitle, error });
UIkit.modal.alert(`Failed to delete post: ${error.message}`);
UIkit.modal.alert(`Failed to delete announcement: ${error.message}`);
}
}

Loading…
Cancel
Save