bcgov/common-hosted-email-service

View on GitHub
app/src/services/chesSvc.js

Summary

Maintainability
F
6 days
Test Coverage
/**
 * @module ChesService
 *
 * Create a business service for CHES.
 * Provide a wrapper around business functions that use multiple services.
 *
 * @see DataService
 * @see EmailService
 * @see QueueService
 * @see Transformer
 *
 * @see mergeComponent
 *
 * @exports ChesService
 */
const { NotFoundError } = require('objection');
const Problem = require('api-problem');

const log = require('../components/log')(module.filename);
const mergeComponent = require('../components/merge');
const { queueState } = require('../components/state');
const transformer = require('../components/transformer');
const utils = require('../components/utils');

const DataService = require('./dataSvc');
const EmailService = require('./emailSvc');
const {
  ClientMismatchError,
  DataIntegrityError,
  UncancellableError,
  UnpromotableError,
  QueueService
} = require('./queueSvc');

class ChesService {

  /**
   * Creates a new ChesService with default Data, Email, Queue Services (all with default connections).
   * @class
   */
  constructor() {
    this.dataService = new DataService();
    this.emailService = new EmailService();
    this.queueService = new QueueService();
  }

  /**
   * @function dataService
   * Gets the current DataService
   */
  get dataService() {
    return this._dataService;
  }

  /**
   * @function dataService
   * Sets the current DataService
   * @param {object} v - a DataService object.
   */
  set dataService(v) {
    this._dataService = v;
  }

  /**
   * @function emailService
   * Gets the current EmailService
   */
  get emailService() {
    return this._emailService;
  }

  /**
   * @function emailService
   * Sets the current EmailService
   * @param {object} v - am EmailService object.
   */
  set emailService(v) {
    this._emailService = v;
  }

  /**
   * @function queueService
   * Gets the current QueueService
   */
  get queueService() {
    return this._queueService;
  }

  /**
   * @function queueService
   * Sets the current QueueService
   * @param {object} v - a QueueService.
   */
  set queueService(v) {
    this._queueService = v;
  }

  /**
   * @function cancelMessage
   * @description Cancels message `messageId` if it is still waiting to send
   *
   * @param {string} client - the authorized party / client
   * @param {string} messageId - the id of the desired message
   * @throws Problem if message is not found or conflicts with internal state
   */
  async cancelMessage(client, messageId) {
    if (!client || !messageId) {
      throw new Problem(400, { detail: 'Error cancelling message. Client and messageId cannot be null' });
    }

    try {
      // Try removing directly from queue first
      const success = await this.queueService.removeJob(client, messageId);
      if (!success) {
        // Check why a job was not found
        const exists = await this.dataService.messageExists(client, messageId);
        throw (!exists) ? new NotFoundError() :
          new UncancellableError(`Message ${messageId} is not cancellable.`);
      }
    } catch (e) {
      if (e instanceof ClientMismatchError) {
        log.warn(e.message, { function: 'cancelMessage' });
        throw new Problem(403, { detail: e.message });
      } else if (e instanceof DataIntegrityError) {
        log.error(e.message, { function: 'cancelMessage' });
        throw new Problem(500, { detail: e.message });
      } else if (e instanceof NotFoundError) {
        log.warn(`Message ${messageId} from client ${client} not found.`, { function: 'cancelMessage' });
        throw new Problem(404, { detail: `Message ${messageId} not found.` });
      } else if (e instanceof UncancellableError) {
        log.warn(e.message, { function: 'cancelMessage' });
        throw new Problem(409, { detail: e.message });
      } else {
        throw e;
      }
    }
  }

  /**
   * @function findCancelMessages
   * @description Finds and attempts to cancel the set of messages matching the search criteria
   *
   * @param {string} client - the authorized party / client
   * @param {string} messageId - the id of the desired message
   * @param {string} status - the desired status of the messages
   * @param {string} tag - the desired tag of the messages
   * @param {string} transactionId - the id of the desired transaction
   * @throws Problem if an unexpected error occurs
   */
  async findCancelMessages(client, messageId, status, tag, transactionId) {
    if (!client) {
      throw new Problem(400, { detail: 'Error finding and cancelling messages. Client cannot be null' });
    }

    try {
      const messages = await this.dataService.findMessagesByQuery(client, messageId, status, tag, transactionId);

      const integrityList = [];
      await Promise.all(messages.map(msg => {
        try {
          // Try removing directly from queue, then update db afterwards
          this.queueService.removeJob(client, msg.messageId);
        } catch (e) {
          if (e instanceof ClientMismatchError || e instanceof NotFoundError ||
            e instanceof UncancellableError) {
            log.info(e.message, { function: 'findCancelMessages' });
          } else if (e instanceof DataIntegrityError) {
            log.error(e.message, { function: 'findCancelMessages' });
            integrityList.push(msg.messageId);
          } else {
            throw e; // We want to throw and not return an error object in this case
          }
          return e;
        }
      }).filter(e => !!e)); // Drop undefined elements from array

      if (integrityList && integrityList.length) {
        log.error(`Message(s) ${integrityList} inconsistent or corrupted.`, { function: 'findCancelMessages' });
        throw new Problem(500, {
          detail: 'Some message(s) are inconsistent or corrupted.',
          messages: integrityList
        });
      }
    } catch (e) {
      if (e instanceof NotFoundError) {
        log.info('No messages found', { function: 'findCancelMessages' });
      } else if (e instanceof Problem) {
        throw e;
      } else {
        log.error(e.message, { function: 'findCancelMessages' });
        throw new Problem(500, { detail: `Unexpected Error: ${e.message}` });
      }
    }
  }

  /**
   * @function findPromoteMessages
   * @description Finds and attempts to promote the set of messages matching the search criteria
   *
   * @param {string} client - the authorized party / client
   * @param {string} messageId - the id of the desired message
   * @param {string} status - the desired status of the messages
   * @param {string} tag - the desired tag of the messages
   * @param {string} transactionId - the id of the desired transaction
   * @throws Problem if an unexpected error occurs
   */
  async findPromoteMessages(client, messageId, status, tag, transactionId) {
    if (!client) {
      throw new Problem(400, { detail: 'Error finding and promoting messages. Client cannot be null' });
    }

    try {
      const messages = await this.dataService.findMessagesByQuery(client, messageId, status, tag, transactionId);

      const integrityList = [];
      await Promise.all(messages.map(async msg => {
        try {
          // Try promoting directly from queue first
          const success = await this.queueService.promoteJob(client, msg.messageId);
          if (!success) {
            // Check if message contents are still there
            if (msg.email) {
              // Try forcing an enqueue ignoring specified delay
              msg.email.messageId = msg.messageId;
              await this.dataService.updateStatus(client, msg.messageId, queueState.PROMOTED, 'Promotion requested');
              log.info(`Message ${msg.messageId} promoted in queue`, { function: 'findPromoteMessages' });
              await this.queueService.enqueue(client, msg.email);
            }
            else throw new UnpromotableError(`Message ${msg.messageId} is not promotable.`);
          }
        } catch (e) {
          if (e instanceof ClientMismatchError || e instanceof NotFoundError
            || e instanceof UnpromotableError) {
            log.info(e.message, { function: 'findPromoteMessages' });
          } else if (e instanceof DataIntegrityError) {
            log.error(e.message, { function: 'findPromoteMessages' });
            integrityList.push(msg.messageId);
          } else {
            throw e; // We want to throw and not return an error object in this case
          }
          return e;
        }
      }).filter(e => !!e)); // Drop undefined elements from array

      if (integrityList && integrityList.length) {
        log.error(`Message(s) ${integrityList} inconsistent or corrupted.`, { function: 'findPromoteMessages' });
        throw new Problem(500, {
          detail: 'Some message(s) are inconsistent or corrupted.',
          messages: integrityList
        });
      }
    } catch (e) {
      if (e instanceof NotFoundError) {
        log.info('No messages found', { function: 'findPromoteMessages' });
      } else if (e instanceof Problem) {
        throw e;
      } else {
        log.error(e.message, { function: 'findPromoteMessages' });
        throw new Problem(500, { detail: `Unexpected Error: ${e.message}` });
      }
    }
  }

  /**
   * @function findStatuses
   * @description Finds the set of message statuses that matches the search criteria
   *
   * @param {string} client - the authorized party / client
   * @param {string} messageId - the id of the desired message
   * @param {string} status - the desired status of the messages
   * @param {string} tag - the desired tag of the messages
   * @param {string} transactionId - the id of the desired transaction
   * @throws Problem if an unexpected error occurs
   * @returns {object[]} Array of Status objects with a subset of properties
   */
  async findStatuses(client, messageId, status, tag, transactionId) {
    try {
      const result = await this.dataService.findMessagesByQuery(client, messageId, status, tag, transactionId);
      return result.map(msg => transformer.toStatusResponse(msg));
    } catch (e) {
      if (e instanceof NotFoundError) {
        log.verbose('No messages found', { function: 'findStatuses' });
        return [];
      } else {
        log.error(e.message, { function: 'findStatuses' });
        throw new Problem(500, { detail: `Unexpected Error: ${e.message}` });
      }
    }
  }

  /**
   * @function getStatus
   * @description Finds the message status of `messageId`
   *
   * @param {string} client - the authorized party / client
   * @param {string} messageId - the id of the desired message
   * @throws Problem if an unexpected error occurs or if message is not found
   * @returns {object[]} The Status object for `messageId` if it exists
   */
  async getStatus(client, messageId) {
    if (!messageId) {
      throw new Problem(400, { detail: 'Error getting status. Message Id cannot be null' });
    }

    try {
      // fetch the message and statuses... (throws error if not found)
      const msg = await this.dataService.readMessage(client, messageId);

      // transform message and statuses into API format...
      const status = transformer.toStatusResponse(msg);
      return status;
    } catch (e) {
      if (e instanceof NotFoundError) {
        log.warn(`Message ${messageId} from client ${client} not found.`, { function: 'getStatus' });
        throw new Problem(404, { detail: `Message ${messageId} not found.` });
      } else {
        log.error(`Unable to retrieve status of message ${messageId} from client ${client}. ${e.message}`, { error: e, function: 'getStatus' });
        throw new Problem(500, { detail: `Unable retrieve status of message ${messageId}. ${e.message}` });
      }
    }
  }

  /**
   * @function promoteMessage
   * @description Promotes message `messageId` if it is still waiting to send
   *
   * @param {string} client - the authorized party / client
   * @param {string} messageId - the id of the desired message
   * @throws Problem if message is not found or conflicts with internal state
   */
  async promoteMessage(client, messageId) {
    if (!client || !messageId) {
      throw new Problem(400, { detail: 'Error promoting message. Client and messageId cannot be null' });
    }

    try {
      // Try promoting directly from queue first
      const success = await this.queueService.promoteJob(client, messageId);
      if (!success) {
        // Check why a job was not found
        const exists = await this.dataService.messageExists(client, messageId);
        if (!exists) throw new NotFoundError();
        await this.recoverMessage(client, messageId);
      }
    } catch (e) {
      if (e instanceof ClientMismatchError) {
        log.info(e.message, { function: 'promoteMessage' });
        throw new Problem(403, { detail: e.message });
      } else if (e instanceof DataIntegrityError) {
        log.error(e.message, { function: 'promoteMessage' });
        throw new Problem(500, { detail: e.message });
      } else if (e instanceof NotFoundError) {
        log.warn(`Message ${messageId} from client ${client} not found.`, { function: 'promoteMessage' });
        throw new Problem(404, { detail: `Message ${messageId} not found.` });
      } else if (e instanceof UnpromotableError) {
        log.warn(e.message, { function: 'promoteMessage' });
        throw new Problem(409, { detail: e.message });
      } else {
        throw e;
      }
    }
  }

  /**
   * @function recoverMessage
   * @description Attempts to procedurally recover message `messageId` in the queue
   *
   * @param {string} client - the authorized party / client
   * @param {string} messageId - the id of the desired message
   * @throws Problem if message is not found or conflicts with internal state
   */
  async recoverMessage(client, messageId) {
    const msg = await this.dataService.readMessage(client, messageId);

    // Check if message contents are still there
    if (msg.email) {
      // Try forcing an enqueue ignoring specified delay
      msg.email.messageId = messageId;
      await this.dataService.updateStatus(client, messageId, queueState.PROMOTED, 'Promotion requested');
      log.info(`Message ${messageId} promoted in queue`, { function: 'recoverMessage' });
      await this.queueService.enqueue(client, msg.email);
    }
    else throw new UnpromotableError(`Message ${messageId} is not promotable.`);
  }

  /**
   * @function sendEmail
   * @description Creates and Queues the API message for delivery
   *
   * @param {string} client - the authorized party / client
   * @param {object} message - the API email message
   * @param {boolean} ethereal - if true, then use the Ethereal connection, send email immediately.
   * @returns {object} TransactionResponse
   */
  async sendEmail(client, message, ethereal = false) {
    if (!message) {
      throw new Problem(400, { detail: 'Error sending email. Email message cannot be null' });
    }
    if (!ethereal && !client) {
      throw new Problem(400, { detail: 'Error sending email. Authorized Party/Client cannot be null' });
    }

    try {
      if (ethereal) {
        const result = await this.emailService.send(message, true, true);
        return result;
      } else {
        // create the transaction...
        const trxn = await this.dataService.createTransaction(client, message);

        // queue up the messages...
        const delayTS = trxn.messages[0].delayTimestamp;
        const delay = delayTS ? utils.calculateDelayMS(delayTS) : undefined;
        this.queueService.enqueue(client, trxn.messages[0], { delay: delay });

        //return to caller in API format
        return transformer.toTransactionResponse(trxn);
      }
    } catch (e) {
      log.error(e.message, { error: e, function: 'sendEmail' });
      throw new Problem(500, { detail: `Error sending email. ${e.message}` });
    }
  }

  /**
   * @function sendEmailMerge
   * @description Creates and Queues the API messages for delivery
   *
   * @param {string} client - the authorized party / client
   * @param {object} template - the API email template
   * @param {boolean} ethereal - if true, then use the Ethereal connection, send email immediately.
   * @returns {object} TransactionResponse
   */
  async sendEmailMerge(client, template, ethereal = false) {
    if (!template) {
      throw new Problem(400, { detail: 'Error sending email merge. Email templates/contexts cannot be null' });
    }
    if (!ethereal && !client) {
      throw new Problem(400, { detail: 'Error sending email merge. Authorized Party/Client cannot be null' });
    }

    try {
      if (ethereal) {
        const contexts = mergeComponent.mergeTemplate(template);

        // Send all mail messages with defined transport object
        const results = await Promise.all(contexts.map(context => {
          // Remove delay as we do not use the queue for Ethereal messages
          delete context.delayTS;
          return this.emailService.send(context, true, true);
        }));

        return results;
      } else {
        // build out the individual messages from the payload...
        const contexts = mergeComponent.mergeTemplate(template);

        // create the transaction and messages...
        let trxn = await this.dataService.createTransaction(client, contexts);

        // Send all mail messages with defined transport object
        trxn.messages.forEach(msg => {
          const delayTS = msg.delayTimestamp;
          const delay = delayTS ? utils.calculateDelayMS(delayTS) : undefined;
          this.queueService.enqueue(client, msg, { delay: delay });
        });

        // return transaction in API format
        return transformer.toTransactionResponse(trxn);
      }
    } catch (e) {
      log.error(`Send Email Merge error. ${e.message}`, { error: e, function: 'sendEmailMerge' });
      throw new Problem(500, { detail: `Error sending email merge. ${e.message}` });
    }
  }
}

module.exports = ChesService;