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 |
|||
block content |
|||
|
|||
h1 Core Nodes |
|||
a(href="/admin/core-node/connect").uk-button.uk-button-primary Connect Core |
|||
include components/list-item |
|||
|
|||
.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. |
|||
|
|||
if Array.isArray(coreNodes) && (coreNodes.length > 0) |
|||
ul.uk-list |
|||
each node in coreNodes |
|||
.uk-tile.uk-tile-default.uk-tile-small |
|||
.uk-margin |
|||
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) |
|||
a(href=`/admin/core-node/${node._id}`).uk-display-block.uk-link-reset |
|||
+renderCoreNodeListItem(node) |
|||
else |
|||
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