v12.0.0 - initial commit
This commit is contained in:
commit
e2c49ea43c
1145 changed files with 97211 additions and 0 deletions
52
packages/server/lib/notifier/codes.js
Normal file
52
packages/server/lib/notifier/codes.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
const T = require('../time')
|
||||
|
||||
const PING = 'PING'
|
||||
const STALE = 'STALE'
|
||||
const LOW_CRYPTO_BALANCE = 'LOW_CRYPTO_BALANCE'
|
||||
const HIGH_CRYPTO_BALANCE = 'HIGH_CRYPTO_BALANCE'
|
||||
const CASH_BOX_FULL = 'CASH_BOX_FULL'
|
||||
const LOW_CASH_OUT = 'LOW_CASH_OUT'
|
||||
const LOW_RECYCLER_STACKER = 'LOW_RECYCLER_STACKER'
|
||||
const SECURITY = 'SECURITY'
|
||||
|
||||
const CODES_DISPLAY = {
|
||||
PING: 'Machine Down',
|
||||
STALE: 'Machine Stuck',
|
||||
LOW_CRYPTO_BALANCE: 'Low Crypto Balance',
|
||||
HIGH_CRYPTO_BALANCE: 'High Crypto Balance',
|
||||
CASH_BOX_FULL: 'Cash box full',
|
||||
LOW_CASH_OUT: 'Low Cash-out',
|
||||
LOW_RECYCLER_STACKER: 'Low Recycler Stacker',
|
||||
HIGH_RECYCLER_STACKER: 'High Recycler Stacker',
|
||||
CASHBOX_REMOVED: 'Cashbox removed',
|
||||
}
|
||||
|
||||
const NETWORK_DOWN_TIME = 3 * T.minute
|
||||
const STALE_STATE = 7 * T.minute
|
||||
const ALERT_SEND_INTERVAL = T.hour
|
||||
|
||||
const NOTIFICATION_TYPES = {
|
||||
HIGH_VALUE_TX: 'highValueTransaction',
|
||||
NORMAL_VALUE_TX: 'transaction',
|
||||
FIAT_BALANCE: 'fiatBalance',
|
||||
CRYPTO_BALANCE: 'cryptoBalance',
|
||||
COMPLIANCE: 'compliance',
|
||||
ERROR: 'error',
|
||||
SECURITY: 'security',
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PING,
|
||||
STALE,
|
||||
LOW_CRYPTO_BALANCE,
|
||||
HIGH_CRYPTO_BALANCE,
|
||||
CASH_BOX_FULL,
|
||||
LOW_CASH_OUT,
|
||||
LOW_RECYCLER_STACKER,
|
||||
SECURITY,
|
||||
CODES_DISPLAY,
|
||||
NETWORK_DOWN_TIME,
|
||||
STALE_STATE,
|
||||
ALERT_SEND_INTERVAL,
|
||||
NOTIFICATION_TYPES,
|
||||
}
|
||||
107
packages/server/lib/notifier/email.js
Normal file
107
packages/server/lib/notifier/email.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
const _ = require('lodash/fp')
|
||||
const utils = require('./utils')
|
||||
|
||||
const email = require('../email')
|
||||
|
||||
const {
|
||||
PING,
|
||||
STALE,
|
||||
LOW_CRYPTO_BALANCE,
|
||||
HIGH_CRYPTO_BALANCE,
|
||||
CASH_BOX_FULL,
|
||||
LOW_CASH_OUT,
|
||||
LOW_RECYCLER_STACKER,
|
||||
SECURITY,
|
||||
} = require('./codes')
|
||||
|
||||
function alertSubject(alertRec, config) {
|
||||
let alerts = []
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.forEach(device => {
|
||||
alerts = _.concat(alerts, utils.deviceAlerts(config, alertRec, device))
|
||||
}, _.keys(alertRec.devices))
|
||||
|
||||
if (alerts.length === 0) return null
|
||||
|
||||
const alertTypes = _.flow(
|
||||
_.map('code'),
|
||||
_.uniq,
|
||||
_.map(utils.codeDisplay),
|
||||
_.sortBy(o => o),
|
||||
)(alerts)
|
||||
return '[Lamassu] Errors reported: ' + alertTypes.join(', ')
|
||||
}
|
||||
|
||||
function printEmailAlerts(alertRec, config) {
|
||||
let body = 'Errors were reported by your Lamassu Machines.\n'
|
||||
|
||||
if (config.balance && alertRec.general.length !== 0) {
|
||||
body += '\nGeneral errors:\n'
|
||||
body += emailAlerts(alertRec.general) + '\n'
|
||||
}
|
||||
|
||||
_.forEach(device => {
|
||||
const deviceName = alertRec.deviceNames[device]
|
||||
body += '\nErrors for ' + deviceName + ':\n'
|
||||
|
||||
const alerts = utils.deviceAlerts(config, alertRec, device)
|
||||
|
||||
body += emailAlerts(alerts)
|
||||
}, _.keys(alertRec.devices))
|
||||
return body
|
||||
}
|
||||
|
||||
function emailAlerts(alerts) {
|
||||
return _.join('\n', _.map(emailAlert, alerts)) + '\n'
|
||||
}
|
||||
|
||||
function emailAlert(alert) {
|
||||
switch (alert.code) {
|
||||
case PING:
|
||||
if (alert.age) {
|
||||
const pingAge = utils.formatAge(alert.age, {
|
||||
compact: true,
|
||||
verbose: true,
|
||||
})
|
||||
return `Machine down for ${pingAge}`
|
||||
}
|
||||
return 'Machine down for a while.'
|
||||
case STALE: {
|
||||
const stuckAge = utils.formatAge(alert.age, {
|
||||
compact: true,
|
||||
verbose: true,
|
||||
})
|
||||
return `Machine is stuck on ${alert.state} screen for ${stuckAge}`
|
||||
}
|
||||
case LOW_CRYPTO_BALANCE: {
|
||||
const balance = utils.formatCurrency(
|
||||
alert.fiatBalance.balance,
|
||||
alert.fiatCode,
|
||||
)
|
||||
return `Low balance in ${alert.cryptoCode} [${balance}]`
|
||||
}
|
||||
case HIGH_CRYPTO_BALANCE: {
|
||||
const highBalance = utils.formatCurrency(
|
||||
alert.fiatBalance.balance,
|
||||
alert.fiatCode,
|
||||
)
|
||||
return `High balance in ${alert.cryptoCode} [${highBalance}]`
|
||||
}
|
||||
case CASH_BOX_FULL:
|
||||
return `Cash box full on ${alert.machineName} [${alert.notes} banknotes]`
|
||||
case LOW_CASH_OUT:
|
||||
return `Cassette for ${alert.denomination} ${alert.fiatCode} low [${alert.notes} banknotes]`
|
||||
case LOW_RECYCLER_STACKER:
|
||||
return `Recycler for ${alert.denomination} ${alert.fiatCode} low [${alert.notes} banknotes]`
|
||||
case SECURITY:
|
||||
return `Cashbox removed on ${alert.machineName}`
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = email.sendMessage
|
||||
|
||||
module.exports = { alertSubject, printEmailAlerts, sendMessage }
|
||||
380
packages/server/lib/notifier/index.js
Normal file
380
packages/server/lib/notifier/index.js
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
const _ = require('lodash/fp')
|
||||
|
||||
const configManager = require('../new-config-manager')
|
||||
const logger = require('../logger')
|
||||
const queries = require('./queries')
|
||||
const settingsLoader = require('../new-settings-loader')
|
||||
const customers = require('../customers')
|
||||
|
||||
const notificationCenter = require('./notificationCenter')
|
||||
const utils = require('./utils')
|
||||
const emailFuncs = require('./email')
|
||||
const smsFuncs = require('./sms')
|
||||
const webhookFuncs = require('./webhook')
|
||||
const { STALE, STALE_STATE } = require('./codes')
|
||||
|
||||
function buildMessage(alerts, notifications) {
|
||||
const smsEnabled = utils.isActive(notifications.sms)
|
||||
const emailEnabled = utils.isActive(notifications.email)
|
||||
|
||||
let rec = {}
|
||||
if (smsEnabled) {
|
||||
rec = _.set(['sms', 'body'])(
|
||||
smsFuncs.printSmsAlerts(alerts, notifications.sms),
|
||||
)(rec)
|
||||
}
|
||||
if (emailEnabled) {
|
||||
rec = _.set(['email', 'subject'])(
|
||||
emailFuncs.alertSubject(alerts, notifications.email),
|
||||
)(rec)
|
||||
rec = _.set(['email', 'body'])(
|
||||
emailFuncs.printEmailAlerts(alerts, notifications.email),
|
||||
)(rec)
|
||||
}
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
function checkNotification(plugins) {
|
||||
const notifications = plugins.getNotificationConfig()
|
||||
const smsEnabled = utils.isActive(notifications.sms)
|
||||
const emailEnabled = utils.isActive(notifications.email)
|
||||
const notificationCenterEnabled = utils.isActive(
|
||||
notifications.notificationCenter,
|
||||
)
|
||||
|
||||
if (!(notificationCenterEnabled || smsEnabled || emailEnabled))
|
||||
return Promise.resolve()
|
||||
|
||||
return getAlerts(plugins)
|
||||
.then(alerts => {
|
||||
notifyIfActive('errors', 'errorAlertsNotify', alerts)
|
||||
const currentAlertFingerprint = utils.buildAlertFingerprint(
|
||||
alerts,
|
||||
notifications,
|
||||
)
|
||||
if (!currentAlertFingerprint) {
|
||||
const inAlert = !!utils.getAlertFingerprint()
|
||||
// variables for setAlertFingerprint: (fingerprint = null, lastAlertTime = null)
|
||||
utils.setAlertFingerprint(null, null)
|
||||
if (inAlert)
|
||||
return utils.sendNoAlerts(plugins, smsEnabled, emailEnabled)
|
||||
}
|
||||
if (utils.shouldNotAlert(currentAlertFingerprint)) return
|
||||
|
||||
const message = buildMessage(alerts, notifications)
|
||||
utils.setAlertFingerprint(currentAlertFingerprint, Date.now())
|
||||
return plugins.sendMessage(message)
|
||||
})
|
||||
.then(results => {
|
||||
if (results && results.length > 0) {
|
||||
logger.debug('Successfully sent alerts')
|
||||
}
|
||||
})
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
function getAlerts(plugins) {
|
||||
return Promise.all([
|
||||
plugins.checkBalances(),
|
||||
queries.machineEvents(),
|
||||
plugins.getMachineNames(),
|
||||
]).then(([balances, events, devices]) => {
|
||||
notifyIfActive('balance', 'balancesNotify', balances)
|
||||
return buildAlerts(checkPings(devices), balances, events, devices)
|
||||
})
|
||||
}
|
||||
|
||||
function buildAlerts(pings, balances, events, devices) {
|
||||
const alerts = { devices: {}, deviceNames: {} }
|
||||
alerts.general = _.filter(r => !r.deviceId, balances)
|
||||
_.forEach(device => {
|
||||
const deviceId = device.deviceId
|
||||
const ping = pings[deviceId] || []
|
||||
const stuckScreen = checkStuckScreen(events, device)
|
||||
|
||||
alerts.devices = _.set(
|
||||
[deviceId, 'balanceAlerts'],
|
||||
_.filter(['deviceId', deviceId], balances),
|
||||
alerts.devices,
|
||||
)
|
||||
alerts.devices[deviceId].deviceAlerts = _.isEmpty(ping) ? stuckScreen : ping
|
||||
|
||||
alerts.deviceNames[deviceId] = device.name
|
||||
}, devices)
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
function checkPings(devices) {
|
||||
return Object.fromEntries(devices.map(d => [d.deviceId, utils.checkPing(d)]))
|
||||
}
|
||||
|
||||
function checkStuckScreen(deviceEvents, machine) {
|
||||
const lastEvent = _.pipe(
|
||||
_.filter(e => e.device_id === machine.deviceId),
|
||||
_.sortBy(utils.getDeviceTime),
|
||||
_.map(utils.parseEventNote),
|
||||
_.last,
|
||||
)(deviceEvents)
|
||||
|
||||
if (!lastEvent) return []
|
||||
|
||||
const state = lastEvent.note.state
|
||||
const isIdle = lastEvent.note.isIdle
|
||||
|
||||
if (isIdle) return []
|
||||
|
||||
const age = Math.floor(lastEvent.age)
|
||||
const machineName = machine.name
|
||||
if (age > STALE_STATE) return [{ code: STALE, state, age, machineName }]
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function transactionNotify(tx, rec) {
|
||||
return settingsLoader.loadConfig().then(config => {
|
||||
const notifSettings = configManager.getGlobalNotifications(config)
|
||||
const highValueTx = tx.fiat.gt(
|
||||
notifSettings.highValueTransaction || Infinity,
|
||||
)
|
||||
const isCashOut = tx.direction === 'cashOut'
|
||||
|
||||
// for notification center
|
||||
const directionDisplay = isCashOut ? 'cash-out' : 'cash-in'
|
||||
const readyToNotify =
|
||||
!isCashOut || (tx.direction === 'cashOut' && rec.isRedemption)
|
||||
// awaiting for redesign. notification should not be sent if toggle in the settings table is disabled,
|
||||
// but currently we're sending notifications of high value tx even with the toggle disabled
|
||||
if (readyToNotify && !highValueTx) {
|
||||
notifyIfActive(
|
||||
'transactions',
|
||||
'notifCenterTransactionNotify',
|
||||
highValueTx,
|
||||
directionDisplay,
|
||||
tx.fiat,
|
||||
tx.fiatCode,
|
||||
tx.deviceId,
|
||||
tx.toAddress,
|
||||
)
|
||||
} else if (readyToNotify && highValueTx) {
|
||||
notificationCenter.notifCenterTransactionNotify(
|
||||
highValueTx,
|
||||
directionDisplay,
|
||||
tx.fiat,
|
||||
tx.fiatCode,
|
||||
tx.deviceId,
|
||||
tx.toAddress,
|
||||
)
|
||||
}
|
||||
|
||||
// alert through sms or email any transaction or high value transaction, if SMS || email alerts are enabled
|
||||
const walletSettings = configManager.getWalletSettings(
|
||||
tx.cryptoCode,
|
||||
config,
|
||||
)
|
||||
const zeroConfLimit = walletSettings.zeroConfLimit || 0
|
||||
const zeroConf = isCashOut && tx.fiat.lte(zeroConfLimit)
|
||||
const notificationsEnabled =
|
||||
notifSettings.sms.transactions || notifSettings.email.transactions
|
||||
const customerPromise = tx.customerId
|
||||
? customers.getById(tx.customerId)
|
||||
: Promise.resolve({})
|
||||
|
||||
if (!notificationsEnabled && !highValueTx) return Promise.resolve()
|
||||
if (zeroConf && isCashOut && !rec.isRedemption && !rec.error)
|
||||
return Promise.resolve()
|
||||
if (!zeroConf && rec.isRedemption)
|
||||
return sendRedemptionMessage(tx.id, rec.error)
|
||||
|
||||
return Promise.all([queries.getMachineName(tx.deviceId), customerPromise])
|
||||
.then(([machineName, customer]) => {
|
||||
return utils.buildTransactionMessage(
|
||||
tx,
|
||||
rec,
|
||||
highValueTx,
|
||||
machineName,
|
||||
customer,
|
||||
)
|
||||
})
|
||||
.then(([msg, highValueTx]) => sendTransactionMessage(msg, highValueTx))
|
||||
})
|
||||
}
|
||||
|
||||
function complianceNotify(settings, customer, deviceId, action, period) {
|
||||
const timestamp = new Date().toLocaleString()
|
||||
return queries.getMachineName(deviceId).then(machineName => {
|
||||
const notifications = configManager.getGlobalNotifications(settings.config)
|
||||
|
||||
const msgCore = {
|
||||
BLOCKED: `was blocked`,
|
||||
SUSPENDED: `was suspended for ${!!period && period} days`,
|
||||
PENDING_COMPLIANCE: `is waiting for your manual approval`,
|
||||
}
|
||||
|
||||
const rec = {
|
||||
sms: {
|
||||
body: `Customer ${customer.phone} ${msgCore[action]} - ${machineName}. ${timestamp}`,
|
||||
},
|
||||
email: {
|
||||
subject: `Customer compliance`,
|
||||
body: `Customer ${customer.phone} ${msgCore[action]} in machine ${machineName}. ${timestamp}`,
|
||||
},
|
||||
webhook: {
|
||||
topic: `Customer compliance`,
|
||||
content: `Customer ${customer.phone} ${msgCore[action]} in machine ${machineName}. ${timestamp}`,
|
||||
},
|
||||
}
|
||||
|
||||
const promises = []
|
||||
|
||||
const emailActive =
|
||||
notifications.email.active && notifications.email.compliance
|
||||
|
||||
const smsActive = notifications.sms.active && notifications.sms.compliance
|
||||
|
||||
const webhookActive = true
|
||||
|
||||
if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec))
|
||||
if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec))
|
||||
if (webhookActive) promises.push(webhookFuncs.sendMessage(settings, rec))
|
||||
|
||||
notifyIfActive(
|
||||
'compliance',
|
||||
'customerComplianceNotify',
|
||||
customer,
|
||||
deviceId,
|
||||
action,
|
||||
machineName,
|
||||
period,
|
||||
)
|
||||
|
||||
return Promise.all(promises).catch(err =>
|
||||
console.error(
|
||||
`An error occurred when sending a notification. Please check your notification preferences and 3rd party account configuration: ${err.stack}`,
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function sendRedemptionMessage(txId, error) {
|
||||
const subject = `Here's an update on transaction ${txId}`
|
||||
const body = error ? `Error: ${error}` : 'It was just dispensed successfully'
|
||||
|
||||
const rec = {
|
||||
sms: {
|
||||
body: `${subject} - ${body}`,
|
||||
},
|
||||
email: {
|
||||
subject,
|
||||
body,
|
||||
},
|
||||
webhook: {
|
||||
topic: `Transaction update`,
|
||||
content: body,
|
||||
},
|
||||
}
|
||||
return sendTransactionMessage(rec)
|
||||
}
|
||||
|
||||
function sendTransactionMessage(rec, isHighValueTx) {
|
||||
return settingsLoader.load().then(settings => {
|
||||
const notifications = configManager.getGlobalNotifications(settings.config)
|
||||
|
||||
const promises = []
|
||||
|
||||
const emailActive =
|
||||
notifications.email.active &&
|
||||
(notifications.email.transactions || isHighValueTx)
|
||||
if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec))
|
||||
|
||||
const smsActive =
|
||||
notifications.sms.active &&
|
||||
(notifications.sms.transactions || isHighValueTx)
|
||||
if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec))
|
||||
|
||||
// TODO: Webhook transaction notifications are dependent on notification settings, due to how transactionNotify() is programmed
|
||||
// As changing it would require structural change to that function and the current behavior is temporary (webhooks will eventually have settings tied to them), it's not worth those changes right now
|
||||
const webhookActive = true
|
||||
if (webhookActive) promises.push(webhookFuncs.sendMessage(settings, rec))
|
||||
|
||||
return Promise.all(promises).catch(err =>
|
||||
console.error(
|
||||
`An error occurred when sending a notification. Please check your notification preferences and 3rd party account configuration: ${err.stack}`,
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function cashboxNotify(deviceId) {
|
||||
return Promise.all([
|
||||
settingsLoader.load(),
|
||||
queries.getMachineName(deviceId),
|
||||
]).then(([settings, machineName]) => {
|
||||
const notifications = configManager.getGlobalNotifications(settings.config)
|
||||
const rec = {
|
||||
sms: {
|
||||
body: `Cashbox removed - ${machineName}`,
|
||||
},
|
||||
email: {
|
||||
subject: `Cashbox removal`,
|
||||
body: `Cashbox removed in machine ${machineName}`,
|
||||
},
|
||||
webhook: {
|
||||
topic: `Cashbox removal`,
|
||||
content: `Cashbox removed in machine ${machineName}`,
|
||||
},
|
||||
}
|
||||
|
||||
const promises = []
|
||||
|
||||
const emailActive =
|
||||
notifications.email.active && notifications.email.security
|
||||
|
||||
const smsActive = notifications.sms.active && notifications.sms.security
|
||||
|
||||
const webhookActive = true
|
||||
|
||||
if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec))
|
||||
if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec))
|
||||
if (webhookActive) promises.push(webhookFuncs.sendMessage(settings, rec))
|
||||
notifyIfActive('security', 'cashboxNotify', deviceId)
|
||||
|
||||
return Promise.all(promises).catch(err =>
|
||||
console.error(
|
||||
`An error occurred when sending a notification. Please check your notification preferences and 3rd party account configuration: ${err.stack}`,
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// for notification center, check if type of notification is active before calling the respective notify function
|
||||
const notifyIfActive = (type, fnName, ...args) => {
|
||||
return settingsLoader
|
||||
.loadConfig()
|
||||
.then(config => {
|
||||
const notificationSettings =
|
||||
configManager.getGlobalNotifications(config).notificationCenter
|
||||
if (!notificationCenter[fnName])
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`Notification function ${fnName} for type ${type} does not exist`,
|
||||
),
|
||||
)
|
||||
if (!(notificationSettings.active && notificationSettings[type]))
|
||||
return Promise.resolve()
|
||||
return notificationCenter[fnName](...args)
|
||||
})
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
transactionNotify,
|
||||
complianceNotify,
|
||||
checkNotification,
|
||||
checkPings,
|
||||
checkStuckScreen,
|
||||
sendRedemptionMessage,
|
||||
cashboxNotify,
|
||||
notifyIfActive,
|
||||
}
|
||||
298
packages/server/lib/notifier/notificationCenter.js
Normal file
298
packages/server/lib/notifier/notificationCenter.js
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
const _ = require('lodash/fp')
|
||||
|
||||
const queries = require('./queries')
|
||||
const utils = require('./utils')
|
||||
const customers = require('../customers')
|
||||
const {
|
||||
NOTIFICATION_TYPES: {
|
||||
SECURITY,
|
||||
COMPLIANCE,
|
||||
CRYPTO_BALANCE,
|
||||
FIAT_BALANCE,
|
||||
ERROR,
|
||||
HIGH_VALUE_TX,
|
||||
NORMAL_VALUE_TX,
|
||||
},
|
||||
|
||||
STALE,
|
||||
PING,
|
||||
|
||||
HIGH_CRYPTO_BALANCE,
|
||||
LOW_CRYPTO_BALANCE,
|
||||
CASH_BOX_FULL,
|
||||
LOW_CASH_OUT,
|
||||
LOW_RECYCLER_STACKER,
|
||||
} = require('./codes')
|
||||
|
||||
const sanctionsNotify = (customer, phone) => {
|
||||
const code = 'SANCTIONS'
|
||||
const detailB = utils.buildDetail({ customerId: customer.id, code })
|
||||
const addNotif = phone =>
|
||||
queries.addNotification(
|
||||
COMPLIANCE,
|
||||
`Blocked customer with phone ${phone} for being on the OFAC sanctions list`,
|
||||
detailB,
|
||||
)
|
||||
// if it's a new customer then phone comes as undefined
|
||||
return phone
|
||||
? addNotif(phone)
|
||||
: customers.getById(customer.id).then(c => addNotif(c.phone))
|
||||
}
|
||||
|
||||
const clearOldCustomerSuspendedNotifications = (customerId, deviceId) => {
|
||||
const detailB = utils.buildDetail({ code: 'SUSPENDED', customerId, deviceId })
|
||||
return queries.invalidateNotification(detailB, 'compliance')
|
||||
}
|
||||
|
||||
const customerComplianceNotify = (
|
||||
customer,
|
||||
deviceId,
|
||||
code,
|
||||
machineName,
|
||||
days = null,
|
||||
) => {
|
||||
// code for now can be "BLOCKED", "SUSPENDED"
|
||||
const detailB = utils.buildDetail({ customerId: customer.id, code, deviceId })
|
||||
const date = new Date()
|
||||
if (days) {
|
||||
date.setDate(date.getDate() + days)
|
||||
}
|
||||
const message =
|
||||
code === 'SUSPENDED'
|
||||
? `Customer ${customer.phone} suspended until ${date.toLocaleString()}`
|
||||
: code === 'BLOCKED'
|
||||
? `Customer ${customer.phone} blocked`
|
||||
: `Customer ${customer.phone} has pending compliance in machine ${machineName}`
|
||||
|
||||
return clearOldCustomerSuspendedNotifications(customer.id, deviceId)
|
||||
.then(() => queries.getValidNotifications(COMPLIANCE, detailB))
|
||||
.then(res => {
|
||||
if (res.length > 0) return Promise.resolve()
|
||||
return queries.addNotification(COMPLIANCE, message, detailB)
|
||||
})
|
||||
}
|
||||
|
||||
const clearOldFiatNotifications = balances => {
|
||||
return queries.getAllValidNotifications(FIAT_BALANCE).then(notifications => {
|
||||
const filterByBalance = _.filter(notification => {
|
||||
const { cassette, deviceId } = notification.detail
|
||||
return !_.find(
|
||||
balance =>
|
||||
balance.cassette === cassette && balance.deviceId === deviceId,
|
||||
)(balances)
|
||||
})
|
||||
const indexesToInvalidate = _.compose(
|
||||
_.map('id'),
|
||||
filterByBalance,
|
||||
)(notifications)
|
||||
const notInvalidated = _.filter(notification => {
|
||||
return !_.find(id => notification.id === id)(indexesToInvalidate)
|
||||
}, notifications)
|
||||
return (
|
||||
indexesToInvalidate.length
|
||||
? queries.batchInvalidate(indexesToInvalidate)
|
||||
: Promise.resolve()
|
||||
).then(() => notInvalidated)
|
||||
})
|
||||
}
|
||||
|
||||
const fiatBalancesNotify = fiatWarnings => {
|
||||
return clearOldFiatNotifications(fiatWarnings).then(notInvalidated => {
|
||||
return fiatWarnings.forEach(balance => {
|
||||
if (
|
||||
_.find(o => {
|
||||
const { cassette, deviceId } = o.detail
|
||||
return cassette === balance.cassette && deviceId === balance.deviceId
|
||||
}, notInvalidated)
|
||||
)
|
||||
return
|
||||
const message =
|
||||
balance.code === LOW_CASH_OUT
|
||||
? `Cash-out cassette ${balance.cassette} low or empty!`
|
||||
: balance.code === LOW_RECYCLER_STACKER
|
||||
? `Recycler ${balance.cassette} low or empty!`
|
||||
: balance.code === CASH_BOX_FULL
|
||||
? `Cash box full or almost full!`
|
||||
: `Cash box full or almost full!` /* Shouldn't happen */
|
||||
const detailB = utils.buildDetail({
|
||||
deviceId: balance.deviceId,
|
||||
cassette: balance.cassette,
|
||||
})
|
||||
return queries.addNotification(FIAT_BALANCE, message, detailB)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const clearOldCryptoNotifications = balances => {
|
||||
return queries.getAllValidNotifications(CRYPTO_BALANCE).then(res => {
|
||||
const filterByBalance = _.filter(notification => {
|
||||
const { cryptoCode, code } = notification.detail
|
||||
return !_.find(
|
||||
balance => balance.cryptoCode === cryptoCode && balance.code === code,
|
||||
)(balances)
|
||||
})
|
||||
const indexesToInvalidate = _.compose(_.map('id'), filterByBalance)(res)
|
||||
|
||||
const notInvalidated = _.filter(notification => {
|
||||
return !_.find(id => notification.id === id)(indexesToInvalidate)
|
||||
}, res)
|
||||
return (
|
||||
indexesToInvalidate.length
|
||||
? queries.batchInvalidate(indexesToInvalidate)
|
||||
: Promise.resolve()
|
||||
).then(() => notInvalidated)
|
||||
})
|
||||
}
|
||||
|
||||
const cryptoBalancesNotify = cryptoWarnings => {
|
||||
return clearOldCryptoNotifications(cryptoWarnings).then(notInvalidated => {
|
||||
return cryptoWarnings.forEach(balance => {
|
||||
// if notification exists in DB and wasnt invalidated then don't add a duplicate
|
||||
if (
|
||||
_.find(o => {
|
||||
const { code, cryptoCode } = o.detail
|
||||
return code === balance.code && cryptoCode === balance.cryptoCode
|
||||
}, notInvalidated)
|
||||
)
|
||||
return
|
||||
|
||||
const fiat = utils.formatCurrency(
|
||||
balance.fiatBalance.balance,
|
||||
balance.fiatCode,
|
||||
)
|
||||
const message = `${balance.code === HIGH_CRYPTO_BALANCE ? 'High' : 'Low'} balance in ${balance.cryptoCode} [${fiat}]`
|
||||
const detailB = utils.buildDetail({
|
||||
cryptoCode: balance.cryptoCode,
|
||||
code: balance.code,
|
||||
})
|
||||
return queries.addNotification(CRYPTO_BALANCE, message, detailB)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const balancesNotify = balances => {
|
||||
const isCryptoCode = c =>
|
||||
_.includes(c, [HIGH_CRYPTO_BALANCE, LOW_CRYPTO_BALANCE])
|
||||
const isFiatCode = c =>
|
||||
_.includes(c, [LOW_CASH_OUT, CASH_BOX_FULL, LOW_RECYCLER_STACKER])
|
||||
const by = o =>
|
||||
isCryptoCode(o) ? 'crypto' : isFiatCode(o) ? 'fiat' : undefined
|
||||
const warnings = _.flow(
|
||||
_.groupBy(_.flow(_.get(['code']), by)),
|
||||
_.update('crypto', _.defaultTo([])),
|
||||
_.update('fiat', _.defaultTo([])),
|
||||
)(balances)
|
||||
return Promise.all([
|
||||
cryptoBalancesNotify(warnings.crypto),
|
||||
fiatBalancesNotify(warnings.fiat),
|
||||
])
|
||||
}
|
||||
|
||||
const clearOldErrorNotifications = alerts => {
|
||||
return queries.getAllValidNotifications(ERROR).then(res => {
|
||||
// for each valid notification in DB see if it exists in alerts
|
||||
// if the notification doesn't exist in alerts, it is not valid anymore
|
||||
const filterByAlert = _.filter(notification => {
|
||||
const { code, deviceId } = notification.detail
|
||||
return !_.find(
|
||||
alert => alert.code === code && alert.deviceId === deviceId,
|
||||
)(alerts)
|
||||
})
|
||||
const indexesToInvalidate = _.compose(_.map('id'), filterByAlert)(res)
|
||||
if (!indexesToInvalidate.length) return Promise.resolve()
|
||||
return queries.batchInvalidate(indexesToInvalidate)
|
||||
})
|
||||
}
|
||||
|
||||
const errorAlertsNotify = alertRec => {
|
||||
const embedDeviceId = deviceId => _.assign({ deviceId })
|
||||
const mapToAlerts = _.map(it =>
|
||||
_.map(embedDeviceId(it), alertRec.devices[it].deviceAlerts),
|
||||
)
|
||||
const alerts = _.compose(_.flatten, mapToAlerts, _.keys)(alertRec.devices)
|
||||
|
||||
return clearOldErrorNotifications(alerts).then(() => {
|
||||
_.forEach(alert => {
|
||||
switch (alert.code) {
|
||||
case PING: {
|
||||
const detailB = utils.buildDetail({
|
||||
code: PING,
|
||||
age: alert.age ? alert.age : -1,
|
||||
deviceId: alert.deviceId,
|
||||
})
|
||||
return queries
|
||||
.getValidNotifications(ERROR, _.omit(['age'], detailB))
|
||||
.then(res => {
|
||||
if (res.length > 0) return Promise.resolve()
|
||||
const message = `Machine down`
|
||||
return queries.addNotification(ERROR, message, detailB)
|
||||
})
|
||||
}
|
||||
case STALE: {
|
||||
const detailB = utils.buildDetail({
|
||||
code: STALE,
|
||||
deviceId: alert.deviceId,
|
||||
})
|
||||
return queries.getValidNotifications(ERROR, detailB).then(res => {
|
||||
if (res.length > 0) return Promise.resolve()
|
||||
const message = `Machine is stuck on ${alert.state} screen`
|
||||
return queries.addNotification(ERROR, message, detailB)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, alerts)
|
||||
})
|
||||
}
|
||||
|
||||
function notifCenterTransactionNotify(
|
||||
isHighValue,
|
||||
direction,
|
||||
fiat,
|
||||
fiatCode,
|
||||
deviceId,
|
||||
cryptoAddress,
|
||||
) {
|
||||
const messageSuffix = isHighValue ? 'High value' : ''
|
||||
const message = `${messageSuffix} ${fiat} ${fiatCode} ${direction} transaction`
|
||||
const detailB = utils.buildDetail({
|
||||
deviceId: deviceId,
|
||||
direction,
|
||||
fiat,
|
||||
fiatCode,
|
||||
cryptoAddress,
|
||||
})
|
||||
return queries.addNotification(
|
||||
isHighValue ? HIGH_VALUE_TX : NORMAL_VALUE_TX,
|
||||
message,
|
||||
detailB,
|
||||
)
|
||||
}
|
||||
|
||||
const blacklistNotify = (tx, isAddressReuse) => {
|
||||
const code = isAddressReuse ? 'REUSED' : 'BLOCKED'
|
||||
const name = isAddressReuse ? 'reused' : 'blacklisted'
|
||||
|
||||
const detailB = utils.buildDetail({
|
||||
cryptoCode: tx.cryptoCode,
|
||||
code,
|
||||
cryptoAddress: tx.toAddress,
|
||||
})
|
||||
const message = `Blocked ${name} address: ${tx.cryptoCode} ${tx.toAddress.substr(0, 10)}...`
|
||||
return queries.addNotification(COMPLIANCE, message, detailB)
|
||||
}
|
||||
|
||||
const cashboxNotify = deviceId => {
|
||||
const detailB = utils.buildDetail({ deviceId: deviceId })
|
||||
const message = `Cashbox removed`
|
||||
return queries.addNotification(SECURITY, message, detailB)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanctionsNotify,
|
||||
customerComplianceNotify,
|
||||
balancesNotify,
|
||||
errorAlertsNotify,
|
||||
notifCenterTransactionNotify,
|
||||
blacklistNotify,
|
||||
cashboxNotify,
|
||||
}
|
||||
116
packages/server/lib/notifier/queries.js
Normal file
116
packages/server/lib/notifier/queries.js
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
const { v4: uuidv4 } = require('uuid')
|
||||
const pgp = require('pg-promise')()
|
||||
const _ = require('lodash/fp')
|
||||
|
||||
const dbm = require('../postgresql_interface')
|
||||
const db = require('../db')
|
||||
const logger = require('../logger')
|
||||
|
||||
// types of notifications able to be inserted into db:
|
||||
/*
|
||||
highValueTransaction - for transactions of value higher than threshold
|
||||
fiatBalance - when the number of notes in cash cassettes falls below threshold
|
||||
cryptoBalance - when ammount of crypto balance in fiat falls below or above low/high threshold
|
||||
compliance - notifications related to warnings triggered by compliance settings
|
||||
error - notifications related to errors
|
||||
*/
|
||||
|
||||
function getMachineName(machineId) {
|
||||
const sql = 'SELECT * FROM devices WHERE device_id=$1'
|
||||
return db
|
||||
.oneOrNone(sql, [machineId])
|
||||
.then(it => it.name)
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
const addNotification = (type, message, detail) => {
|
||||
const sql = `INSERT INTO notifications (id, type, message, detail) VALUES ($1, $2, $3, $4)`
|
||||
return db
|
||||
.oneOrNone(sql, [uuidv4(), type, message, detail])
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
const getAllValidNotifications = type => {
|
||||
const sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't'`
|
||||
return db.any(sql, [type]).catch(logger.error)
|
||||
}
|
||||
|
||||
const invalidateNotification = (detail, type) => {
|
||||
detail = _.omitBy(_.isEmpty, detail)
|
||||
const sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE valid = 't' AND type = $1 AND detail::jsonb @> $2::jsonb`
|
||||
return db.none(sql, [type, detail]).catch(logger.error)
|
||||
}
|
||||
|
||||
const batchInvalidate = ids => {
|
||||
const formattedIds = _.map(pgp.as.text, ids).join(',')
|
||||
const sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE id IN ($1^)`
|
||||
return db.none(sql, [formattedIds]).catch(logger.error)
|
||||
}
|
||||
|
||||
const clearBlacklistNotification = (cryptoCode, cryptoAddress) => {
|
||||
const sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE type = 'compliance' AND detail->>'cryptoCode' = $1 AND detail->>'cryptoAddress' = $2 AND (detail->>'code' = 'BLOCKED' OR detail->>'code' = 'REUSED')`
|
||||
return db.none(sql, [cryptoCode, cryptoAddress]).catch(logger.error)
|
||||
}
|
||||
|
||||
const getValidNotifications = (type, detail) => {
|
||||
const sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't' AND detail @> $2`
|
||||
return db.any(sql, [type, detail]).catch(logger.error)
|
||||
}
|
||||
|
||||
const WITHIN_PAST_WEEK = `created > (CURRENT_TIMESTAMP - INTERVAL '7' DAY)`
|
||||
|
||||
const getNotifications = () => {
|
||||
const sql = `
|
||||
SELECT * FROM notifications
|
||||
WHERE ${WITHIN_PAST_WEEK}
|
||||
ORDER BY created DESC
|
||||
`
|
||||
return db.any(sql).catch(logger.error)
|
||||
}
|
||||
const setRead = (id, read) => {
|
||||
const sql = `UPDATE notifications SET read = $1 WHERE id = $2`
|
||||
return db.none(sql, [read, id]).catch(logger.error)
|
||||
}
|
||||
|
||||
const markAllAsRead = () => {
|
||||
const sql = `UPDATE notifications SET read = 't'`
|
||||
return db.none(sql).catch(logger.error)
|
||||
}
|
||||
|
||||
const hasUnreadNotifications = () => {
|
||||
const sql = `
|
||||
SELECT EXISTS
|
||||
(SELECT * FROM notifications
|
||||
WHERE NOT read AND ${WITHIN_PAST_WEEK})
|
||||
`
|
||||
return db
|
||||
.oneOrNone(sql)
|
||||
.then(res => res.exists)
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
const getAlerts = () => {
|
||||
const types = ['fiatBalance', 'cryptoBalance', 'error']
|
||||
const sql = `
|
||||
SELECT * FROM notifications
|
||||
WHERE ${WITHIN_PAST_WEEK} AND valid='t' AND type IN ($1:list)
|
||||
ORDER BY created DESC
|
||||
`
|
||||
return db.any(sql, [types]).catch(logger.error)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
machineEvents: dbm.machineEvents,
|
||||
addNotification,
|
||||
getAllValidNotifications,
|
||||
invalidateNotification,
|
||||
batchInvalidate,
|
||||
clearBlacklistNotification,
|
||||
getValidNotifications,
|
||||
getNotifications,
|
||||
setRead,
|
||||
markAllAsRead,
|
||||
hasUnreadNotifications,
|
||||
getAlerts,
|
||||
getMachineName,
|
||||
}
|
||||
58
packages/server/lib/notifier/sms.js
Normal file
58
packages/server/lib/notifier/sms.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const _ = require('lodash/fp')
|
||||
const utils = require('./utils')
|
||||
const sms = require('../sms')
|
||||
|
||||
function printSmsAlerts(alertRec, config) {
|
||||
let alerts = []
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.forEach(device => {
|
||||
alerts = _.concat(alerts, utils.deviceAlerts(config, alertRec, device))
|
||||
}, _.keys(alertRec.devices))
|
||||
|
||||
if (alerts.length === 0) return null
|
||||
|
||||
const alertsMap = _.groupBy('code', alerts)
|
||||
|
||||
const alertTypes = _.map(entry => {
|
||||
const code = entry[0]
|
||||
const machineNames = _.filter(
|
||||
_.negate(_.isEmpty),
|
||||
_.map('machineName', entry[1]),
|
||||
)
|
||||
|
||||
const cryptoCodes = _.filter(
|
||||
_.negate(_.isEmpty),
|
||||
_.map('cryptoCode', entry[1]),
|
||||
)
|
||||
|
||||
return {
|
||||
codeDisplay: utils.codeDisplay(code),
|
||||
machineNames,
|
||||
cryptoCodes,
|
||||
}
|
||||
}, _.toPairs(alertsMap))
|
||||
|
||||
const mapByCodeDisplay = _.map(it => {
|
||||
if (_.isEmpty(it.machineNames) && _.isEmpty(it.cryptoCodes))
|
||||
return it.codeDisplay
|
||||
if (_.isEmpty(it.machineNames))
|
||||
return `${it.codeDisplay} (${it.cryptoCodes.join(', ')})`
|
||||
return `${it.codeDisplay} (${it.machineNames.join(', ')})`
|
||||
})
|
||||
|
||||
const displayAlertTypes = _.compose(
|
||||
_.uniq,
|
||||
mapByCodeDisplay,
|
||||
_.sortBy('codeDisplay'),
|
||||
)(alertTypes)
|
||||
|
||||
return '[Lamassu] Errors reported: ' + displayAlertTypes.join(', ')
|
||||
}
|
||||
|
||||
const sendMessage = sms.sendMessage
|
||||
|
||||
module.exports = { printSmsAlerts, sendMessage }
|
||||
28
packages/server/lib/notifier/test/email.test.js
Normal file
28
packages/server/lib/notifier/test/email.test.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
const email = require('../email')
|
||||
|
||||
const alertRec = {
|
||||
devices: {
|
||||
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: {
|
||||
balanceAlerts: [],
|
||||
deviceAlerts: [
|
||||
{ code: 'PING', age: 602784301.446, machineName: 'Abc123' },
|
||||
],
|
||||
},
|
||||
},
|
||||
deviceNames: {
|
||||
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: 'Abc123',
|
||||
},
|
||||
general: [],
|
||||
}
|
||||
|
||||
const printEmailMsg = `Errors were reported by your Lamassu Machines.
|
||||
|
||||
Errors for Abc123:
|
||||
Machine down for ~6 days
|
||||
`
|
||||
|
||||
test('Print Email Alers', () => {
|
||||
expect(email.printEmailAlerts(alertRec, { active: true, errors: true })).toBe(
|
||||
printEmailMsg,
|
||||
)
|
||||
})
|
||||
348
packages/server/lib/notifier/test/notifier.test.js
Normal file
348
packages/server/lib/notifier/test/notifier.test.js
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
const BN = require('../../../lib/bn')
|
||||
|
||||
const notifier = require('..')
|
||||
const utils = require('../utils')
|
||||
const smsFuncs = require('../sms')
|
||||
|
||||
afterEach(() => {
|
||||
// https://stackoverflow.com/questions/58151010/difference-between-resetallmocks-resetmodules-resetmoduleregistry-restoreallm
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
// mock plugins object with mock data to test functions
|
||||
const plugins = {
|
||||
sendMessage: rec => {
|
||||
return rec
|
||||
},
|
||||
getNotificationConfig: () => ({
|
||||
email_active: false,
|
||||
sms_active: true,
|
||||
email_errors: false,
|
||||
sms_errors: true,
|
||||
sms: { active: true, errors: true },
|
||||
email: { active: false, errors: false },
|
||||
}),
|
||||
getMachineNames: () => [
|
||||
{
|
||||
deviceId:
|
||||
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||
cashbox: 0,
|
||||
cassette1: 444,
|
||||
cassette2: 222,
|
||||
version: '7.5.0-beta.0',
|
||||
model: 'unknown',
|
||||
pairedAt: '2020-11-13T16:20:31.624Z',
|
||||
lastPing: '2020-11-16T13:11:03.169Z',
|
||||
name: 'Abc123',
|
||||
paired: true,
|
||||
cashOut: true,
|
||||
statuses: [{ label: 'Unresponsive', type: 'error' }],
|
||||
},
|
||||
],
|
||||
checkBalances: () => [],
|
||||
}
|
||||
|
||||
const tx = {
|
||||
id: 'bec8d452-9ea2-4846-841b-55a9df8bbd00',
|
||||
deviceId: '490ab16ee0c124512dc769be1f3e7ee3894ce1e5b4b8b975e134fb326e551e88',
|
||||
toAddress: 'bc1q7s4yy5n9vp6zhlf6mrw3cttdgx5l3ysr2mhc4v',
|
||||
cryptoAtoms: new BN(252100),
|
||||
cryptoCode: 'BTC',
|
||||
fiat: new BN(55),
|
||||
fiatCode: 'USD',
|
||||
fee: null,
|
||||
txHash: null,
|
||||
phone: null,
|
||||
error: null,
|
||||
created: '2020-12-04T16:28:11.016Z',
|
||||
send: true,
|
||||
sendConfirmed: false,
|
||||
timedout: false,
|
||||
sendTime: null,
|
||||
errorCode: null,
|
||||
operatorCompleted: false,
|
||||
sendPending: true,
|
||||
cashInFee: new BN(2),
|
||||
minimumTx: 5,
|
||||
customerId: '47ac1184-8102-11e7-9079-8f13a7117867',
|
||||
txVersion: 6,
|
||||
termsAccepted: false,
|
||||
commissionPercentage: new BN(0.11),
|
||||
rawTickerPrice: new BN(18937.4),
|
||||
isPaperWallet: false,
|
||||
direction: 'cashIn',
|
||||
}
|
||||
|
||||
const notifSettings = {
|
||||
email_active: false,
|
||||
sms_active: true,
|
||||
email_errors: false,
|
||||
sms_errors: true,
|
||||
sms_transactions: true,
|
||||
highValueTransaction: Infinity, // this will make highValueTx always false
|
||||
sms: {
|
||||
active: true,
|
||||
errors: true,
|
||||
transactions: false, // force early return
|
||||
},
|
||||
email: {
|
||||
active: false,
|
||||
errors: false,
|
||||
transactions: false, // force early return
|
||||
},
|
||||
}
|
||||
|
||||
test('Exits checkNotifications with Promise.resolve() if SMS and Email are disabled', async () => {
|
||||
expect.assertions(1)
|
||||
await expect(
|
||||
notifier.checkNotification({
|
||||
getNotificationConfig: () => ({
|
||||
sms: { active: false, errors: false },
|
||||
email: { active: false, errors: false },
|
||||
}),
|
||||
}),
|
||||
).resolves.toBe(undefined)
|
||||
})
|
||||
|
||||
test('Exits checkNotifications with Promise.resolve() if SMS and Email are disabled even if errors or balance are defined to something', async () => {
|
||||
expect.assertions(1)
|
||||
await expect(
|
||||
notifier.checkNotification({
|
||||
getNotificationConfig: () => ({
|
||||
sms: { active: false, errors: true, balance: true },
|
||||
email: { active: false, errors: true, balance: true },
|
||||
}),
|
||||
}),
|
||||
).resolves.toBe(undefined)
|
||||
})
|
||||
|
||||
test("Check Pings should return code PING for devices that haven't been pinged recently", () => {
|
||||
expect(
|
||||
notifier.checkPings([
|
||||
{
|
||||
deviceId:
|
||||
'7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4',
|
||||
lastPing: '2020-11-16T13:11:03.169Z',
|
||||
name: 'Abc123',
|
||||
},
|
||||
]),
|
||||
).toMatchObject({
|
||||
'7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4': [
|
||||
{ code: 'PING', machineName: 'Abc123' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('Checkpings returns empty array as the value for the id prop, if the lastPing is more recent than 60 seconds', () => {
|
||||
expect(
|
||||
notifier.checkPings([
|
||||
{
|
||||
deviceId:
|
||||
'7a531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4',
|
||||
lastPing: new Date(),
|
||||
name: 'Abc123',
|
||||
},
|
||||
]),
|
||||
).toMatchObject({
|
||||
'7a531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4': [],
|
||||
})
|
||||
})
|
||||
|
||||
test('Check notification resolves to undefined if shouldNotAlert is called and is true', async () => {
|
||||
const mockShouldNotAlert = jest.spyOn(utils, 'shouldNotAlert')
|
||||
mockShouldNotAlert.mockReturnValue(true)
|
||||
|
||||
const result = await notifier.checkNotification(plugins)
|
||||
expect(mockShouldNotAlert).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(undefined)
|
||||
})
|
||||
|
||||
test('Sendmessage is called if shouldNotAlert is called and is false', async () => {
|
||||
const mockShouldNotAlert = jest.spyOn(utils, 'shouldNotAlert')
|
||||
const mockSendMessage = jest.spyOn(plugins, 'sendMessage')
|
||||
|
||||
mockShouldNotAlert.mockReturnValue(false)
|
||||
const result = await notifier.checkNotification(plugins)
|
||||
|
||||
expect(mockShouldNotAlert).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendMessage).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(undefined)
|
||||
})
|
||||
|
||||
test('If no alert fingerprint and inAlert is true, exits on call to sendNoAlerts', async () => {
|
||||
// mock utils.buildAlertFingerprint to return null
|
||||
// mock utils.getAlertFingerprint to be true which will make inAlert true
|
||||
|
||||
const buildFp = jest.spyOn(utils, 'buildAlertFingerprint')
|
||||
const mockGetFp = jest.spyOn(utils, 'getAlertFingerprint')
|
||||
const mockSendNoAlerts = jest.spyOn(utils, 'sendNoAlerts')
|
||||
|
||||
buildFp.mockReturnValue(null)
|
||||
mockGetFp.mockReturnValue(true)
|
||||
await notifier.checkNotification(plugins)
|
||||
|
||||
expect(mockGetFp).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendNoAlerts).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// vvv tests for checkstuckscreen...
|
||||
test('checkStuckScreen returns [] when no events are found', () => {
|
||||
expect(notifier.checkStuckScreen([], 'Abc123')).toEqual([])
|
||||
})
|
||||
|
||||
test('checkStuckScreen returns [] if most recent event is idle', () => {
|
||||
// device_time is what matters for the sorting of the events by recency
|
||||
expect(
|
||||
notifier.checkStuckScreen([
|
||||
{
|
||||
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||
device_id:
|
||||
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||
event_type: 'stateChange',
|
||||
note: '{"state":"chooseCoin","isIdle":false}',
|
||||
created: '2020-11-23T19:30:29.209Z',
|
||||
device_time: '1999-11-23T19:30:29.177Z',
|
||||
age: 157352628.123,
|
||||
},
|
||||
{
|
||||
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||
device_id:
|
||||
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||
event_type: 'stateChange',
|
||||
note: '{"state":"chooseCoin","isIdle":true}',
|
||||
created: '2020-11-23T19:30:29.209Z',
|
||||
device_time: '2020-11-23T19:30:29.177Z',
|
||||
age: 157352628.123,
|
||||
},
|
||||
]),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('checkStuckScreen returns object array of length 1 with prop code: "STALE" if age > STALE_STATE', () => {
|
||||
// there is an age 0 and an isIdle true in the first object but it will be below the second one in the sorting order and thus ignored
|
||||
const result = notifier.checkStuckScreen([
|
||||
{
|
||||
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||
device_id:
|
||||
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||
event_type: 'stateChange',
|
||||
note: '{"state":"chooseCoin","isIdle":true}',
|
||||
created: '2020-11-23T19:30:29.209Z',
|
||||
device_time: '1999-11-23T19:30:29.177Z',
|
||||
age: 0,
|
||||
},
|
||||
{
|
||||
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||
device_id:
|
||||
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||
event_type: 'stateChange',
|
||||
note: '{"state":"chooseCoin","isIdle":false}',
|
||||
created: '2020-11-23T19:30:29.209Z',
|
||||
device_time: '2020-11-23T19:30:29.177Z',
|
||||
age: 157352628.123,
|
||||
},
|
||||
])
|
||||
expect(result[0]).toMatchObject({ code: 'STALE' })
|
||||
})
|
||||
|
||||
test('checkStuckScreen returns empty array if age < STALE_STATE', () => {
|
||||
const STALE_STATE = require('../codes').STALE_STATE
|
||||
const result1 = notifier.checkStuckScreen([
|
||||
{
|
||||
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||
device_id:
|
||||
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||
event_type: 'stateChange',
|
||||
note: '{"state":"chooseCoin","isIdle":false}',
|
||||
created: '2020-11-23T19:30:29.209Z',
|
||||
device_time: '2020-11-23T19:30:29.177Z',
|
||||
age: 0,
|
||||
},
|
||||
])
|
||||
const result2 = notifier.checkStuckScreen([
|
||||
{
|
||||
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||
device_id:
|
||||
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||
event_type: 'stateChange',
|
||||
note: '{"state":"chooseCoin","isIdle":false}',
|
||||
created: '2020-11-23T19:30:29.209Z',
|
||||
device_time: '2020-11-23T19:30:29.177Z',
|
||||
age: STALE_STATE,
|
||||
},
|
||||
])
|
||||
expect(result1).toEqual([])
|
||||
expect(result2).toEqual([])
|
||||
})
|
||||
|
||||
test('calls sendRedemptionMessage if !zeroConf and rec.isRedemption', async () => {
|
||||
const configManager = require('../../new-config-manager')
|
||||
const settingsLoader = require('../../new-settings-loader')
|
||||
|
||||
const loadLatest = jest.spyOn(settingsLoader, 'loadLatest')
|
||||
const getGlobalNotifications = jest.spyOn(
|
||||
configManager,
|
||||
'getGlobalNotifications',
|
||||
)
|
||||
const getWalletSettings = jest.spyOn(configManager, 'getWalletSettings')
|
||||
|
||||
// sendRedemptionMessage will cause this func to be called
|
||||
jest.spyOn(smsFuncs, 'sendMessage').mockImplementation((_, rec) => rec)
|
||||
|
||||
getWalletSettings.mockReturnValue({ zeroConfLimit: -Infinity })
|
||||
loadLatest.mockReturnValue(Promise.resolve({}))
|
||||
getGlobalNotifications.mockReturnValue({
|
||||
...notifSettings,
|
||||
sms: { active: true, errors: true, transactions: true },
|
||||
notificationCenter: { active: true },
|
||||
})
|
||||
|
||||
const response = await notifier.transactionNotify(tx, { isRedemption: true })
|
||||
|
||||
// this type of response implies sendRedemptionMessage was called
|
||||
expect(response[0]).toMatchObject({
|
||||
sms: {
|
||||
body: "Here's an update on transaction bec8d452-9ea2-4846-841b-55a9df8bbd00 - It was just dispensed successfully",
|
||||
},
|
||||
email: {
|
||||
subject:
|
||||
"Here's an update on transaction bec8d452-9ea2-4846-841b-55a9df8bbd00",
|
||||
body: 'It was just dispensed successfully',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('calls sendTransactionMessage if !zeroConf and !rec.isRedemption', async () => {
|
||||
const configManager = require('../../new-config-manager')
|
||||
const settingsLoader = require('../../new-settings-loader')
|
||||
const queries = require('../queries')
|
||||
const loadLatest = jest.spyOn(settingsLoader, 'loadLatest')
|
||||
const getGlobalNotifications = jest.spyOn(
|
||||
configManager,
|
||||
'getGlobalNotifications',
|
||||
)
|
||||
const getWalletSettings = jest.spyOn(configManager, 'getWalletSettings')
|
||||
const getMachineName = jest.spyOn(queries, 'getMachineName')
|
||||
const buildTransactionMessage = jest.spyOn(utils, 'buildTransactionMessage')
|
||||
|
||||
// sendMessage on emailFuncs isn't called because it is disabled in getGlobalNotifications.mockReturnValue
|
||||
jest
|
||||
.spyOn(smsFuncs, 'sendMessage')
|
||||
.mockImplementation((_, rec) => ({ prop: rec }))
|
||||
buildTransactionMessage.mockImplementation(() => ['mock message', false])
|
||||
|
||||
getMachineName.mockResolvedValue('mockMachineName')
|
||||
getWalletSettings.mockReturnValue({ zeroConfLimit: -Infinity })
|
||||
loadLatest.mockReturnValue(Promise.resolve({}))
|
||||
getGlobalNotifications.mockReturnValue({
|
||||
...notifSettings,
|
||||
sms: { active: true, errors: true, transactions: true },
|
||||
notificationCenter: { active: true },
|
||||
})
|
||||
|
||||
const response = await notifier.transactionNotify(tx, { isRedemption: false })
|
||||
|
||||
// If the return object is this, it means the code went through all the functions expected to go through if
|
||||
// getMachineName, buildTransactionMessage and sendTransactionMessage were called, in this order
|
||||
expect(response).toEqual([{ prop: 'mock message' }])
|
||||
})
|
||||
22
packages/server/lib/notifier/test/sms.test.js
Normal file
22
packages/server/lib/notifier/test/sms.test.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
const sms = require('../sms')
|
||||
|
||||
const alertRec = {
|
||||
devices: {
|
||||
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: {
|
||||
balanceAlerts: [],
|
||||
deviceAlerts: [
|
||||
{ code: 'PING', age: 602784301.446, machineName: 'Abc123' },
|
||||
],
|
||||
},
|
||||
},
|
||||
deviceNames: {
|
||||
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: 'Abc123',
|
||||
},
|
||||
general: [],
|
||||
}
|
||||
|
||||
test('Print SMS alerts', () => {
|
||||
expect(sms.printSmsAlerts(alertRec, { active: true, errors: true })).toBe(
|
||||
'[Lamassu] Errors reported: Machine Down (Abc123)',
|
||||
)
|
||||
})
|
||||
104
packages/server/lib/notifier/test/utils.test.js
Normal file
104
packages/server/lib/notifier/test/utils.test.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
const utils = require('../utils')
|
||||
|
||||
const plugins = {
|
||||
sendMessage: rec => {
|
||||
return rec
|
||||
},
|
||||
}
|
||||
|
||||
const alertRec = {
|
||||
devices: {
|
||||
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: {
|
||||
balanceAlerts: [],
|
||||
deviceAlerts: [
|
||||
{ code: 'PING', age: 1605532263169, machineName: 'Abc123' },
|
||||
],
|
||||
},
|
||||
},
|
||||
deviceNames: {
|
||||
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: 'Abc123',
|
||||
},
|
||||
general: [],
|
||||
}
|
||||
|
||||
const notifications = {
|
||||
sms: { active: true, errors: true },
|
||||
email: { active: false, errors: false },
|
||||
}
|
||||
|
||||
describe('buildAlertFingerprint', () => {
|
||||
test('Build alert fingerprint returns null if no sms or email alerts', () => {
|
||||
expect(
|
||||
utils.buildAlertFingerprint(
|
||||
{
|
||||
devices: {},
|
||||
deviceNames: {},
|
||||
general: [],
|
||||
},
|
||||
notifications,
|
||||
),
|
||||
).toBe(null)
|
||||
})
|
||||
|
||||
test('Build alert fingerprint returns null if sms and email are disabled', () => {
|
||||
expect(
|
||||
utils.buildAlertFingerprint(alertRec, {
|
||||
sms: { active: false, errors: true },
|
||||
email: { active: false, errors: false },
|
||||
}),
|
||||
).toBe(null)
|
||||
})
|
||||
|
||||
test('Build alert fingerprint returns hash if email or [sms] are enabled and there are alerts in alertrec', () => {
|
||||
expect(
|
||||
typeof utils.buildAlertFingerprint(alertRec, {
|
||||
sms: { active: true, errors: true },
|
||||
email: { active: false, errors: false },
|
||||
}),
|
||||
).toBe('string')
|
||||
})
|
||||
|
||||
test('Build alert fingerprint returns hash if [email] or sms are enabled and there are alerts in alertrec', () => {
|
||||
expect(
|
||||
typeof utils.buildAlertFingerprint(alertRec, {
|
||||
sms: { active: false, errors: false },
|
||||
email: { active: true, errors: true },
|
||||
}),
|
||||
).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendNoAlerts', () => {
|
||||
test('Send no alerts returns empty object with sms and email disabled', () => {
|
||||
expect(utils.sendNoAlerts(plugins, false, false)).toEqual({})
|
||||
})
|
||||
|
||||
test('Send no alerts returns object with sms prop with sms only enabled', () => {
|
||||
expect(utils.sendNoAlerts(plugins, true, false)).toEqual({
|
||||
sms: {
|
||||
body: '[Lamassu] All clear',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('Send no alerts returns object with sms and email prop with both enabled', () => {
|
||||
expect(utils.sendNoAlerts(plugins, true, true)).toEqual({
|
||||
email: {
|
||||
body: 'No errors are reported for your machines.',
|
||||
subject: '[Lamassu] All clear',
|
||||
},
|
||||
sms: {
|
||||
body: '[Lamassu] All clear',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('Send no alerts returns object with email prop if only email is enabled', () => {
|
||||
expect(utils.sendNoAlerts(plugins, false, true)).toEqual({
|
||||
email: {
|
||||
body: 'No errors are reported for your machines.',
|
||||
subject: '[Lamassu] All clear',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
223
packages/server/lib/notifier/utils.js
Normal file
223
packages/server/lib/notifier/utils.js
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
const _ = require('lodash/fp')
|
||||
const crypto = require('crypto')
|
||||
const { utils: coinUtils } = require('@lamassu/coins')
|
||||
const prettyMs = require('pretty-ms')
|
||||
|
||||
const {
|
||||
CODES_DISPLAY,
|
||||
NETWORK_DOWN_TIME,
|
||||
PING,
|
||||
ALERT_SEND_INTERVAL,
|
||||
} = require('./codes')
|
||||
|
||||
const DETAIL_TEMPLATE = {
|
||||
deviceId: '',
|
||||
cryptoCode: '',
|
||||
code: '',
|
||||
cassette: '',
|
||||
age: '',
|
||||
customerId: '',
|
||||
cryptoAddress: '',
|
||||
direction: '',
|
||||
fiat: '',
|
||||
fiatCode: '',
|
||||
}
|
||||
|
||||
function parseEventNote(event) {
|
||||
return _.update(
|
||||
'note',
|
||||
note => (typeof note === 'string' ? JSON.parse(note) : note),
|
||||
event,
|
||||
)
|
||||
}
|
||||
|
||||
function checkPing(device) {
|
||||
const age = Date.now() - new Date(device.lastPing).getTime()
|
||||
if (age > NETWORK_DOWN_TIME)
|
||||
return [{ code: PING, age, machineName: device.name }]
|
||||
return []
|
||||
}
|
||||
|
||||
const getDeviceTime = _.flow(_.get('device_time'), Date.parse)
|
||||
|
||||
const isActive = it => it.active && (it.balance || it.errors)
|
||||
|
||||
const codeDisplay = code => CODES_DISPLAY[code]
|
||||
|
||||
const alertFingerprint = {
|
||||
fingerprint: null,
|
||||
lastAlertTime: null,
|
||||
}
|
||||
|
||||
const getAlertFingerprint = () => alertFingerprint.fingerprint
|
||||
|
||||
const getLastAlertTime = () => alertFingerprint.lastAlertTime
|
||||
|
||||
const setAlertFingerprint = (fp, time = Date.now()) => {
|
||||
alertFingerprint.fingerprint = fp
|
||||
alertFingerprint.lastAlertTime = time
|
||||
}
|
||||
|
||||
const shouldNotAlert = currentAlertFingerprint => {
|
||||
return (
|
||||
currentAlertFingerprint === getAlertFingerprint() &&
|
||||
getLastAlertTime() - Date.now() < ALERT_SEND_INTERVAL
|
||||
)
|
||||
}
|
||||
|
||||
function buildAlertFingerprint(alertRec, notifications) {
|
||||
const sms = getAlertTypes(alertRec, notifications.sms)
|
||||
const email = getAlertTypes(alertRec, notifications.email)
|
||||
if (sms.length === 0 && email.length === 0) return null
|
||||
|
||||
const smsTypes = _.map(codeDisplay, _.uniq(_.map('code', sms))).sort()
|
||||
const emailTypes = _.map(codeDisplay, _.uniq(_.map('code', email))).sort()
|
||||
|
||||
const subject = _.concat(smsTypes, emailTypes).join(', ')
|
||||
return crypto.createHash('sha256').update(subject).digest('hex')
|
||||
}
|
||||
|
||||
function sendNoAlerts(plugins, smsEnabled, emailEnabled) {
|
||||
const subject = '[Lamassu] All clear'
|
||||
|
||||
let rec = {}
|
||||
if (smsEnabled) {
|
||||
rec = _.set(['sms', 'body'])(subject)(rec)
|
||||
}
|
||||
|
||||
if (emailEnabled) {
|
||||
rec = _.set(['email', 'subject'])(subject)(rec)
|
||||
rec = _.set(['email', 'body'])('No errors are reported for your machines.')(
|
||||
rec,
|
||||
)
|
||||
}
|
||||
|
||||
return plugins.sendMessage(rec)
|
||||
}
|
||||
|
||||
const buildTransactionMessage = (
|
||||
tx,
|
||||
rec,
|
||||
highValueTx,
|
||||
machineName,
|
||||
customer,
|
||||
) => {
|
||||
const isCashOut = tx.direction === 'cashOut'
|
||||
const direction = isCashOut ? 'Cash Out' : 'Cash In'
|
||||
const crypto = `${coinUtils.toUnit(tx.cryptoAtoms, tx.cryptoCode)} ${
|
||||
tx.cryptoCode
|
||||
}`
|
||||
const fiat = `${tx.fiat} ${tx.fiatCode}`
|
||||
const customerName = customer.name || customer.id
|
||||
const phone = customer.phone ? `- Phone: ${customer.phone}` : ''
|
||||
|
||||
let status = null
|
||||
if (rec.error) {
|
||||
status = `Error - ${rec.error}`
|
||||
} else {
|
||||
status = !isCashOut
|
||||
? 'Successful'
|
||||
: !rec.isRedemption
|
||||
? 'Successful & awaiting redemption'
|
||||
: 'Successful & dispensed'
|
||||
}
|
||||
|
||||
const body = `
|
||||
- Transaction ID: ${tx.id}
|
||||
- Status: ${status}
|
||||
- Machine name: ${machineName}
|
||||
- ${direction}
|
||||
- ${fiat}
|
||||
- ${crypto}
|
||||
- Customer: ${customerName}
|
||||
${phone}
|
||||
`
|
||||
const smsSubject = `A ${highValueTx ? 'high value ' : ''}${direction.toLowerCase()} transaction just happened at ${machineName} for ${fiat}`
|
||||
const emailSubject = `A ${highValueTx ? 'high value ' : ''}transaction just happened`
|
||||
|
||||
return [
|
||||
{
|
||||
sms: {
|
||||
body: `${smsSubject} – ${status}`,
|
||||
},
|
||||
email: {
|
||||
emailSubject,
|
||||
body,
|
||||
},
|
||||
webhook: {
|
||||
topic: `New transaction`,
|
||||
content: body,
|
||||
},
|
||||
},
|
||||
highValueTx,
|
||||
]
|
||||
}
|
||||
|
||||
function formatCurrency(num = 0, code) {
|
||||
const formattedNumber = Number(num).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
return `${formattedNumber} ${code}`
|
||||
}
|
||||
|
||||
function formatAge(age, settings) {
|
||||
return prettyMs(age, settings)
|
||||
}
|
||||
|
||||
function buildDetail(obj) {
|
||||
// obj validation
|
||||
const objKeys = _.keys(obj)
|
||||
const detailKeys = _.keys(DETAIL_TEMPLATE)
|
||||
if (_.difference(objKeys, detailKeys).length > 0) {
|
||||
return Promise.reject(
|
||||
new Error('Error when building detail object: invalid properties'),
|
||||
)
|
||||
}
|
||||
return { ...DETAIL_TEMPLATE, ...obj }
|
||||
}
|
||||
|
||||
function deviceAlerts(config, alertRec, device) {
|
||||
let alerts = []
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||
}
|
||||
|
||||
if (config.errors) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
function getAlertTypes(alertRec, config) {
|
||||
let alerts = []
|
||||
if (!isActive(config)) return alerts
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.forEach(device => {
|
||||
alerts = _.concat(alerts, deviceAlerts(config, alertRec, device))
|
||||
}, _.keys(alertRec.devices))
|
||||
return alerts
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
codeDisplay,
|
||||
parseEventNote,
|
||||
getDeviceTime,
|
||||
checkPing,
|
||||
isActive,
|
||||
getAlertFingerprint,
|
||||
getLastAlertTime,
|
||||
setAlertFingerprint,
|
||||
shouldNotAlert,
|
||||
buildAlertFingerprint,
|
||||
sendNoAlerts,
|
||||
buildTransactionMessage,
|
||||
formatCurrency,
|
||||
formatAge,
|
||||
buildDetail,
|
||||
deviceAlerts,
|
||||
}
|
||||
21
packages/server/lib/notifier/webhook.js
Normal file
21
packages/server/lib/notifier/webhook.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
const axios = require('axios')
|
||||
const _ = require('lodash/fp')
|
||||
const uuid = require('uuid')
|
||||
|
||||
const WEBHOOK_URL = process.env.WEBHOOK_URL
|
||||
|
||||
const sendMessage = (settings, rec) => {
|
||||
if (_.isEmpty(WEBHOOK_URL)) return Promise.resolve()
|
||||
|
||||
const body = _.merge(rec.webhook, { id: uuid.v4() })
|
||||
|
||||
return axios({
|
||||
method: 'POST',
|
||||
url: WEBHOOK_URL,
|
||||
data: body,
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendMessage,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue