UNPKG

@lykmapipo/postman

Version:

collective notifications for nodejs

1,120 lines (897 loc) 24.8 kB
'use strict'; /** * @module Message * @name Message * @description A discrete unit of communication intended by the source(sender) * for consumption by some recipient(receiver) or group of recipients(receivers). * * A message may be delivered by various means(transports) including email, sms, * push notification etc. * * @see {@link https://en.wikipedia.org/wiki/Message} * @author lally elias <lallyelias87@gmail.com> * @license MIT * @version 0.1.0 * @since 0.1.0 * @public */ /* @todo country data */ /* @todo transport data */ /* @todo message form alert, announcement, reminder etc */ /* dependencies */ const path = require('path'); const _ = require('lodash'); const async = require('async'); const mongoose = require('mongoose'); const { getString, getBoolean } = require('@lykmapipo/env'); const actions = require('mongoose-rest-actions'); const hash = require('object-hash'); const isHtml = require('is-html'); const { plugin: runInBackground, worker } = require('mongoose-kue'); const { Schema } = mongoose; const { Mixed } = Schema.Types; /* transports */ const ECHO_TRANSPORT_NAME = 'echo'; const DEFAULT_TRANSPORT_NAME = getString('DEFAULT_TRANSPORT_NAME', ECHO_TRANSPORT_NAME); /* load transports */ const transports = { 'infobip-sms': require(path.join(__dirname, 'transports', 'sms.infobip')), 'tz-ega-sms': require(path.join(__dirname, 'transports', 'sms.tz.ega')), 'smtp': require(path.join(__dirname, 'transports', 'smtp')), }; /* message directions */ const DIRECTION_OUTBOUND = 'Outbound'; const DIRECTION_INBOUND = 'Inbound'; const DIRECTIONS = [DIRECTION_INBOUND, DIRECTION_OUTBOUND]; /* message types */ const TYPE_SMS = 'SMS'; const TYPE_EMAIL = 'EMAIL'; const TYPE_PUSH = 'PUSH'; const TYPES = [TYPE_SMS, TYPE_EMAIL, TYPE_PUSH]; /* message mime types. Used to tell what is mime of the message body. * It used mostly in smtp transports to decide which content to send i.e email * or text. */ const MIME_TEXT = 'text/plain'; const MIME_HTML = 'text/html'; const MIMES = [MIME_TEXT, MIME_HTML]; /* messages priorities */ const PRIORITY_LOW = 'low'; const PRIORITY_NORMAL = 'normal'; const PRIORITY_MEDIUM = 'medium'; const PRIORITY_HIGH = 'high'; const PRIORITY_CRITICAL = 'critical'; const PRIORITIES = [ PRIORITY_LOW, PRIORITY_NORMAL, PRIORITY_MEDIUM, PRIORITY_HIGH, PRIORITY_CRITICAL ]; /* transport send modes */ const SEND_MODE_PULL = 'Pull'; const SEND_MODE_PUSH = 'Push'; const SEND_MODES = [SEND_MODE_PULL, SEND_MODE_PUSH]; /* model name for the message */ const MODEL_NAME = getString('MODEL_NAME', 'Message'); /* collection name for the message */ const COLLECTION_NAME = getString('COLLECTION_NAME', 'messages'); /* schema options */ const SCHEMA_OPTIONS = ({ timestamps: true, emitIndexErrors: true, collection: COLLECTION_NAME }); /* message hash fields */ const HASH_FIELDS = [ 'type', 'direction', 'bulk', 'sender', 'to', 'transport', 'body', 'priority', 'createdAt' ]; /* messages state */ //state assigned to a message received from a transport const STATE_RECEIVED = 'Received'; //state assigned to message to be sent by a transport mainly poll transport const STATE_UNKNOWN = 'Unknown'; //state assigned to a message once a poll transport receive a message to send const STATE_SENT = 'Sent'; //state assigned to a message after receiving acknowledge from poll transport //that message have been queued for sending const STATE_QUEUED = 'Queued'; //state assigned to a message once successfully delivered to a receiver(s) const STATE_DELIVERED = 'Delivered'; //states const STATES = [ STATE_RECEIVED, STATE_UNKNOWN, STATE_SENT, STATE_QUEUED, STATE_DELIVERED ]; /** * @name MessageSchema * @description message schema * @type {Schema} * @author lally elias <lallyelias87@gmail.com> * @version 0.1.0 * @since 0.1.0 * @private */ const MessageSchema = new Schema({ /** * @name type * @description message type i.e SMS, e-mail, push etc * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ type: { type: String, enum: TYPES, trim: true, default: TYPE_EMAIL, index: true, searchable: true, fake: true }, /** * @name mime * @description message mime type i.e text/plain, text/html etc * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ mime: { type: String, enum: MIMES, trim: true, default: MIME_TEXT, index: true, searchable: true, fake: true }, /** * @name direction * @description message direction i.e received or sending * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ direction: { type: String, enum: DIRECTIONS, trim: true, default: DIRECTION_OUTBOUND, index: true, searchable: true, fake: true }, /** * @name state * @description message state i.e Received, Sent, Queued etc * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ state: { type: String, enum: STATES, trim: true, default: STATE_UNKNOWN, index: true, searchable: true, fake: true }, /** * @name mode * @description message transport send mode i.e Pull or Push etc * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ mode: { type: String, enum: SEND_MODES, trim: true, default: SEND_MODE_PUSH, index: true, searchable: true, fake: true }, /** * @name bulk * @description unique identifier used to track group messages which have been * send together. * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ bulk: { type: String, trim: true, index: true, searchable: true, fake: { generator: 'random', type: 'uuid' } }, /** * @name sender * @description sender of the message * i.e e-mail sender, message sender etc * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ sender: { type: String, trim: true, required: true, index: true, searchable: true, fake: { generator: 'internet', type: 'email' } }, /** * @name to * @description receiver(s) of the message * i.e e-mail receiver, message receiver etc * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ to: { type: [String], required: true, index: true, searchable: true, fake: { generator: 'internet', type: 'email' } }, /** * @name cc * @description receiver(s) of the carbon copy of the message * i.e e-mail cc receiver * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ cc: { type: [String], index: true, searchable: true, fake: { generator: 'internet', type: 'email' } }, /** * @name bcc * @description receiver(s) of the blind carbon copy of the message * i.e e-mail cc receiver * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ bcc: { type: [String], index: true, searchable: true, fake: { generator: 'internet', type: 'email' } }, /** * @name subject * @description subject of the message * i.e email title etc * e.g Hello * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ subject: { type: String, trim: true, index: true, searchable: true, fake: { generator: 'lorem', type: 'sentence' } }, /** * @name body * @description content of the message to be conveyed to receiver(s) * e.g Hello * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ body: { type: String, trim: true, index: true, searchable: true, fake: { generator: 'lorem', type: 'sentence' } }, /** * @name sentAt * @description time when message was send successfully to a receiver. * * If message send succeed, set the result and update sent time. * * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ sentAt: { type: Date, index: true, fake: { generator: 'date', type: 'past' } }, /** * @name failedAt * @description latesst time when message sned to receiver(s) failed. * * If message send failed just set the result and set failed time. * * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ failedAt: { type: Date, index: true, fake: { generator: 'date', type: 'recent' } }, /** * @name deliveredAt * @description latest time when message delivered to receiver(s). * * If message delivered just set the result and set delivery time. * * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ deliveredAt: { type: Date, index: true, fake: { generator: 'date', type: 'recent' } }, /** * @name result * @description message send result i.e success or failure response * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ result: { type: Mixed, fake: { generator: 'helpers', type: 'createTransaction' } }, /** * @name transport * @description method used to actual send the message. It must be set-ed * by a transport used to send message. * * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ transport: { type: String, trim: true, default: DEFAULT_TRANSPORT_NAME, index: true, searchable: true, fake: true }, /** * @name priority * @description message sending priority * @see {@link https://github.com/Automattic/kue#job-priority} * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ priority: { type: String, enum: PRIORITIES, trim: true, default: PRIORITY_NORMAL, index: true, searchable: true, fake: true }, /** * @name hash * @description unique message hash that is set by a transport. * * It allow for a transport to uniquely identify a message. * * A quick scenarion is when sms is received and you dont want to receive * a message previous received from a transport. * * You can use transport hash to check for sms existance or upserting * a message. * * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ hash: { type: String, trim: true, required: true, unique: true, searchable: true, fake: { generator: 'random', type: 'uuid' } }, /** * @name tags * @description additional tags(or labels) used to identified sent message * @type {Object} * @since 0.1.0 * @version 1.0.0 * @instance */ tags: { type: [String], index: true, searchable: true, fake: { generator: 'address', type: 'city' } } }, SCHEMA_OPTIONS); /*----------------------------------------------------------------------------- Hooks ------------------------------------------------------------------------------*/ /** * @name onPreValidate * @description message schema pre validate hook * @since 0.1.0 * @version 0.1.0 * @private */ MessageSchema.pre('validate', function onPreValidate(next) { this.preValidate(next); }); /*----------------------------------------------------------------------------- Instance ------------------------------------------------------------------------------*/ /** * @name preValidate * @function preValidate * @description run logics before message validation * @param {Function} done a callback to invoke on success or failure * @return {Message|Error} an instance of message or error * @since 0.1.0 * @version 0.1.0 * @private */ MessageSchema.methods.preValidate = function preValidate(next) { //ensure `to` field is in array format if (this.to && _.isString(this.to)) { this.to = [].concat(this.to); } //ensure `cc` field is in array format if (this.cc && _.isString(this.cc)) { this.cc = [].concat(this.cc); } //ensure `bcc` field is in array format if (this.bcc && _.isString(this.bcc)) { this.bcc = [].concat(this.bcc); } //ensure `tags` field is in array format if (this.tags && _.isString(this.tags)) { this.tags = [].concat(this.tags); } //ensure message hash if not set by a transport if (!this.hash || _.isEmpty(this.hash)) { this.createdAt = this.createdAt || new Date(); const _hash = _.pick(this, HASH_FIELDS); this.hash = hash(_hash); } //set mime type if (isHtml(this.body)) { this.mime = MIME_HTML; } else { this.mime = MIME_TEXT; } //compact and lowercase to, cc, bcc & tags this.to = _.map(_.uniq(_.compact(this.to)), _.toLower); this.cc = _.map(_.uniq(_.compact(this.cc)), _.toLower); this.bcc = _.map(_.uniq(_.compact(this.bcc)), _.toLower); this.tags = _.map(_.uniq(_.compact(this.tags)), _.toLower); //ensure state to be unknown for poll transport if (this.mode === SEND_MODE_PULL) { this.state = STATE_UNKNOWN; } //ensure transport if (_.isEmpty(this.transport)) { this.transport = DEFAULT_TRANSPORT_NAME; } next(null, this); }; /** * @name isHtml * @function isHtml * @description check if message body is html * @return {Boolean} true or false * @since 0.1.0 * @version 0.1.0 * @instance */ MessageSchema.methods.isHtml = function _isHtml() { return (this.mime = MIME_HTML) || isHtml(this.body); }; /** * @name _send * @function _send * @description send this message using actual transport * @param {Function} done a callback to invoke on success or failure * @return {Message|Error} an instance of message or error * @since 0.1.0 * @version 0.1.0 * @private */ MessageSchema.methods._send = function (done) { //this refer to Message instance context try { //obtain actual message transport const transport = transports[this.transport]; //NOTE! poll transport should return state on the result //cause they will later pick messages for sending async.waterfall([ function send(next) { //this refer to Message instance context //set send data this.sentAt = new Date(); //update message with transport details // this.transport = transport.toObject(); //send message via transport transport.send(this, function (error, result) { //prepare result let _result = _.merge({}, { success: true }, result); //handle error if (error) { //set failed date this.failedAt = new Date(); //obtain error details if (error instanceof Error) { _result = _.merge({}, { success: false }, { message: error.message, code: error.code, status: error.status }); } } //update message sending details if (!this.failedAt) { this.deliveredAt = this.deliveredAt || new Date(); } this.state = STATE_DELIVERED; this.result = _result; next(null, this); }.bind(this)); }.bind(this), function update(message, next) { message.put(next); } ], done); } catch (error) { done(error); } }; /** * @name send * @function send * @description send this message using actual transport or debug it * @param {Function} done a callback to invoke on success or failure * @return {Message|Error} an instance of message or error * @since 0.1.0 * @version 0.1.0 * @instance * @example * * message.send(cb); * */ MessageSchema.methods.send = function send(done) { //this refer to Message instance context /* @todo format <to> based on message type */ /* @todo format <to> as e164 phone numbers */ //send via echo transport const useEchoTransport = (_.isEmpty(this.transport) || (this.transport === ECHO_TRANSPORT_NAME)); //check for debug flags const DEBUG = getBoolean('DEBUG', false); if (useEchoTransport) { //update message this.sentAt = new Date(); this.deliveredAt = new Date(); this.result = { success: true }; this.failedAt = undefined; //ensure echo transport this.transport = (this.transport || ECHO_TRANSPORT_NAME); //persist message return this.put(done); } //handle debug message sending else if (DEBUG) { //update message this.sentAt = this.sentAt || new Date(); this.deliveredAt = new Date(); this.result = { success: true }; //persist message return this.put(done); } //send message using actual transport else { return this._send(done); } }; /** * @name queue * @function queue * @description queue message for later send * @events job error, job success * @fire {Message|Error} an instance of queued message or error * @since 0.1.0 * @instance * @example * * message.queue(); * */ MessageSchema.methods.queue = function queue() { //this refer to Message instance context //persist message this.save(function (error, message) { //notify error if (error) { worker.queue.emit('job error', error); } //notify message queued successfully //since a poll transport will later pull the message //for actul send else if (message.mode === SEND_MODE_PULL) { worker.queue.emit('job queued', message); } //queue message for later send //push transport are notified in their worker to send the message else { //prepare job details const jobType = (message.type || getString('QUEUE_DEFAULT_JOB_TYPE')); const title = (message.subject || jobType); const jobDefaults = ({ method: 'send', title: title, type: jobType }); const jobDetails = _.merge({}, jobDefaults, message.toObject()); const job = message.runInBackground(jobDetails); //ensure message has been queued job.save(function (error) { if (error) { worker.queue.emit('job error', error); } else { worker.queue.emit('job queued', message); } }); } }); }; /*----------------------------------------------------------------------------- Statics ------------------------------------------------------------------------------*/ /* schema options*/ MessageSchema.statics.MODEL_NAME = MODEL_NAME; MessageSchema.statics.COLLECTION_NAME = COLLECTION_NAME; /* message directions */ MessageSchema.statics.DIRECTION_INBOUND = DIRECTION_INBOUND; MessageSchema.statics.DIRECTION_OUTBOUND = DIRECTION_OUTBOUND; MessageSchema.statics.DIRECTIONS = DIRECTIONS; /* message types */ MessageSchema.statics.TYPE_SMS = TYPE_SMS; MessageSchema.statics.TYPE_EMAIL = TYPE_EMAIL; MessageSchema.statics.TYPE_PUSH = TYPE_PUSH; MessageSchema.statics.TYPES = TYPES; /* message mime types */ MessageSchema.statics.MIME_TEXT = MIME_TEXT; MessageSchema.statics.MIME_HTML = MIME_HTML; MessageSchema.statics.MIMES = MIMES; /* mesage priorities */ MessageSchema.statics.PRIORITY_LOW = PRIORITY_LOW; MessageSchema.statics.PRIORITY_NORMAL = PRIORITY_NORMAL; MessageSchema.statics.PRIORITY_MEDIUM = PRIORITY_MEDIUM; MessageSchema.statics.PRIORITY_HIGH = PRIORITY_HIGH; MessageSchema.statics.PRIORITY_CRITICAL = PRIORITY_CRITICAL; MessageSchema.statics.PRIORITIES = PRIORITIES; /* transaport sending mode */ MessageSchema.statics.SEND_MODE_PULL = SEND_MODE_PULL; MessageSchema.statics.SEND_MODE_PUSH = SEND_MODE_PUSH; MessageSchema.statics.SEND_MODES = SEND_MODES; /* message states */ MessageSchema.statics.STATE_RECEIVED = STATE_RECEIVED; MessageSchema.statics.STATE_UNKNOWN = STATE_UNKNOWN; MessageSchema.statics.STATE_SENT = STATE_SENT; MessageSchema.statics.STATE_QUEUED = STATE_QUEUED; MessageSchema.statics.STATE_DELIVERED = STATE_DELIVERED; MessageSchema.statics.STATES = STATES; /** * @name unsent * @function unsent * @description obtain unsent message(s) * @param {Object} [criteria] valid mongoose query criteria * @param {Function} done a callback to invoke on success or failure * @return {Message[]|Error} collection of unsent messages * @since 0.1.0 * @version 0.1.0 * @public * @static * @example * * Message.unsent(cb); * Message.unsent(criteria, cb); * Message.unsent().exec(cb); * Message.unsent(criteria).exec(cb); * */ MessageSchema.statics.unsent = function unsent(criteria, done) { //this refer to Message static context let _criteria = criteria; let _done = done; //normalize arguments if (criteria && _.isFunction(criteria)) { _done = criteria; _criteria = {}; } //ensure unsent criteria _criteria = _.merge({}, { sentAt: null }, _criteria); //find unsent messages return this.find(_criteria, _done); }; /** * @name sent * @function sent * @description obtain already sent message(s) * @param {Object} [criteria] valid mongoose query criteria * @param {Function} done a callback to invoke on success or failure * @return {Message[]|Error} collection of already sent message(s) * @since 0.1.0 * @version 0.1.0 * @public * @static * @example * * Message.sent(cb); * Message.sent(criteria, cb); * Message.sent().exec(cb); * Message.sent(criteria).exec(cb); * */ MessageSchema.statics.sent = function sent(criteria, done) { //this refer to Message static context let _done = done; let _criteria = criteria; //normalize arguments if (criteria && _.isFunction(criteria)) { _done = criteria; _criteria = {}; } //ensure sent criteria _criteria = _.merge({}, { sentAt: { $ne: null } }, _criteria); //find sent message return this.find(criteria, _done); }; /** * @name resend * @function resend * @description re-send all failed message(s) based on specified criteria * @param {Object} [criteria] valid mongoose query criteria * @param {Function} done a callback to invoke on success or failure * @return {Message[]|Error} collection of resend message(s) * @since 0.1.0 * @version 0.1.0 * @public * @example * * Message.resend(criteria, cb); * Message.resend(cb); * */ MessageSchema.statics.resend = function resend(criteria, done) { //this refer to Message static context /* @todo use stream */ let _done = done; let _criteria = criteria; //normalize arguments if (criteria && _.isFunction(criteria)) { _done = criteria; _criteria = {}; } //reference Message const Message = this; //resend fail or unsent message(s) async.waterfall([ function findUnsentMessages(next) { Message.unsent(_criteria, next); /* @todo also failedAt is set */ }, function resendMessages(unsents, next) { //check for unsent message(s) if (unsents) { /* @todo make use of parallelism */ //prepare send work unsents = _.map(unsents, function (unsent) { return function (_next) { unsent.send(_next); /* @todo spies test */ }; }); async.parallel(_.compact(unsents), next); } else { next(null, unsents); } } ], done); }; /** * @name requeue * @description requeue all failed message(s) based on specified criteria * @param {Object} [criteria] valid mongoose query criteria * @type {Function} * @events job error, job success * @fire {Message[]|Error} collection of requeued messages or error * @since 0.1.0 * @version 0.1.0 * @public * @static * @example * * Message.requeue(); * Message.requeue(criteria); * */ MessageSchema.statics.requeue = function (criteria) { //this refer to Message static context /* @todo use stream */ //merge criteria let _criteria = _.merge({}, criteria); //reference Message const Message = this; //find all unsent message(s) for requeue Message.unsent(_criteria, function (error, unsents) { //there is no message queue if (!worker.queue) { if (error) { throw error; } } //there is message queue else { //fire requeue error if (error) { worker.queue.emit('job error', error); } //re-queue all unsent message(s) else { //fire requeue success worker.queue.emit('job success', unsents); //re-queue unsent message _.forEach(unsents, function (unsent) { unsent.queue(); }); } } }); }; /*----------------------------------------------------------------------------- Plugins ------------------------------------------------------------------------------*/ MessageSchema.plugin(actions); MessageSchema.plugin(runInBackground, { types: TYPES }); /** * export message schema * @type {Model} * @private */ exports = module.exports = mongoose.model(MODEL_NAME, MessageSchema);