diff --git a/app/controllers/admin/announcement.js b/app/controllers/admin/announcement.js index ebdf4f1..cfcd3dc 100644 --- a/app/controllers/admin/announcement.js +++ b/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 }); diff --git a/app/controllers/comment.js b/app/controllers/comment.js index a40cbaa..7935572 100644 --- a/app/controllers/comment.js +++ b/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 }); diff --git a/app/models/announcement.js b/app/models/announcement.js index 88db819..d7d8b51 100644 --- a/app/models/announcement.js +++ b/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); \ No newline at end of file diff --git a/app/models/comment.js b/app/models/comment.js index 00b7dad..2aaa3de 100644 --- a/app/models/comment.js +++ b/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 = [ diff --git a/app/models/core-user.js b/app/models/core-user.js index 812e826..04e9e46 100644 --- a/app/models/core-user.js +++ b/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({ diff --git a/app/models/lib/resource-stats.js b/app/models/lib/resource-stats.js index 1e4b401..257bfc7 100644 --- a/app/models/lib/resource-stats.js +++ b/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, }; \ No newline at end of file diff --git a/app/models/user.js b/app/models/user.js index e71b1fc..adb39a9 100644 --- a/app/models/user.js +++ b/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 }, }); diff --git a/app/services/announcement.js b/app/services/announcement.js index a5e812c..786b901 100644 --- a/app/services/announcement.js +++ b/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 }); } } diff --git a/app/services/comment.js b/app/services/comment.js index 13976e3..79bb539 100644 --- a/app/services/comment.js +++ b/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). */ diff --git a/app/services/content-vote.js b/app/services/content-vote.js index 66cef90..a611d98 100644 --- a/app/services/content-vote.js +++ b/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 }; } } diff --git a/app/services/core-node.js b/app/services/core-node.js index a26990b..b8cc039 100644 --- a/app/services/core-node.js +++ b/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, diff --git a/app/views/comment/components/comment.pug b/app/views/comment/components/comment.pug index a5769af..f2a6798 100644 --- a/app/views/comment/components/comment.pug +++ b/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", diff --git a/app/workers/reeeper.js b/app/workers/reeeper.js index 57d263d..6a0da5c 100644 --- a/app/workers/reeeper.js +++ b/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(); } diff --git a/app/workers/reeeper/cron/expire-announcements.js b/app/workers/reeeper/cron/expire-announcements.js new file mode 100644 index 0000000..648dec2 --- /dev/null +++ b/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; \ No newline at end of file diff --git a/app/workers/reeeper/cron/expire-crashed-hosts.js b/app/workers/reeeper/cron/expire-crashed-hosts.js index e85940f..2662fb2 100644 --- a/app/workers/reeeper/cron/expire-crashed-hosts.js +++ b/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 { diff --git a/client/js/site-admin-app.js b/client/js/site-admin-app.js index 833f100..6786430 100644 --- a/client/js/site-admin-app.js +++ b/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}`); } }