From 42428d84a5a056ff8e821820498e023532cd03e7 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 15 Jul 2022 17:27:27 -0400 Subject: [PATCH] newsletter and Kaleidoscope token grant + 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 token --- app/controllers/admin.js | 11 +- app/controllers/admin/core-node.js | 31 ++- app/controllers/admin/newsletter.js | 177 ++++++++++++++++++ app/controllers/newsletter.js | 102 ++++++++++ app/models/core-node-request.js | 3 +- app/models/core-node.js | 3 + app/models/newsletter-recipient.js | 21 +++ app/models/newsletter.js | 31 +++ app/models/oauth2-client.js | 3 + app/services/core-node.js | 58 ++++-- app/services/email.js | 6 +- app/services/newsletter.js | 123 ++++++++++++ app/services/oauth2.js | 3 + app/views/admin/components/menu.pug | 37 +++- app/views/admin/content-report/index.pug | 2 +- .../admin/core-node/components/list-item.pug | 29 +++ app/views/admin/core-node/connect.pug | 2 +- app/views/admin/core-node/index.pug | 42 +---- app/views/admin/core-node/view.pug | 46 +++++ .../admin/job-queue/components/job-list.pug | 18 ++ app/views/admin/job-queue/queue-view.pug | 27 +-- app/views/admin/newsletter/editor.pug | 67 +++++++ app/views/admin/newsletter/index.pug | 36 ++++ app/views/admin/newsletter/job-status.pug | 45 +++++ app/views/admin/service-node/index.pug | 9 +- app/views/components/library.pug | 2 +- app/views/newsletter/index.pug | 15 ++ app/workers/newsletter.js | 155 +++++++++++++++ client/js/site-admin-app.js | 23 +++ config/job-queues.js | 7 +- config/limiter.js | 16 ++ start-local | 2 + 32 files changed, 1060 insertions(+), 92 deletions(-) create mode 100644 app/controllers/admin/newsletter.js create mode 100644 app/controllers/newsletter.js create mode 100644 app/models/newsletter-recipient.js create mode 100644 app/models/newsletter.js create mode 100644 app/services/newsletter.js create mode 100644 app/views/admin/core-node/components/list-item.pug create mode 100644 app/views/admin/core-node/view.pug create mode 100644 app/views/admin/job-queue/components/job-list.pug create mode 100644 app/views/admin/newsletter/editor.pug create mode 100644 app/views/admin/newsletter/index.pug create mode 100644 app/views/admin/newsletter/job-status.pug create mode 100644 app/views/newsletter/index.pug create mode 100644 app/workers/newsletter.js diff --git a/app/controllers/admin.js b/app/controllers/admin.js index ccc30ed..f8ae0db 100644 --- a/app/controllers/admin.js +++ b/app/controllers/admin.js @@ -42,14 +42,15 @@ class AdminController extends SiteController { }), ); - router.use('/content-report',await this.loadChild(path.join(__dirname, 'admin', 'content-report'))); - router.use('/core-node',await this.loadChild(path.join(__dirname, 'admin', 'core-node'))); - router.use('/core-user',await this.loadChild(path.join(__dirname, 'admin', 'core-user'))); - router.use('/host',await this.loadChild(path.join(__dirname, 'admin', 'host'))); + router.use('/content-report', await this.loadChild(path.join(__dirname, 'admin', 'content-report'))); + router.use('/core-node', await this.loadChild(path.join(__dirname, 'admin', 'core-node'))); + router.use('/core-user', await this.loadChild(path.join(__dirname, 'admin', 'core-user'))); + router.use('/host', await this.loadChild(path.join(__dirname, 'admin', 'host'))); router.use('/job-queue', await this.loadChild(path.join(__dirname, 'admin', 'job-queue'))); router.use('/log', await this.loadChild(path.join(__dirname, 'admin', 'log'))); + router.use('/newsletter', await this.loadChild(path.join(__dirname, 'admin', 'newsletter'))); router.use('/settings', await this.loadChild(path.join(__dirname, 'admin', 'settings'))); - router.use('/service-node',await this.loadChild(path.join(__dirname, 'admin', 'service-node'))); + router.use('/service-node', await this.loadChild(path.join(__dirname, 'admin', 'service-node'))); router.use('/user', await this.loadChild(path.join(__dirname, 'admin', 'user'))); router.get('/diagnostics', this.getDiagnostics.bind(this)); diff --git a/app/controllers/admin/core-node.js b/app/controllers/admin/core-node.js index e321d9f..0d68cb5 100644 --- a/app/controllers/admin/core-node.js +++ b/app/controllers/admin/core-node.js @@ -7,7 +7,7 @@ const express = require('express'); // const multer = require('multer'); -const { SiteController } = require('../../../lib/site-lib'); +const { SiteController, SiteError } = require('../../../lib/site-lib'); class CoreNodeController extends SiteController { @@ -25,16 +25,33 @@ class CoreNodeController extends SiteController { return next(); }); + router.param('coreNodeId', this.populateCoreNodeId.bind(this)); + router.post('/connect', this.postCoreNodeConnect.bind(this)); router.get('/resolve', this.getCoreNodeResolveForm.bind(this)); router.get('/connect', this.getCoreNodeConnectForm.bind(this)); + router.get('/:coreNodeId', this.getCoreNodeView.bind(this)); router.get('/', this.getIndex.bind(this)); return router; } + async populateCoreNodeId (req, res, next, coreNodeId) { + const { coreNode: coreNodeService } = this.dtp.services; + try { + res.locals.coreNode = await coreNodeService.getCoreById(coreNodeId); + if (!res.locals.coreNode) { + throw new SiteError(404, 'Core Node not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate Core Node', { coreNodeId, error }); + return next(error); + } + } + async postCoreNodeConnect (req, res) { const { coreNode: coreNodeService } = this.dtp.services; @@ -99,6 +116,18 @@ class CoreNodeController extends SiteController { res.render('admin/core-node/connect'); } + async getCoreNodeView (req, res, next) { + const { coreNode: coreNodeService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.requestHistory = await coreNodeService.getCoreRequestHistory(res.locals.coreNode, res.locals.pagination); + res.render('admin/core-node/view'); + } catch (error) { + this.log.error('failed to render Core Node view', { error }); + return next(error); + } + } + async getIndex (req, res, next) { const { coreNode: coreNodeService } = this.dtp.services; try { diff --git a/app/controllers/admin/newsletter.js b/app/controllers/admin/newsletter.js new file mode 100644 index 0000000..cedc864 --- /dev/null +++ b/app/controllers/admin/newsletter.js @@ -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; + }, +}; \ No newline at end of file diff --git a/app/controllers/newsletter.js b/app/controllers/newsletter.js new file mode 100644 index 0000000..c1edfba --- /dev/null +++ b/app/controllers/newsletter.js @@ -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; + }, +}; diff --git a/app/models/core-node-request.js b/app/models/core-node-request.js index c63a199..df526f4 100644 --- a/app/models/core-node-request.js +++ b/app/models/core-node-request.js @@ -22,7 +22,7 @@ const { RequestTokenSchema } = require('./lib/core-request-token'); */ const CoreNodeRequestSchema = new Schema({ - created: { type: Date, default: Date.now, required: true, index: 1 }, + created: { type: Date, default: Date.now, required: true, index: 1, expires: '7d' }, core: { type: Schema.ObjectId, required: true, ref: 'CoreNode' }, token: { type: RequestTokenSchema }, method: { type: String, required: true }, @@ -30,6 +30,7 @@ const CoreNodeRequestSchema = new Schema({ response: { received: { type: Date }, elapsed: { type: Number }, + statusCode: { type: String }, success: { type: Boolean }, }, }); diff --git a/app/models/core-node.js b/app/models/core-node.js index aebf158..81eda79 100644 --- a/app/models/core-node.js +++ b/app/models/core-node.js @@ -24,6 +24,9 @@ const CoreNodeSchema = new Schema({ scopes: { type: [String] }, redirectUri: { type: String }, }, + kaleidoscope: { + token: { type: String }, + }, meta: { name: { type: String }, description: { type: String }, diff --git a/app/models/newsletter-recipient.js b/app/models/newsletter-recipient.js new file mode 100644 index 0000000..da76741 --- /dev/null +++ b/app/models/newsletter-recipient.js @@ -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); diff --git a/app/models/newsletter.js b/app/models/newsletter.js new file mode 100644 index 0000000..b036fdb --- /dev/null +++ b/app/models/newsletter.js @@ -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); diff --git a/app/models/oauth2-client.js b/app/models/oauth2-client.js index 087326f..de56e24 100644 --- a/app/models/oauth2-client.js +++ b/app/models/oauth2-client.js @@ -21,6 +21,9 @@ const OAuth2ClientSchema = new Schema({ secret: { type: String, required: true }, scopes: { type: [String], required: true }, callbackUrl: { type: String, required: true }, + kaleidoscope: { + token: { type: String, required: true, index: 1 }, + }, flags: { isActive: { type: Boolean, default: true, required: true, index: 1 }, }, diff --git a/app/services/core-node.js b/app/services/core-node.js index 9f45068..cb13e93 100644 --- a/app/services/core-node.js +++ b/app/services/core-node.js @@ -59,6 +59,7 @@ class CoreNodeService extends SiteService { async start ( ) { const cores = await this.getConnectedCores(null, true); + this.log.info('Core Node service starting', { connectedCoreCount: cores.length }); cores.forEach((core) => this.registerPassportCoreOAuth2(core)); } @@ -197,6 +198,13 @@ class CoreNodeService extends SiteService { return address.parse(host); } + async getCoreById (coreNodeId) { + const core = await CoreNode + .findOne({ _id: coreNodeId }) + .lean(); + return core; + } + async getCoreByAddress (address) { const core = await CoreNode .findOne({ @@ -361,30 +369,17 @@ class CoreNodeService extends SiteService { try { json = await response.json(); } catch (error) { + await this.setRequestResponse(req, response); throw new SiteError(response.status, response.statusText); } this.log.debug('received failure response', { json }); + await this.setRequestResponse(req, response, json); throw new SiteError(response.status, json.message || response.statusText); } const json = await response.json(); - /* - * capture a little inline health monitoring data, which can be used to - * generate health alerts. - */ - const DONE = new Date(); - const ELAPSED = DONE.valueOf() - req.created.valueOf(); - await CoreNodeRequest.updateOne( - { _id: req._id }, - { - $set: { - 'response.received': DONE, - 'response.elapsed': ELAPSED, - 'response.success': json.success, - }, - }, - ); + await this.setRequestResponse(req, response, json); this.log.info('Core node request complete', { request: req }); return { request: req.toObject(), response: json }; @@ -394,6 +389,36 @@ class CoreNodeService extends SiteService { } } + async setRequestResponse (request, response, json) { + const DONE = new Date(); + const ELAPSED = DONE.valueOf() - request.created.valueOf(); + const updateOp = { + $set: { + 'response.received': DONE, + 'response.elapsed': ELAPSED, + 'response.statusCode': response.status, + }, + }; + if (json) { + updateOp.$set['response.success'] = json.success; + } + await CoreNodeRequest.updateOne({ _id: request._id }, updateOp); + } + + async getCoreRequestHistory (core, pagination) { + const requests = await CoreNodeRequest + .find({ core: core._id }) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .lean(); + for (const req of requests) { + req.core = core; + } + const totalRequestCount = await CoreNodeRequest.countDocuments({ core: core._id }); + return { requests, totalRequestCount }; + } + async connect (response) { const request = await CoreNodeRequest .findOne({ 'token.value': response.token }) @@ -481,6 +506,7 @@ class CoreNodeService extends SiteService { return request; } + async acceptServiceNode (requestToken, appNode) { const { oauth2: oauth2Service } = this.dtp.services; const response = { token: requestToken }; diff --git a/app/services/email.js b/app/services/email.js index bc4f5ff..6cfbdcc 100644 --- a/app/services/email.js +++ b/app/services/email.js @@ -71,10 +71,12 @@ class EmailService extends SiteService { async send (message) { const NOW = new Date(); + await this.checkEmailAddress(message.to); + this.log.info('sending email', { to: message.to, subject: message.subject }); const response = await this.transport.sendMail(message); - await EmailLog.create({ + const log = await EmailLog.create({ created: NOW, from: message.from, to: message.to, @@ -82,6 +84,8 @@ class EmailService extends SiteService { subject: message.subject, messageId: response.messageId, }); + + return { response, log }; } async checkEmailAddress (emailAddress) { diff --git a/app/services/newsletter.js b/app/services/newsletter.js new file mode 100644 index 0000000..efcbe1f --- /dev/null +++ b/app/services/newsletter.js @@ -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); }, +}; diff --git a/app/services/oauth2.js b/app/services/oauth2.js index c3290d0..1f00130 100644 --- a/app/services/oauth2.js +++ b/app/services/oauth2.js @@ -286,6 +286,9 @@ class OAuth2Service extends SiteService { secret: clientDefinition.secret, scopes: clientDefinition.coreAuth.scopes, callbackUrl: clientDefinition.coreAuth.callbackUrl, + kaleidoscope: { + token: generatePassword(256, false), + }, }, }, { diff --git a/app/views/admin/components/menu.pug b/app/views/admin/components/menu.pug index ec77620..3bb8f02 100644 --- a/app/views/admin/components/menu.pug +++ b/app/views/admin/components/menu.pug @@ -1,4 +1,4 @@ -ul.uk-nav.uk-nav-default +ul(uk-nav).uk-nav-default li.uk-nav-header Admin Menu li(class={ 'uk-active': (adminView === 'home') }) @@ -27,6 +27,35 @@ ul.uk-nav.uk-nav-default i.fas.fa-ban span.uk-margin-small-left Content Reports + li(class={ 'uk-active': (adminView === 'newsletter') }).uk-parent + a + span.nav-item-icon + i.fas.fa-newspaper + span.uk-margin-small-left Newsletter + span.uk-nav-parent-icon + + ul.uk-nav-sub + li(class={ 'uk-active': (newsletterView === 'index') }) + a(href="/admin/newsletter") + span.nav-item-icon + i.fas.fa-newspaper + span.uk-margin-small-left Home + li(class={ 'uk-active': (newsletterView === 'editor') }) + a(href="/admin/newsletter/compose") + span.nav-item-icon + i.fas.fa-edit + span.uk-margin-small-left Composer + li(class={ 'uk-active': (newsletterView === 'recipient') }) + a(href="/admin/newsletter/recipient") + span.nav-item-icon + i.fas.fa-user + span.uk-margin-small-left Recipients + li(class={ 'uk-active': (newsletterView === 'job-status') }) + a(href="/admin/newsletter/job-status") + span.nav-item-icon + i.fas.fa-newspaper + span.uk-margin-small-left Job Status + li.uk-nav-divider li(class={ 'uk-active': (adminView === 'core-node') }) @@ -47,12 +76,6 @@ ul.uk-nav.uk-nav-default i.fas.fa-puzzle-piece span.uk-margin-small-left Service Nodes - li(class={ 'uk-active': (adminView === 'connect-queue') }) - a(href="/admin/service-node/connect-queue") - span.nav-item-icon - i.fas.fa-plug - span.uk-margin-small-left Connect Queue - li.uk-nav-divider li(class={ 'uk-active': (adminView === 'host') }) diff --git a/app/views/admin/content-report/index.pug b/app/views/admin/content-report/index.pug index 4c7605a..137e225 100644 --- a/app/views/admin/content-report/index.pug +++ b/app/views/admin/content-report/index.pug @@ -2,7 +2,7 @@ extends ../layouts/main block content .uk-margin - +renderSectionTitle('Content Reports') + h1(style="line-height: 1em;").uk-margin-remove.uk-text-truncate Content Reports if Array.isArray(reports) && (reports.length > 0) .uk-overflow-auto diff --git a/app/views/admin/core-node/components/list-item.pug b/app/views/admin/core-node/components/list-item.pug new file mode 100644 index 0000000..cf38271 --- /dev/null +++ b/app/views/admin/core-node/components/list-item.pug @@ -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) \ No newline at end of file diff --git a/app/views/admin/core-node/connect.pug b/app/views/admin/core-node/connect.pug index 3f438a1..137ca4c 100644 --- a/app/views/admin/core-node/connect.pug +++ b/app/views/admin/core-node/connect.pug @@ -15,4 +15,4 @@ block content input(id="host", name="host", placeholder="Enter host:port of Core to connect", required).uk-input .uk-card-footer - button(type="submit").uk-button.uk-button-primary Send Request \ No newline at end of file + button(type="submit").uk-button.uk-button-primary.uk-border-rounded Send Request \ No newline at end of file diff --git a/app/views/admin/core-node/index.pug b/app/views/admin/core-node/index.pug index 6c21f82..fddcdaa 100644 --- a/app/views/admin/core-node/index.pug +++ b/app/views/admin/core-node/index.pug @@ -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. \ No newline at end of file diff --git a/app/views/admin/core-node/view.pug b/app/views/admin/core-node/view.pug new file mode 100644 index 0000000..a728eaa --- /dev/null +++ b/app/views/admin/core-node/view.pug @@ -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) \ No newline at end of file diff --git a/app/views/admin/job-queue/components/job-list.pug b/app/views/admin/job-queue/components/job-list.pug new file mode 100644 index 0000000..d437a0b --- /dev/null +++ b/app/views/admin/job-queue/components/job-list.pug @@ -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()}% diff --git a/app/views/admin/job-queue/queue-view.pug b/app/views/admin/job-queue/queue-view.pug index 1fd3595..7c20824 100644 --- a/app/views/admin/job-queue/queue-view.pug +++ b/app/views/admin/job-queue/queue-view.pug @@ -1,24 +1,7 @@ extends ../layouts/main block content - mixin renderJobList (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/${queue.name}/${job.id}`)= job.name - td= job.attemptsMade - td #{job.progress()}% + include components/job-list .uk-margin h1 Job Queue: #{queueName} @@ -38,25 +21,25 @@ block content .uk-card-header h3.uk-card-title Active .uk-card-body - +renderJobList(jobs.active) + +renderJobQueueJobList(queue, 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 - +renderJobList(jobs.waiting) + +renderJobQueueJobList(queue, 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 - +renderJobList(jobs.delayed) + +renderJobQueueJobList(queue, 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 - +renderJobList(jobs.failed) \ No newline at end of file + +renderJobQueueJobList(queue, jobs.failed) \ No newline at end of file diff --git a/app/views/admin/newsletter/editor.pug b/app/views/admin/newsletter/editor.pug new file mode 100644 index 0000000..24b5043 --- /dev/null +++ b/app/views/admin/newsletter/editor.pug @@ -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]; + }); \ No newline at end of file diff --git a/app/views/admin/newsletter/index.pug b/app/views/admin/newsletter/index.pug new file mode 100644 index 0000000..fa0f0a2 --- /dev/null +++ b/app/views/admin/newsletter/index.pug @@ -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. \ No newline at end of file diff --git a/app/views/admin/newsletter/job-status.pug b/app/views/admin/newsletter/job-status.pug new file mode 100644 index 0000000..6ea723c --- /dev/null +++ b/app/views/admin/newsletter/job-status.pug @@ -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) \ No newline at end of file diff --git a/app/views/admin/service-node/index.pug b/app/views/admin/service-node/index.pug index 1a61e45..597fb87 100644 --- a/app/views/admin/service-node/index.pug +++ b/app/views/admin/service-node/index.pug @@ -3,7 +3,14 @@ block content include components/list-item - h1 Service Nodes + div(uk-grid) + .uk-width-expand + h1(style="line-height: 1em;").uk-margin-remove Service Nodes + .uk-width-auto + a(href='/admin/service-node/connect-queue').uk-button.uk-button-secondary.uk-border-rounded + span + i.fas.fa-plug + span.uk-margin-small-left Connect Queue if Array.isArray(serviceNodes) && (serviceNodes.length > 0) ul.uk-list.uk-list-divider diff --git a/app/views/components/library.pug b/app/views/components/library.pug index a8fbe8d..db9a59b 100644 --- a/app/views/components/library.pug +++ b/app/views/components/library.pug @@ -16,7 +16,7 @@ include section-title mixin renderCell (label, value, className) div(title=`${label}: ${numeral(value).format('0,0')}`).uk-tile.uk-tile-default.uk-padding-remove.no-select - div(class=className)= value + div(class=className)!= value .uk-text-muted.uk-text-small= label mixin renderBackButton ( ) diff --git a/app/views/newsletter/index.pug b/app/views/newsletter/index.pug new file mode 100644 index 0000000..7e4cc10 --- /dev/null +++ b/app/views/newsletter/index.pug @@ -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. \ No newline at end of file diff --git a/app/workers/newsletter.js b/app/workers/newsletter.js new file mode 100644 index 0000000..eb24421 --- /dev/null +++ b/app/workers/newsletter.js @@ -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); + } +})(); \ No newline at end of file diff --git a/client/js/site-admin-app.js b/client/js/site-admin-app.js index 8b2fff8..833f100 100644 --- a/client/js/site-admin-app.js +++ b/client/js/site-admin-app.js @@ -238,6 +238,29 @@ export default class DtpSiteAdminHostStatsApp extends DtpApp { } } + async sendNewsletter (event) { + const newsletterId = event.currentTarget.getAttribute('data-newsletter-id'); + const newsletterTitle = event.currentTarget.getAttribute('data-newsletter-title'); + console.log(newsletterId, newsletterTitle); + try { + await UIkit.modal.confirm(`Are you sure you want to transmit "${newsletterTitle}"`); + } catch (error) { + this.log.info('sendNewsletter', 'aborted'); + return; + } + try { + const response = await fetch(`/admin/newsletter/${newsletterId}/transmit`, { + method: 'POST', + }); + if (!response.ok) { + throw new Error('Server error'); + } + await this.processResponse(response); + } catch (error) { + this.log.error('sendNewsletter', 'failed to send newsletter', { newsletterId, newsletterTitle, error }); + UIkit.modal.alert(`Failed to send newsletter: ${error.message}`); + } + } async deleteNewsletter (event) { const newsletterId = event.currentTarget.getAttribute('data-newsletter-id'); diff --git a/config/job-queues.js b/config/job-queues.js index 7c5dc58..67f8f85 100644 --- a/config/job-queues.js +++ b/config/job-queues.js @@ -5,7 +5,8 @@ 'use strict'; module.exports = { - // 'queue-name': { - // attempts: 3, - // }, + 'newsletter': { + attempts: 3, + removeOnComplete: false, + }, }; \ No newline at end of file diff --git a/config/limiter.js b/config/limiter.js index fe2bdbf..86ddcc6 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -132,6 +132,22 @@ module.exports = { } }, + /* + * NewsletterController + */ + newsletter: { + getView: { + total: 5, + expire: ONE_MINUTE, + message: 'You are reading newsletters too quickly', + }, + getIndex: { + total: 60, + expire: ONE_MINUTE, + message: 'You are fetching newsletters too quickly', + }, + }, + /* * UserController */ diff --git a/start-local b/start-local index 4565df4..20a0a21 100755 --- a/start-local +++ b/start-local @@ -9,8 +9,10 @@ export MINIO_ROOT_USER MINIO_ROOT_PASSWORD MINIO_CI_CD forever start --killSignal=SIGINT app/workers/host-services.js forever start --killSignal=SIGINT app/workers/reeeper.js +forever start --killSignal=SIGINT app/workers/newsletter.js minio server ./data/minio --address ":9010" --console-address ":9011" +forever stop app/workers/newsletter.js forever stop app/workers/reeeper.js forever stop app/workers/host-services.js \ No newline at end of file