notifications.js

import _ from 'lodash'
import nanoid from 'nanoid'

import helpers from './database/helpers'

import { resolveModuleRootPath } from './util'

const getOriginatingModule = () => {
  const origPrepareStackTrace = Error.prepareStackTrace
  Error.prepareStackTrace = (_, stack) => stack

  const err = new Error()
  const stack = err.stack
  Error.prepareStackTrace = origPrepareStackTrace
  stack.shift()

  return stack[1].getFileName()
}

const notifications = ({ knex, modules, logger, events }) => {
  const toDatabase = (knex, notification) => ({
    id: notification.id,
    message: notification.message,
    level: notification.level,
    module_id: notification.moduleId,
    module_icon: notification.icon,
    module_name: notification.name,
    redirect_url: notification.url,
    created_on: helpers(knex).date.now(),
    read: helpers(knex).bool.false(),
    archived: helpers(knex).bool.false()
  })

  const fromDatabase = (knex, row) => ({
    id: row.id,
    message: row.message,
    level: row.level,
    moduleId: row.module_id,
    icon: row.module_icon,
    name: row.module_name,
    url: row.redirect_url,
    date: new Date(row.created_on),
    sound: false,
    read: helpers(knex).bool.parse(row.read)
  })

  // TODO: a bunch of functions below doesn't use `await`, should they actually be `async`?

  /**
   * Marks a single notification as read (but doesn't archive it)
   * @param  {string} notificationId The id of the notification to mark as read
   * @return {Promise}
   */
  const markAsRead = async notificationId =>
    knex('notifications')
      .where({ id: notificationId })
      .update({ read: helpers(knex).bool.true() })
      .then()

  /**
   * Marks all notifications as read (but doesn't archive them)
   * @return {Promise}
   */
  const markAllAsRead = async () =>
    knex('notifications')
      .update({ read: helpers(knex).bool.true() })
      .then()

  /**
   * Get the top 100 (unseen) notifications
   * @return {Promise<Array<Notification>>} The list of all unseen notifications
   */
  const getInbox = async () =>
    knex('notifications')
      .where({ archived: helpers(knex).bool.false() })
      .orderBy('created_on', 'DESC')
      .limit(100)
      .then(rows => rows.map(row => fromDatabase(knex, row)))

  /**
   * Returns all archived notifications
   * @return {Promise<Array<Notification>>} The list of all archived notifications
   */
  const getArchived = async () =>
    knex('notifications')
      .where({ archived: helpers(knex).bool.true() })
      .orderBy('created_on', 'DESC')
      .limit(100)
      .then(rows => rows.map(row => fromDatabase(knex, row)))

  /**
 * Archives a single notification
 * @param  {string} notificationId The id of the notification to archive
 * @return {Promise}
 */
  const archive = async notificationId =>
    knex('notifications')
      .where({ id: notificationId })
      .update({ archived: helpers(knex).bool.true() })
      .then()

  /**
 * Archives all notifications
 * @return {Promise}
 */
  const archiveAll = async () =>
    knex('notifications')
      .update({ archived: helpers(knex).bool.true() })
      .then()

  // Internal use only
  // Binds events to actions
  const _bindEvents = () => {
    events.on('notifications.getAll', async () => {
      events.emit('notifications.all', await getInbox())
    })

    events.on('notifications.read', async id => {
      await markAsRead(id)
      events.emit('notifications.all', await getInbox())
    })

    events.on('notifications.allRead', async () => {
      await markAllAsRead()
      events.emit('notifications.all', await getInbox())
    })

    events.on('notifications.trashAll', async () => {
      await archiveAll()
      events.emit('notifications.all', await getInbox())
    })
  }

  /**
   * Create and append a new Notification in the Hub. Emits a `notifications.new` event.
   * @param  {string} options.message     (required) The body message of the notification
   * @param  {string} options.redirectUrl (optional) The URL the users will be redirected to
   *                                      when clicking on the notification
   * @param  {string} options.level       (optional) The level (info, success, error, warning). Defaults to `info`.
   * @param  {bool} options.enableSound (optional) Whether the notification will trigger a buzzing sound
   *                                    if a user is currently logged on the dashboard. (defaults to `false`)
   * @return {Promise}
   */
  const create = async ({ message, redirectUrl, level, enableSound }) => {
    if (!message || typeof message !== 'string') {
      throw new Error("'message' is mandatory and should be a string")
    }

    if (!level || typeof level !== 'string' || !_.includes(['info', 'error', 'success'], level.toLowerCase())) {
      level = 'info'
    } else {
      level = level.toLowerCase()
    }

    const callingFile = getOriginatingModule()
    const callingModuleRoot = callingFile && resolveModuleRootPath(callingFile)

    const module = _.find(modules, mod => {
      return mod.root === callingModuleRoot
    })

    let options = {
      moduleId: 'botpress',
      icon: 'view_module',
      name: 'botpress',
      url: _.isString(redirectUrl) ? redirectUrl : '/'
    }

    if (module) {
      // because the bot itself can send notifications
      options = {
        moduleId: module.name,
        icon: module.settings.menuIcon,
        name: module.settings.menuText,
        url: redirectUrl
      }

      if (!redirectUrl || typeof url !== 'string') {
        options.url = `/modules/${module.name}`
      }
    }

    const notification = {
      id: nanoid(),
      message: message,
      level: level,
      moduleId: options.moduleId,
      icon: options.icon,
      name: options.name,
      url: options.url,
      date: new Date(),
      sound: enableSound || false,
      read: false
    }

    await knex('notifications')
      .insert(toDatabase(knex, notification))
      .then()

    if (logger) {
      const logMessage = `[notification::${notification.moduleId}] ${notification.message}`
      const loggerLevel = logger[level] || logger.info
      loggerLevel(logMessage)
    }

    if (events) {
      events.emit('notifications.new', notification)
    }
  }

  return {
    // ----> Start of legacy API (DEPRECATED as of Botpress 1.1)
    load: getInbox,
    send: ({ message, url, level, sound }) => {
      return create({ message, redirectUrl: url, level, enableSound: sound })
    },
    // End of legacy API <---
    markAsRead,
    markAllAsRead,
    archiveAll,
    archive,
    getInbox,
    getArchived,
    create,
    // internal API
    _bindEvents
  }
}

module.exports = notifications