+ 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 tokenmaster
parent
49f5510254
commit
42428d84a5
@ -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