Browse Source
+ brought newsletter down from Sites to Base so everything can have a newsletter + brought newsletter worker to Base, added to start-local + A Core accepting a Service Node now grants a Kaleidoscope tokenpull/1/head
32 changed files with 1060 additions and 92 deletions
@ -0,0 +1,177 @@ |
|||||
|
// admin/newsletter.js
|
||||
|
// Copyright (C) 2021 Digital Telepresence, LLC
|
||||
|
// License: Apache-2.0
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
const express = require('express'); |
||||
|
|
||||
|
const { SiteController, SiteError } = require('../../../lib/site-lib'); |
||||
|
const Bull = require('bull'); |
||||
|
|
||||
|
class NewsletterController extends SiteController { |
||||
|
|
||||
|
constructor (dtp) { |
||||
|
super(dtp, module.exports); |
||||
|
} |
||||
|
|
||||
|
async start ( ) { |
||||
|
const { jobQueue: jobQueueService } = this.dtp.services; |
||||
|
const { config } = this.dtp; |
||||
|
|
||||
|
this.newsletterQueue = await jobQueueService.getJobQueue( |
||||
|
'newsletter', |
||||
|
config.jobQueues.newsletter, |
||||
|
); |
||||
|
|
||||
|
const router = express.Router(); |
||||
|
router.use(async (req, res, next) => { |
||||
|
res.locals.currentView = 'admin'; |
||||
|
res.locals.adminView = 'newsletter'; |
||||
|
return next(); |
||||
|
}); |
||||
|
|
||||
|
router.param('newsletterId', this.populateNewsletterId.bind(this)); |
||||
|
|
||||
|
router.post('/:newsletterId/transmit', this.postTransmitNewsletter.bind(this)); |
||||
|
router.post('/:newsletterId', this.postUpdateNewsletter.bind(this)); |
||||
|
router.post('/', this.postCreateNewsletter.bind(this)); |
||||
|
|
||||
|
router.get('/compose', this.getComposer.bind(this)); |
||||
|
router.get('/job-status', this.getJobStatusView.bind(this)); |
||||
|
|
||||
|
router.get('/:newsletterId', this.getComposer.bind(this)); |
||||
|
|
||||
|
router.get('/', this.getIndex.bind(this)); |
||||
|
|
||||
|
router.delete('/:newsletterId', this.deleteNewsletter.bind(this)); |
||||
|
|
||||
|
return router; |
||||
|
} |
||||
|
|
||||
|
async populateNewsletterId (req, res, next, newsletterId) { |
||||
|
const { newsletter: newsletterService } = this.dtp.services; |
||||
|
try { |
||||
|
res.locals.newsletter = await newsletterService.getById(newsletterId); |
||||
|
if (!res.locals.newsletter) { |
||||
|
throw new SiteError(404, 'Newsletter not found'); |
||||
|
} |
||||
|
return next(); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to populate newsletterId', { newsletterId, error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async postTransmitNewsletter (req, res, next) { |
||||
|
try { |
||||
|
const displayList = this.createDisplayList('transmit-newsletter'); |
||||
|
res.locals.jobData = { |
||||
|
newsletterId: res.locals.newsletter._id, |
||||
|
}; |
||||
|
this.log.info('creating newsletter transmit job', { jobData: res.locals.jobData }); |
||||
|
res.locals.job = await this.newsletterQueue.add('transmit', res.locals.jobData); |
||||
|
displayList.navigateTo(`/admin/job-queue/newsletter/${res.locals.job.id}`); |
||||
|
res.status(200).json({ success: true, displayList }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to create newsletter transmit job', { error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async postUpdateNewsletter (req, res, next) { |
||||
|
const { newsletter: newsletterService } = this.dtp.services; |
||||
|
try { |
||||
|
await newsletterService.update(res.locals.newsletter, req.body); |
||||
|
res.redirect('/admin/newsletter'); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to update newsletter', { newletterId: res.locals.newsletter._id, error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async postCreateNewsletter (req, res, next) { |
||||
|
const { newsletter: newsletterService } = this.dtp.services; |
||||
|
try { |
||||
|
await newsletterService.create(req.user, req.body); |
||||
|
res.redirect('/admin/newsletter'); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to create newsletter', { error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getComposer (req, res) { |
||||
|
res.render('admin/newsletter/editor'); |
||||
|
} |
||||
|
|
||||
|
async getJobStatusView (req, res, next) { |
||||
|
const { jobQueue: jobQueueService } = this.dtp.services; |
||||
|
try { |
||||
|
res.locals.newsletterView = 'job-status'; |
||||
|
res.locals.queueName = 'newsletter'; |
||||
|
res.locals.newsletterQueue = await jobQueueService.getJobQueue(res.locals.queueName); |
||||
|
|
||||
|
res.locals.newsletterQueue = this.newsletterQueue; |
||||
|
res.locals.jobCounts = await this.newsletterQueue.getJobCounts(); |
||||
|
res.locals.jobs = { |
||||
|
waiting: await this.newsletterQueue.getWaiting(0, 5), |
||||
|
active: await this.newsletterQueue.getActive(0, 5), |
||||
|
delayed: await this.newsletterQueue.getDelayed(0, 5), |
||||
|
failed: await this.newsletterQueue.getFailed(0, 5), |
||||
|
}; |
||||
|
res.render('admin/newsletter/job-status'); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to render job status view', { error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getIndex (req, res, next) { |
||||
|
const { newsletter: newsletterService } = this.dtp.services; |
||||
|
try { |
||||
|
res.locals.newsletterView = 'index'; |
||||
|
res.locals.pagination = this.getPaginationParameters(req, 20); |
||||
|
res.locals.newsletters = await newsletterService.getNewsletters(res.locals.pagination, ['draft', 'published']); |
||||
|
res.render('admin/newsletter/index'); |
||||
|
} catch (error) { |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async deleteNewsletter (req, res) { |
||||
|
const { newsletter: newsletterService } = this.dtp.services; |
||||
|
try { |
||||
|
const displayList = this.createDisplayList('delete-newsletter'); |
||||
|
|
||||
|
await newsletterService.deleteNewsletter(res.locals.newsletter); |
||||
|
|
||||
|
displayList.removeElement(`li[data-newsletter-id="${res.locals.newsletter._id}"]`); |
||||
|
displayList.showNotification( |
||||
|
`Newsletter "${res.locals.newsletter.title}" deleted`, |
||||
|
'success', |
||||
|
'bottom-center', |
||||
|
3000, |
||||
|
); |
||||
|
res.status(200).json({ success: true, displayList }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to delete newsletter', { |
||||
|
newsletterId: res.local.newsletter._id, |
||||
|
error, |
||||
|
}); |
||||
|
res.status(error.statusCode || 500).json({ |
||||
|
success: false, |
||||
|
message: error.message, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
module.exports = { |
||||
|
name: 'adminNewsletter', |
||||
|
slug: 'admin-newsletter', |
||||
|
create: async (dtp) => { |
||||
|
let controller = new NewsletterController(dtp); |
||||
|
return controller; |
||||
|
}, |
||||
|
}; |
@ -0,0 +1,102 @@ |
|||||
|
// newsletter.js
|
||||
|
// Copyright (C) 2021 Digital Telepresence, LLC
|
||||
|
// License: Apache-2.0
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
const express = require('express'); |
||||
|
const multer = require('multer'); |
||||
|
|
||||
|
const { SiteController } = require('../../lib/site-lib'); |
||||
|
|
||||
|
class NewsletterController extends SiteController { |
||||
|
|
||||
|
constructor (dtp) { |
||||
|
super(dtp, module.exports); |
||||
|
} |
||||
|
|
||||
|
async start ( ) { |
||||
|
const { dtp } = this; |
||||
|
const { limiter: limiterService } = dtp.services; |
||||
|
|
||||
|
const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${module.exports.slug}` }); |
||||
|
|
||||
|
const router = express.Router(); |
||||
|
dtp.app.use('/newsletter', router); |
||||
|
|
||||
|
router.use(async (req, res, next) => { |
||||
|
res.locals.currentView = module.exports.slug; |
||||
|
return next(); |
||||
|
}); |
||||
|
|
||||
|
router.param('newsletterId', this.populateNewsletterId.bind(this)); |
||||
|
|
||||
|
router.post('/', upload.none(), this.postAddRecipient.bind(this)); |
||||
|
|
||||
|
router.get('/:newsletterId', |
||||
|
limiterService.create(limiterService.config.newsletter.getView), |
||||
|
this.getView.bind(this), |
||||
|
); |
||||
|
|
||||
|
router.get('/', |
||||
|
limiterService.create(limiterService.config.newsletter.getIndex), |
||||
|
this.getIndex.bind(this), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
async populateNewsletterId (req, res, next, newsletterId) { |
||||
|
const { newsletter: newsletterService } = this.dtp.services; |
||||
|
try { |
||||
|
res.locals.newsletter = await newsletterService.getById(newsletterId); |
||||
|
return next(); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to populate newsletterId', { newsletterId, error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async postAddRecipient (req, res) { |
||||
|
const { newsletter: newsletterService } = this.dtp.services; |
||||
|
try { |
||||
|
const displayList = this.createDisplayList('add-recipient'); |
||||
|
await newsletterService.addRecipient(req.body.email); |
||||
|
displayList.showNotification( |
||||
|
'You have been added to the newsletter. Please check your email and verify your email address.', |
||||
|
'success', |
||||
|
'bottom-center', |
||||
|
10000, |
||||
|
); |
||||
|
res.status(200).json({ success: true, displayList }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to update account settings', { error }); |
||||
|
return res.status(error.statusCode || 500).json({ |
||||
|
success: false, |
||||
|
message: error.message, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getView (req, res) { |
||||
|
res.render('newsletter/view'); |
||||
|
} |
||||
|
|
||||
|
async getIndex (req, res, next) { |
||||
|
const { newsletter: newsletterService } = this.dtp.services; |
||||
|
try { |
||||
|
res.locals.pagination = this.getPaginationParameters(req, 20); |
||||
|
res.locals.newsletters = await newsletterService.getNewsletters(res.locals.pagination); |
||||
|
res.render('newsletter/index'); |
||||
|
} catch (error) { |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
module.exports = { |
||||
|
slug: 'newsletter', |
||||
|
name: 'newsletter', |
||||
|
create: async (dtp) => { |
||||
|
let controller = new NewsletterController(dtp); |
||||
|
return controller; |
||||
|
}, |
||||
|
}; |
@ -0,0 +1,21 @@ |
|||||
|
// newsletter-recipient.js
|
||||
|
// Copyright (C) 2022 DTP Technologies, LLC
|
||||
|
// License: Apache-2.0
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
const mongoose = require('mongoose'); |
||||
|
const Schema = mongoose.Schema; |
||||
|
|
||||
|
const NewsletterRecipientSchema = new Schema({ |
||||
|
created: { type: Date, default: Date.now, required: true, index: 1 }, |
||||
|
address: { type: String, required: true }, |
||||
|
address_lc: { type: String, required: true, lowercase: true, unique: true, index: 1 }, |
||||
|
flags: { |
||||
|
isVerified: { type: Boolean, default: false, required: true, index: 1 }, |
||||
|
isOptIn: { type: Boolean, default: false, required: true, index: 1 }, |
||||
|
isRejected: { type: Boolean, default: false, required: true, index: 1 }, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
module.exports = mongoose.model('NewsletterRecipient', NewsletterRecipientSchema); |
@ -0,0 +1,31 @@ |
|||||
|
// newsletter.js
|
||||
|
// Copyright (C) 2022 DTP Technologies, LLC
|
||||
|
// License: Apache-2.0
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
const mongoose = require('mongoose'); |
||||
|
|
||||
|
const Schema = mongoose.Schema; |
||||
|
|
||||
|
const NEWSLETTER_STATUS_LIST = ['draft', 'published', 'archived']; |
||||
|
|
||||
|
const NewsletterSchema = new Schema({ |
||||
|
created: { type: Date, default: Date.now, required: true, index: -1 }, |
||||
|
author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
||||
|
title: { type: String, required: true }, |
||||
|
summary: { type: String }, |
||||
|
content: { |
||||
|
html: { type: String, required: true, select: false, }, |
||||
|
text: { type: String, required: true, select: false, }, |
||||
|
}, |
||||
|
status: { |
||||
|
type: String, |
||||
|
enum: NEWSLETTER_STATUS_LIST, |
||||
|
default: 'draft', |
||||
|
required: true, |
||||
|
index: true, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
module.exports = mongoose.model('Newsletter', NewsletterSchema); |
@ -0,0 +1,123 @@ |
|||||
|
// newsletter.js
|
||||
|
// Copyright (C) 2022 DTP Technologies, LLC
|
||||
|
// License: Apache-2.0
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
const striptags = require('striptags'); |
||||
|
|
||||
|
const { SiteService } = require('../../lib/site-lib'); |
||||
|
|
||||
|
const mongoose = require('mongoose'); |
||||
|
|
||||
|
const Newsletter = mongoose.model('Newsletter'); |
||||
|
const NewsletterRecipient = mongoose.model('NewsletterRecipient'); |
||||
|
|
||||
|
class NewsletterService extends SiteService { |
||||
|
|
||||
|
constructor (dtp) { |
||||
|
super(dtp, module.exports); |
||||
|
|
||||
|
this.populateNewsletter = [ |
||||
|
{ |
||||
|
path: 'author', |
||||
|
select: '_id username username_lc displayName picture', |
||||
|
}, |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
async create (author, newsletterDefinition) { |
||||
|
const NOW = new Date(); |
||||
|
|
||||
|
const newsletter = new Newsletter(); |
||||
|
newsletter.created = NOW; |
||||
|
newsletter.author = author._id; |
||||
|
newsletter.title = striptags(newsletterDefinition.title.trim()); |
||||
|
newsletter.summary = striptags(newsletterDefinition.summary.trim()); |
||||
|
newsletter.content.html = newsletterDefinition['content.html'].trim(); |
||||
|
newsletter.content.text = striptags(newsletterDefinition['content.text'].trim()); |
||||
|
newsletter.status = 'draft'; |
||||
|
|
||||
|
await newsletter.save(); |
||||
|
|
||||
|
return newsletter.toObject(); |
||||
|
} |
||||
|
|
||||
|
async update (newsletter, newsletterDefinition) { |
||||
|
const updateOp = { $set: { } }; |
||||
|
|
||||
|
if (newsletterDefinition.title) { |
||||
|
updateOp.$set.title = striptags(newsletterDefinition.title.trim()); |
||||
|
} |
||||
|
if (newsletterDefinition.summary) { |
||||
|
updateOp.$set.summary = striptags(newsletterDefinition.summary.trim()); |
||||
|
} |
||||
|
if (newsletterDefinition['content.html']) { |
||||
|
updateOp.$set['content.html'] = newsletterDefinition['content.html'].trim(); |
||||
|
} |
||||
|
if (newsletterDefinition['content.text']) { |
||||
|
updateOp.$set['content.text'] = striptags(newsletterDefinition['content.text'].trim()); |
||||
|
} |
||||
|
if (newsletterDefinition.status) { |
||||
|
updateOp.$set.status = striptags(newsletterDefinition.status.trim()); |
||||
|
} |
||||
|
|
||||
|
if (Object.keys(updateOp.$set).length === 0) { |
||||
|
return; // no update to perform
|
||||
|
} |
||||
|
|
||||
|
await Newsletter.updateOne( |
||||
|
{ _id: newsletter._id }, |
||||
|
updateOp, |
||||
|
{ upsert: true }, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
async getNewsletters (pagination, status = ['published']) { |
||||
|
if (!Array.isArray(status)) { |
||||
|
status = [status]; |
||||
|
} |
||||
|
const newsletters = await Newsletter |
||||
|
.find({ status: { $in: status } }) |
||||
|
.sort({ created: -1 }) |
||||
|
.skip(pagination.skip) |
||||
|
.limit(pagination.cpp) |
||||
|
.lean(); |
||||
|
return newsletters; |
||||
|
} |
||||
|
|
||||
|
async getById (newsletterId) { |
||||
|
const newsletter = await Newsletter |
||||
|
.findById(newsletterId) |
||||
|
.select('+content.html +content.text') |
||||
|
.populate(this.populateNewsletter) |
||||
|
.lean(); |
||||
|
return newsletter; |
||||
|
} |
||||
|
|
||||
|
async addRecipient (emailAddress) { |
||||
|
const { email: emailService } = this.dtp.services; |
||||
|
const NOW = new Date(); |
||||
|
|
||||
|
await emailService.checkEmailAddress(emailAddress); |
||||
|
|
||||
|
const recipient = new NewsletterRecipient(); |
||||
|
recipient.created = NOW; |
||||
|
recipient.address = striptags(emailAddress.trim()); |
||||
|
recipient.address_lc = recipient.address.toLowerCase(); |
||||
|
await recipient.save(); |
||||
|
|
||||
|
return recipient.toObject(); |
||||
|
} |
||||
|
|
||||
|
async deleteNewsletter (newsletter) { |
||||
|
this.log.info('deleting newsletter', { newsletterId: newsletter._id }); |
||||
|
await Newsletter.deleteOne({ _id: newsletter._id }); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
module.exports = { |
||||
|
slug: 'newsletter', |
||||
|
name: 'newsletter', |
||||
|
create: (dtp) => { return new NewsletterService(dtp); }, |
||||
|
}; |
@ -0,0 +1,29 @@ |
|||||
|
mixin renderCoreNodeListItem (coreNode) |
||||
|
.uk-tile.uk-tile-default.uk-tile-small.uk-padding-small.dtp-border.uk-border-rounded |
||||
|
.uk-margin |
||||
|
div(uk-grid).uk-flex-between |
||||
|
.uk-width-auto |
||||
|
+renderCell('Name', coreNode.meta.name) |
||||
|
.uk-width-auto |
||||
|
+renderCell('Admin', coreNode.meta.admin) |
||||
|
.uk-width-auto |
||||
|
+renderCell('Domain', coreNode.meta.domain) |
||||
|
.uk-width-auto |
||||
|
+renderCell('Domain Key', coreNode.meta.domainKey) |
||||
|
|
||||
|
.uk-margin |
||||
|
div(uk-grid).uk-flex-between |
||||
|
.uk-width-auto |
||||
|
+renderCell('Connected', moment(coreNode.created).format('MMM DD, YYYY')) |
||||
|
.uk-width-auto |
||||
|
+renderCell('Updated', moment(coreNode.updated).format('MMM DD, YYYY')) |
||||
|
.uk-width-auto |
||||
|
+renderCell('Version', coreNode.meta.version) |
||||
|
.uk-width-auto |
||||
|
+renderCell('Host', coreNode.address.host) |
||||
|
.uk-width-auto |
||||
|
+renderCell('Port', coreNode.address.port) |
||||
|
.uk-width-auto |
||||
|
+renderCell('Connected', coreNode.flags.isConnected) |
||||
|
.uk-width-auto |
||||
|
+renderCell('Blocked', coreNode.flags.isBlocked) |
@ -1,43 +1,21 @@ |
|||||
extends ../layouts/main |
extends ../layouts/main |
||||
block content |
block content |
||||
|
|
||||
h1 Core Nodes |
include components/list-item |
||||
a(href="/admin/core-node/connect").uk-button.uk-button-primary Connect Core |
|
||||
|
.uk-margin |
||||
|
div(uk-grid) |
||||
|
.uk-width-expand |
||||
|
h1(style="line-height: 1em;").uk-margin-remove.uk-text-truncate Core Nodes |
||||
|
.uk-width-auto |
||||
|
a(href="/admin/core-node/connect").uk-button.uk-button-primary.uk-border-rounded Connect Core |
||||
|
|
||||
p You can register with one or more Core nodes to exchange information with those nodes. |
p You can register with one or more Core nodes to exchange information with those nodes. |
||||
|
|
||||
if Array.isArray(coreNodes) && (coreNodes.length > 0) |
if Array.isArray(coreNodes) && (coreNodes.length > 0) |
||||
ul.uk-list |
ul.uk-list |
||||
each node in coreNodes |
each node in coreNodes |
||||
.uk-tile.uk-tile-default.uk-tile-small |
a(href=`/admin/core-node/${node._id}`).uk-display-block.uk-link-reset |
||||
.uk-margin |
+renderCoreNodeListItem(node) |
||||
div(uk-grid) |
|
||||
.uk-width-auto |
|
||||
+renderCell('Name', node.meta.name) |
|
||||
.uk-width-auto |
|
||||
+renderCell('Domain', node.meta.domain) |
|
||||
.uk-width-auto |
|
||||
+renderCell('Domain Key', node.meta.domainKey) |
|
||||
.uk-width-auto |
|
||||
+renderCell('Connected', moment(node.created).format('MMM DD, YYYY')) |
|
||||
.uk-width-auto |
|
||||
+renderCell('Updated', moment(node.updated).format('MMM DD, YYYY')) |
|
||||
.uk-width-auto |
|
||||
+renderCell('Version', node.meta.version) |
|
||||
|
|
||||
.uk-margin |
|
||||
div(uk-grid) |
|
||||
.uk-width-auto |
|
||||
+renderCell('Host', node.address.host) |
|
||||
.uk-width-auto |
|
||||
+renderCell('Port', node.address.port) |
|
||||
.uk-width-auto |
|
||||
+renderCell('Connected', node.flags.isConnected) |
|
||||
.uk-width-auto |
|
||||
+renderCell('Blocked', node.flags.isBlocked) |
|
||||
.uk-width-auto |
|
||||
+renderCell('Admin', node.meta.admin) |
|
||||
.uk-width-auto |
|
||||
+renderCell('Support', node.meta.supportEmail) |
|
||||
else |
else |
||||
p There are no registered core nodes. |
p There are no registered core nodes. |
@ -0,0 +1,46 @@ |
|||||
|
extends ../layouts/main |
||||
|
block content |
||||
|
|
||||
|
include ../../components/pagination-bar |
||||
|
include components/list-item |
||||
|
|
||||
|
.uk-margin |
||||
|
div(uk-grid).uk-grid-small.uk-flex-middle |
||||
|
div(class="uk-width-1-1 uk-width-expand@m") |
||||
|
h1(style="line-height: 1em;") Core Node |
||||
|
div(class="uk-width-1-1 uk-width-auto@m") |
||||
|
a(href=`mailto:${coreNode.meta.supportEmail}?subject=${encodeURIComponent(`Support request from ${site.name}`)}`) |
||||
|
span |
||||
|
i.fas.fa-envelope |
||||
|
span.uk-margin-small-left Email Support |
||||
|
div(class="uk-width-1-1 uk-width-auto@m") |
||||
|
span.uk-label(style="line-height: 1.75em;", class={ |
||||
|
'uk-label-success': coreNode.flags.isConnected, |
||||
|
'uk-label-warning': !coreNode.flags.isConnected && !coreNode.flags.isBlocked, |
||||
|
'uk-label-danger': coreNode.flags.isBlocked, |
||||
|
}).no-select= coreNode.flags.isConnected ? 'Connected' : 'Pending' |
||||
|
|
||||
|
+renderCoreNodeListItem(coreNode) |
||||
|
|
||||
|
.uk-margin |
||||
|
table.uk-table.uk-table-small |
||||
|
thead |
||||
|
tr |
||||
|
th Timestamp |
||||
|
th Method |
||||
|
th URL |
||||
|
th Status |
||||
|
th Result |
||||
|
th Perf |
||||
|
tbody |
||||
|
each request in requestHistory.requests |
||||
|
tr |
||||
|
td= moment(request.created).format('YYYY-MM-DD HH:mm:ss.SSS') |
||||
|
td= request.method |
||||
|
td= request.url |
||||
|
td= (request.response && request.response.statusCode) ? request.response.statusCode : '- - -' |
||||
|
td= (request.response) ? ((request.response.success) ? 'success' : 'fail') : '- - -' |
||||
|
td= request.response ? `${numeral(request.response.elapsed).format('0,0')}ms` : '- - -' |
||||
|
|
||||
|
.uk-margin |
||||
|
+renderPaginationBar(`/admin/core-node/${coreNode._id}`, requestHistory.totalRequestCount) |
@ -0,0 +1,18 @@ |
|||||
|
mixin renderJobQueueJobList (jobQueue, jobList) |
||||
|
if !Array.isArray(jobList) || (jobList.length === 0) |
||||
|
div No jobs |
||||
|
else |
||||
|
table.uk-table.uk-table-small |
||||
|
thead |
||||
|
th ID |
||||
|
th Name |
||||
|
th Attempts |
||||
|
th Progress |
||||
|
tbody |
||||
|
each job in jobList |
||||
|
tr |
||||
|
td= job.id |
||||
|
td |
||||
|
a(href=`/admin/job-queue/${jobQueue.name}/${job.id}`)= job.name |
||||
|
td= job.attemptsMade |
||||
|
td #{job.progress()}% |
@ -0,0 +1,67 @@ |
|||||
|
extends ../layouts/main |
||||
|
block content |
||||
|
|
||||
|
- var actionUrl = newsletter ? `/admin/newsletter/${newsletter._id}` : `/admin/newsletter`; |
||||
|
|
||||
|
form(method="POST", action= actionUrl).uk-form |
||||
|
.uk-margin |
||||
|
label(for="title").uk-form-label.sr-only Newsletter title |
||||
|
input(id="title", name="title", type="text", placeholder= "Enter newsletter title", value= newsletter ? newsletter.title : undefined, required).uk-input |
||||
|
|
||||
|
.uk-margin |
||||
|
label(for="summary").uk-form-label.sr-only Newsletter summary |
||||
|
textarea(id="summary", name="summary", rows="4", placeholder= "Enter newsletter summary (text only, no HTML)", required).uk-textarea= newsletter ? newsletter.summary : undefined |
||||
|
|
||||
|
.uk-margin |
||||
|
label(for="content-html").uk-form-label.sr-only Newsletter HTML body |
||||
|
textarea(id="content-html", name="content.html", rows="4").uk-textarea= newsletter ? newsletter.content.html : undefined |
||||
|
|
||||
|
.uk-margin |
||||
|
button(type="button", onclick="return dtp.app.copyHtmlToText(event, 'content-text');").uk-button.dtp-button-default Copy HTML to Text |
||||
|
|
||||
|
.uk-margin |
||||
|
label(for="content-text").uk-form-label.sr-only Newsletter text body |
||||
|
textarea(id="content-text", name="content.text", rows="4", placeholder= "Enter text-only version of newsletter.", required).uk-textarea= newsletter ? newsletter.content.text : undefined |
||||
|
|
||||
|
button(type="submit").uk-button.dtp-button-primary= newsletter ? 'Update newsletter' : 'Save newsletter' |
||||
|
|
||||
|
block viewjs |
||||
|
script(src="/tinymce/tinymce.min.js") |
||||
|
script. |
||||
|
const useDarkMode = document.body.classList.contains('dtp-dark'); |
||||
|
window.addEventListener('dtp-load', async ( ) => { |
||||
|
const toolbarItems = [ |
||||
|
'undo redo', |
||||
|
'blocks visualblocks', |
||||
|
'bold italic backcolor', |
||||
|
'alignleft aligncenter alignright alignjustify', |
||||
|
'bullist numlist outdent indent removeformat', |
||||
|
'link image media code', |
||||
|
'help' |
||||
|
]; |
||||
|
const pluginItems = [ |
||||
|
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', |
||||
|
'preview', 'anchor', 'searchreplace', 'visualblocks', 'code', |
||||
|
'fullscreen', 'insertdatetime', 'media', 'table', |
||||
|
'help', 'wordcount', |
||||
|
] |
||||
|
|
||||
|
const editors = await tinymce.init({ |
||||
|
selector: 'textarea#content-html', |
||||
|
height: 500, |
||||
|
menubar: false, |
||||
|
plugins: pluginItems.join(' '), |
||||
|
toolbar: toolbarItems.join('|'), |
||||
|
branding: false, |
||||
|
images_upload_url: '/image/tinymce', |
||||
|
image_class_list: [ |
||||
|
{ title: 'Body Image', value: 'dtp-image-body' }, |
||||
|
{ title: 'Title Image', value: 'dtp-image-title' }, |
||||
|
], |
||||
|
convert_urls: false, |
||||
|
skin: useDarkMode ? "oxide-dark" : "oxide", |
||||
|
content_css: useDarkMode ? "dark" : "default", |
||||
|
}); |
||||
|
|
||||
|
window.dtp.app.editor = editors[0]; |
||||
|
}); |
@ -0,0 +1,36 @@ |
|||||
|
extends ../layouts/main |
||||
|
block content |
||||
|
|
||||
|
.uk-margin |
||||
|
h1(style="line-height: 1em;").uk-text-truncate.uk-margin-remove Newsletters |
||||
|
|
||||
|
.uk-margin |
||||
|
if (Array.isArray(newsletters) && (newsletters.length > 0)) |
||||
|
ul.uk-list |
||||
|
each newsletter in newsletters |
||||
|
li(data-newsletter-id= newsletter._id) |
||||
|
div(uk-grid).uk-grid-small.uk-flex-middle |
||||
|
.uk-width-expand |
||||
|
a(href=`/admin/newsletter/${newsletter._id}`).uk-display-block.uk-text-large.uk-text-truncate= newsletter.title |
||||
|
|
||||
|
.uk-width-auto |
||||
|
div(uk-grid).uk-grid-small |
||||
|
.uk-width-auto |
||||
|
button( |
||||
|
type="button", |
||||
|
data-newsletter-id= newsletter._id, |
||||
|
data-newsletter-title= newsletter.title, |
||||
|
onclick="return dtp.adminApp.deleteNewsletter(event);", |
||||
|
).uk-button.uk-button-danger |
||||
|
+renderButtonIcon('fa-trash', 'Delete') |
||||
|
|
||||
|
.uk-width-auto |
||||
|
button( |
||||
|
type="button", |
||||
|
data-newsletter-id= newsletter._id, |
||||
|
data-newsletter-title= newsletter.title, |
||||
|
onclick="return dtp.adminApp.sendNewsletter(event);", |
||||
|
).uk-button.uk-button-default |
||||
|
+renderButtonIcon('fa-paper-plane', 'Send') |
||||
|
else |
||||
|
div There are no newsletters at this time. |
@ -0,0 +1,45 @@ |
|||||
|
extends ../layouts/main |
||||
|
block content |
||||
|
|
||||
|
include ../job-queue/components/job-list |
||||
|
|
||||
|
.uk-margin |
||||
|
h1 Job Queue: #{queueName} |
||||
|
div(uk-grid).uk-flex-between |
||||
|
- var pendingJobCount = jobCounts.waiting + jobCounts.delayed + jobCounts.paused + jobCounts.active |
||||
|
.uk-width-auto Total#[br]#{numeral(pendingJobCount).format('0,0')} |
||||
|
.uk-width-auto Waiting#[br]#{numeral(jobCounts.waiting).format('0,0')} |
||||
|
.uk-width-auto Delayed#[br]#{numeral(jobCounts.delayed).format('0,0')} |
||||
|
.uk-width-auto Paused#[br]#{numeral(jobCounts.paused).format('0,0')} |
||||
|
.uk-width-auto Active#[br]#{numeral(jobCounts.active).format('0,0')} |
||||
|
.uk-width-auto Completed#[br]#{numeral(jobCounts.completed).format('0,0')} |
||||
|
.uk-width-auto Failed#[br]#{numeral(jobCounts.failed).format('0,0')} |
||||
|
|
||||
|
div(uk-grid) |
||||
|
div(class="uk-width-1-1 uk-width-1-2@l") |
||||
|
.uk-card.uk-card-default.uk-card-small |
||||
|
.uk-card-header |
||||
|
h3.uk-card-title Active |
||||
|
.uk-card-body |
||||
|
+renderJobQueueJobList(newsletterQueue, jobs.active) |
||||
|
|
||||
|
div(class="uk-width-1-1 uk-width-1-2@l") |
||||
|
.uk-card.uk-card-default.uk-card-small |
||||
|
.uk-card-header |
||||
|
h3.uk-card-title Waiting |
||||
|
.uk-card-body |
||||
|
+renderJobQueueJobList(newsletterQueue, jobs.waiting) |
||||
|
|
||||
|
div(class="uk-width-1-1 uk-width-1-2@l") |
||||
|
.uk-card.uk-card-default.uk-card-small |
||||
|
.uk-card-header |
||||
|
h3.uk-card-title Delayed |
||||
|
.uk-card-body |
||||
|
+renderJobQueueJobList(newsletterQueue, jobs.delayed) |
||||
|
|
||||
|
div(class="uk-width-1-1 uk-width-1-2@l") |
||||
|
.uk-card.uk-card-default.uk-card-small |
||||
|
.uk-card-header |
||||
|
h3.uk-card-title Failed |
||||
|
.uk-card-body |
||||
|
+renderJobQueueJobList(newsletterQueue, jobs.failed) |
@ -0,0 +1,15 @@ |
|||||
|
extends ../layouts/main |
||||
|
block content |
||||
|
|
||||
|
section.uk-section.uk-section-default |
||||
|
.uk-container |
||||
|
|
||||
|
h1 #{site.name} Newsletters |
||||
|
|
||||
|
if Array.isArray(newsletters) && (newsletters.length > 0) |
||||
|
ul.uk-list |
||||
|
each newsletter of newsletters |
||||
|
li |
||||
|
a(href=`/newsletter/${newsletter._id}`).uk-link-reset= newsletter.title |
||||
|
else |
||||
|
div There are no newsletters at this time. Please check back later. |
@ -0,0 +1,155 @@ |
|||||
|
// newsletter.js
|
||||
|
// Copyright (C) 2022 DTP Technologies, LLC
|
||||
|
// License: Apache-2.0
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
const DTP_COMPONENT = { name: 'newsletter', slug: 'newsletter' }; |
||||
|
|
||||
|
const path = require('path'); |
||||
|
require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); |
||||
|
|
||||
|
const mongoose = require('mongoose'); |
||||
|
|
||||
|
const { SiteWorker, SiteLog } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); |
||||
|
|
||||
|
module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); |
||||
|
module.config = { |
||||
|
component: DTP_COMPONENT, |
||||
|
root: path.resolve(__dirname, '..', '..'), |
||||
|
}; |
||||
|
|
||||
|
class NewsletterWorker extends SiteWorker { |
||||
|
|
||||
|
constructor (dtp) { |
||||
|
super(dtp, dtp.config.component); |
||||
|
this.newsletters = this.newsletters || { }; |
||||
|
} |
||||
|
|
||||
|
async start ( ) { |
||||
|
await super.start(); |
||||
|
|
||||
|
const { jobQueue: jobQueueService } = this.dtp.services; |
||||
|
this.jobQueue = await jobQueueService.getJobQueue('newsletter', { |
||||
|
attempts: 3, |
||||
|
}); |
||||
|
this.jobQueue.process('transmit', this.transmitNewsletter.bind(this)); |
||||
|
this.jobQueue.process('email-send', this.sendNewsletterEmail.bind(this)); |
||||
|
} |
||||
|
|
||||
|
async stop ( ) { |
||||
|
if (this.jobQueue) { |
||||
|
this.log.info('stopping newsletter job queue'); |
||||
|
await this.jobQueue.close(); |
||||
|
delete this.jobQueue; |
||||
|
} |
||||
|
await super.stop(); |
||||
|
} |
||||
|
|
||||
|
async loadNewsletter (newsletterId) { |
||||
|
const { newsletter: newsletterService } = this.dtp.services; |
||||
|
let newsletter = this.newsletters[newsletterId]; |
||||
|
if (!newsletter) { |
||||
|
newsletter = await newsletterService.getById(newsletterId); |
||||
|
this.newsletters[newsletterId] = newsletter; |
||||
|
} |
||||
|
return newsletter; |
||||
|
} |
||||
|
|
||||
|
async transmitNewsletter (job) { |
||||
|
const User = mongoose.model('User'); |
||||
|
const NewsletterRecipient = mongoose.model('NewsletterRecipient'); |
||||
|
this.log.info('newsletter email job received', { data: job.data }); |
||||
|
try { |
||||
|
/* |
||||
|
* Transmit first to all local user accounts with verified email who've |
||||
|
* opted in for receiving marketing email. |
||||
|
*/ |
||||
|
await User |
||||
|
.find({ |
||||
|
'flags.isEmailVerified': true, |
||||
|
'optIn.marketing': true, |
||||
|
}) |
||||
|
.select('email displayName username username_lc') |
||||
|
.lean() |
||||
|
.cursor() |
||||
|
.eachAsync(async (user) => { |
||||
|
try { |
||||
|
const jobData = { |
||||
|
newsletterId: job.data.newsletterId, |
||||
|
recipient: user.email, |
||||
|
recipientName: user.displayName || user.username, |
||||
|
}; |
||||
|
const jobOptions = { attempts: 3 }; |
||||
|
await this.jobQueue.add('email-send', jobData, jobOptions); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to create newsletter email job', { error }); |
||||
|
} |
||||
|
}, { parallel: 4 }); |
||||
|
|
||||
|
/* |
||||
|
* Transmit to all newsletter recipients on file who've joined through the |
||||
|
* widget on the site w/o signing up for an account. |
||||
|
*/ |
||||
|
await NewsletterRecipient |
||||
|
.find({ 'flags.isVerified': true, 'flags.isOptIn': true, 'flags.isRejected': false }) |
||||
|
.lean() |
||||
|
.cursor() |
||||
|
.eachAsync(async (recipient) => { |
||||
|
try { |
||||
|
const jobData = { |
||||
|
newsletterId: job.data.newsletterId, |
||||
|
recipient: recipient.address, |
||||
|
}; |
||||
|
const jobOptions = { attempts: 3 }; |
||||
|
await this.jobQueue.add('email-send', jobData, jobOptions); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to create newsletter email job', { error }); |
||||
|
} |
||||
|
}, { parallel: 4 }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to send newsletter', { newsletterId: job.data.newsletterId, error }); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async sendNewsletterEmail (job) { |
||||
|
const { email: emailService } = this.dtp.services; |
||||
|
const { newsletterId, recipient } = job.data; |
||||
|
|
||||
|
try { |
||||
|
let newsletter = await this.loadNewsletter(newsletterId); |
||||
|
if (!newsletter) { |
||||
|
throw new Error('newsletter not found'); |
||||
|
} |
||||
|
|
||||
|
const result = await emailService.send({ |
||||
|
from: process.env.DTP_EMAIL_SMTP_FROM || `noreply@${this.dtp.config.site.domainKey}`, |
||||
|
to: recipient, |
||||
|
subject: newsletter.title, |
||||
|
html: newsletter.content.html, |
||||
|
text: newsletter.content.text, |
||||
|
}); |
||||
|
|
||||
|
job.log(`newsletter email sent: ${result}`); |
||||
|
this.log.info('newsletter email sent', { recipient, result }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to send newsletter email', { newsletterId, recipient, error }); |
||||
|
throw error; // throw error to Bull so it can report in job reports
|
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
(async ( ) => { |
||||
|
try { |
||||
|
module.log = new SiteLog(module, module.config.component); |
||||
|
|
||||
|
module.worker = new NewsletterWorker(module); |
||||
|
await module.worker.start(); |
||||
|
|
||||
|
module.log.info(`${module.pkg.name} v${module.pkg.version} Newsletter worker started`); |
||||
|
} catch (error) { |
||||
|
module.log.error('failed to start Newsletter worker', { error }); |
||||
|
process.exit(-1); |
||||
|
} |
||||
|
})(); |
Loading…
Reference in new issue