Chore: make notification center UI
Chore: fiatBalancesNotify refactor Chore: removed now-unused code in some files Feat: change column "detail" in database to use jsonb Chore: add notification center background and button Chore: notifications screen scaffolding Fix: change position of notification UI Feat: join backend and frontend Feat: notification icons and machine names Feat: add clear all button, stripe overlay on invalid notification Fix: rework notification styles Feat: use popper to render notifications Feat: make notification center UI Fix: fix css on notification center Fix: fix invalidateNotification Chore: apply PR requested changes Fix: PR fixes Fix: make toggleable body/root styles be handled by react Chore: delete old notifier file Fix: undo variable name changes for cryptobalance notifs
This commit is contained in:
parent
2a9e8dadba
commit
c457faab40
37 changed files with 1337 additions and 1332 deletions
|
|
@ -1,4 +1,5 @@
|
|||
const db = require('./db')
|
||||
const notifier = require('./notifier')
|
||||
|
||||
// Get all blacklist rows from the DB "blacklist" table that were manually inserted by the operator
|
||||
const getBlacklist = () => {
|
||||
|
|
@ -13,11 +14,9 @@ const getBlacklist = () => {
|
|||
|
||||
// Delete row from blacklist table by crypto code and address
|
||||
const deleteFromBlacklist = (cryptoCode, address) => {
|
||||
const sql = `DELETE FROM blacklist WHERE crypto_code = $1 AND address = $2;
|
||||
UPDATE notifications SET valid = 'f', read = 't' WHERE valid = 't' AND detail IN ($3^)`
|
||||
|
||||
const detail = `'${cryptoCode}_BLOCKED_${address}', '${cryptoCode}_REUSED_${address}'`
|
||||
return db.none(sql, [cryptoCode, address, detail])
|
||||
const sql = `DELETE FROM blacklist WHERE crypto_code = $1 AND address = $2`
|
||||
notifier.clearBlacklistNotification(cryptoCode, address)
|
||||
return db.none(sql, [cryptoCode, address])
|
||||
}
|
||||
|
||||
const insertIntoBlacklist = (cryptoCode, address) => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const plugins = require('../plugins')
|
|||
const logger = require('../logger')
|
||||
const settingsLoader = require('../new-settings-loader')
|
||||
const configManager = require('../new-config-manager')
|
||||
const notifier = require("../notifier/index")
|
||||
const notifier = require('../notifier')
|
||||
|
||||
const cashInAtomic = require('./cash-in-atomic')
|
||||
const cashInLow = require('./cash-in-low')
|
||||
|
|
@ -16,7 +16,7 @@ const cashInLow = require('./cash-in-low')
|
|||
const PENDING_INTERVAL = '60 minutes'
|
||||
const MAX_PENDING = 10
|
||||
|
||||
module.exports = {post, monitorPending, cancel, PENDING_INTERVAL}
|
||||
module.exports = { post, monitorPending, cancel, PENDING_INTERVAL }
|
||||
|
||||
function post (machineTx, pi) {
|
||||
return db.tx(cashInAtomic.atomic(machineTx, pi))
|
||||
|
|
@ -29,10 +29,10 @@ function post (machineTx, pi) {
|
|||
.then(([{ config }, blacklistItems]) => {
|
||||
const rejectAddressReuseActive = configManager.getCompliance(config).rejectAddressReuse
|
||||
|
||||
if (_.some(it => it.created_by_operator === true)(blacklistItems)) {
|
||||
if (_.some(it => it.created_by_operator)(blacklistItems)) {
|
||||
blacklisted = true
|
||||
notifier.blacklistNotify(r.tx, false)
|
||||
} else if (_.some(it => it.created_by_operator === false)(blacklistItems) && rejectAddressReuseActive) {
|
||||
} else if (_.some(it => !it.created_by_operator)(blacklistItems) && rejectAddressReuseActive) {
|
||||
notifier.blacklistNotify(r.tx, true)
|
||||
addressReuse = true
|
||||
}
|
||||
|
|
@ -65,7 +65,7 @@ function logAction (rec, tx) {
|
|||
}
|
||||
|
||||
function logActionById (action, _rec, txId) {
|
||||
const rec = _.assign(_rec, {action, tx_id: txId})
|
||||
const rec = _.assign(_rec, { action, tx_id: txId })
|
||||
const sql = pgp.helpers.insert(rec, null, 'cash_in_actions')
|
||||
|
||||
return db.none(sql)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ const helper = require('./cash-out-helper')
|
|||
const cashOutActions = require('./cash-out-actions')
|
||||
const cashOutLow = require('./cash-out-low')
|
||||
|
||||
const notifier = require("../notifier/index")
|
||||
|
||||
const toObj = helper.toObj
|
||||
|
||||
module.exports = {atomic}
|
||||
|
|
@ -124,10 +122,8 @@ function updateCassettes (t, tx) {
|
|||
tx.deviceId
|
||||
]
|
||||
|
||||
return t.one(sql, values).then(r => {
|
||||
notifier.cashCassettesNotify(r, tx.deviceId)
|
||||
return socket.emit(_.assign(r, {op: 'cassetteUpdate', deviceId: tx.deviceId}))
|
||||
})
|
||||
return t.one(sql, values)
|
||||
.then(r => socket.emit(_.assign(r, {op: 'cassetteUpdate', deviceId: tx.deviceId})))
|
||||
}
|
||||
|
||||
function wasJustAuthorized (oldTx, newTx, isZeroConf) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ const complianceOverrides = require('./compliance_overrides')
|
|||
const users = require('./users')
|
||||
const options = require('./options')
|
||||
const writeFile = util.promisify(fs.writeFile)
|
||||
|
||||
const notifierQueries = require('./notifier/queries')
|
||||
const notifierUtils = require('./notifier/utils')
|
||||
const NUM_RESULTS = 1000
|
||||
const idPhotoCardBasedir = _.get('idPhotoCardDir', options)
|
||||
const frontCameraBaseDir = _.get('frontCameraDir', options)
|
||||
|
|
@ -115,7 +116,7 @@ async function updateCustomer (id, data, userToken) {
|
|||
|
||||
const sql = Pgp.helpers.update(updateData, _.keys(updateData), 'customers') +
|
||||
' where id=$1'
|
||||
invalidateCustomerNotifications(id, formattedData)
|
||||
invalidateCustomerNotifications(id, formattedData).catch(console.error)
|
||||
|
||||
await db.none(sql, [id])
|
||||
|
||||
|
|
@ -123,12 +124,10 @@ async function updateCustomer (id, data, userToken) {
|
|||
}
|
||||
|
||||
const invalidateCustomerNotifications = (id, data) => {
|
||||
let detail = '';
|
||||
if(data.authorized_override === 'verified') {
|
||||
detail = `BLOCKED_${id}`
|
||||
}
|
||||
const sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE valid = 't' AND detail = $1`
|
||||
return db.none(sql, [detail])
|
||||
if (data.authorized_override !== 'verified') return Promise.resolve()
|
||||
|
||||
const detailB = notifierUtils.buildDetail({ code: 'BLOCKED', customerId: id })
|
||||
return notifierQueries.invalidateNotification(detailB, 'compliance').catch(console.error)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -409,7 +408,7 @@ function populateOverrideUsernames (customer) {
|
|||
return users.getByIds(queryTokens)
|
||||
.then(usersList => {
|
||||
return _.map(userField => {
|
||||
const user = _.find({token: userField.token}, usersList)
|
||||
const user = _.find({ token: userField.token }, usersList)
|
||||
return {
|
||||
[userField.field]: user ? user.name : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
const _ = require('lodash/fp')
|
||||
const axios = require('axios')
|
||||
|
||||
const logger = require('./logger')
|
||||
const db = require('./db')
|
||||
const pairing = require('./pairing')
|
||||
const notifier = require('./notifier')
|
||||
const dbm = require('./postgresql_interface')
|
||||
const configManager = require('./new-config-manager')
|
||||
const settingsLoader = require('./new-settings-loader')
|
||||
|
||||
module.exports = {getMachineName, getMachines, getMachine, getMachineNames, setMachine}
|
||||
const notifierUtils = require('./notifier/utils')
|
||||
const notifierQueries = require('./notifier/queries')
|
||||
|
||||
function getMachines () {
|
||||
return db.any('select * from devices where display=TRUE order by created')
|
||||
return db.any('SELECT * FROM devices WHERE display=TRUE ORDER BY created')
|
||||
.then(rr => rr.map(r => ({
|
||||
deviceId: r.device_id,
|
||||
cashbox: r.cashbox,
|
||||
|
|
@ -36,9 +35,9 @@ function getConfig (defaultConfig) {
|
|||
}
|
||||
|
||||
function getMachineNames (config) {
|
||||
const fullyFunctionalStatus = {label: 'Fully functional', type: 'success'}
|
||||
const unresponsiveStatus = {label: 'Unresponsive', type: 'error'}
|
||||
const stuckStatus = {label: 'Stuck', type: 'error'}
|
||||
const fullyFunctionalStatus = { label: 'Fully functional', type: 'success' }
|
||||
const unresponsiveStatus = { label: 'Unresponsive', type: 'error' }
|
||||
const stuckStatus = { label: 'Stuck', type: 'error' }
|
||||
|
||||
return Promise.all([getMachines(), getConfig(config)])
|
||||
.then(([machines, config]) => Promise.all(
|
||||
|
|
@ -65,7 +64,7 @@ function getMachineNames (config) {
|
|||
)
|
||||
]
|
||||
|
||||
return _.assign(r, {cashOut, statuses})
|
||||
return _.assign(r, { cashOut, statuses })
|
||||
}
|
||||
|
||||
return _.map(addName, machines)
|
||||
|
|
@ -83,31 +82,29 @@ function getMachineNames (config) {
|
|||
* @returns {string} machine name
|
||||
*/
|
||||
function getMachineName (machineId) {
|
||||
const sql = 'select * from devices where device_id=$1'
|
||||
const sql = 'SELECT * FROM devices WHERE device_id=$1'
|
||||
return db.oneOrNone(sql, [machineId])
|
||||
.then(it => it.name)
|
||||
}
|
||||
|
||||
function getMachine (machineId) {
|
||||
const sql = 'select * from devices where device_id=$1'
|
||||
const sql = 'SELECT * FROM devices WHERE device_id=$1'
|
||||
return db.oneOrNone(sql, [machineId]).then(res => _.mapKeys(_.camelCase)(res))
|
||||
}
|
||||
|
||||
function renameMachine (rec) {
|
||||
const sql = 'update devices set name=$1 where device_id=$2'
|
||||
const sql = 'UPDATE devices SET name=$1 WHERE device_id=$2'
|
||||
return db.none(sql, [rec.newName, rec.deviceId])
|
||||
}
|
||||
|
||||
function resetCashOutBills (rec) {
|
||||
const sql = `
|
||||
update devices set cassette1=$1, cassette2=$2 where device_id=$3;
|
||||
update notifications set read = 't', valid = 'f' where read = 'f' AND valid = 't' AND device_id = $3 AND type = 'fiatBalance';
|
||||
`
|
||||
return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.deviceId])
|
||||
const detailB = notifierUtils.buildDetail({ deviceId: rec.deviceId })
|
||||
const sql = `UPDATE devices SET cassette1=$1, cassette2=$2 WHERE device_id=$3;`
|
||||
return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.deviceId]).then(() => notifierQueries.invalidateNotification(detailB, 'fiatBalance'))
|
||||
}
|
||||
|
||||
function emptyCashInBills (rec) {
|
||||
const sql = 'update devices set cashbox=0 where device_id=$1'
|
||||
const sql = 'UPDATE devices SET cashbox=0 WHERE device_id=$1'
|
||||
return db.none(sql, [rec.deviceId])
|
||||
}
|
||||
|
||||
|
|
@ -145,3 +142,5 @@ function setMachine (rec) {
|
|||
default: throw new Error('No such action: ' + rec.action)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getMachineName, getMachines, getMachine, getMachineNames, setMachine }
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ const logs = require('../../logs')
|
|||
const settingsLoader = require('../../new-settings-loader')
|
||||
// const tokenManager = require('../../token-manager')
|
||||
const blacklist = require('../../blacklist')
|
||||
const machineEventsByIdBatch = require("../../postgresql_interface").machineEventsByIdBatch
|
||||
const machineEventsByIdBatch = require('../../postgresql_interface').machineEventsByIdBatch
|
||||
const promoCodeManager = require('../../promo-codes')
|
||||
const notifierQueries = require('../../notifier/queries')
|
||||
|
||||
const serverVersion = require('../../../package.json').version
|
||||
const transactions = require('../transactions')
|
||||
|
|
@ -247,6 +248,17 @@ const typeDefs = gql`
|
|||
rate: Float
|
||||
}
|
||||
|
||||
type Notification {
|
||||
id: ID!
|
||||
deviceName: String
|
||||
type: String
|
||||
detail: JSON
|
||||
message: String
|
||||
created: Date
|
||||
read: Boolean
|
||||
valid: Boolean
|
||||
}
|
||||
|
||||
type Query {
|
||||
countries: [Country]
|
||||
currencies: [Currency]
|
||||
|
|
@ -279,6 +291,8 @@ const typeDefs = gql`
|
|||
promoCodes: [PromoCode]
|
||||
cryptoRates: JSONObject
|
||||
fiatRates: [Rate]
|
||||
notifications: [Notification]
|
||||
hasUnreadNotifications: Boolean
|
||||
}
|
||||
|
||||
type SupportLogsResponse {
|
||||
|
|
@ -312,6 +326,8 @@ const typeDefs = gql`
|
|||
insertBlacklistRow(cryptoCode: String!, address: String!): Blacklist
|
||||
createPromoCode(code: String!, discount: Int!): PromoCode
|
||||
deletePromoCode(codeId: ID!): PromoCode
|
||||
clearNotification(id: ID!): Notification
|
||||
clearAllNotifications: Notification
|
||||
}
|
||||
`
|
||||
|
||||
|
|
@ -373,7 +389,9 @@ const resolvers = {
|
|||
}
|
||||
})
|
||||
}),
|
||||
fiatRates: () => forex.getFiatRates()
|
||||
fiatRates: () => forex.getFiatRates(),
|
||||
notifications: () => notifierQueries.getNotifications(),
|
||||
hasUnreadNotifications: () => notifierQueries.hasUnreadNotifications()
|
||||
},
|
||||
Mutation: {
|
||||
machineAction: (...[, { deviceId, action, cashbox, cassette1, cassette2, newName }]) => machineAction({ deviceId, action, cashbox, cassette1, cassette2, newName }),
|
||||
|
|
@ -397,7 +415,9 @@ const resolvers = {
|
|||
blacklist.insertIntoBlacklist(cryptoCode, address),
|
||||
// revokeToken: (...[, { token }]) => tokenManager.revokeToken(token)
|
||||
createPromoCode: (...[, { code, discount }]) => promoCodeManager.createPromoCode(code, discount),
|
||||
deletePromoCode: (...[, { codeId }]) => promoCodeManager.deletePromoCode(codeId)
|
||||
deletePromoCode: (...[, { codeId }]) => promoCodeManager.deletePromoCode(codeId),
|
||||
clearNotification: (...[, { id }]) => notifierQueries.markAsRead(id),
|
||||
clearAllNotifications: () => notifierQueries.markAllAsRead()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,18 +44,6 @@ const configSql = 'insert into user_config (type, data, valid, schema_version) v
|
|||
function saveConfig (config) {
|
||||
return loadLatestConfigOrNone()
|
||||
.then(currentConfig => {
|
||||
if(config.notifications_cryptoHighBalance || config.notifications_cryptoLowBalance) {
|
||||
clearCryptoBalanceNotifications(currentConfig, config, false)
|
||||
}
|
||||
if(config.notifications_cryptoBalanceOverrides) {
|
||||
clearCryptoBalanceNotifications(currentConfig.notifications_cryptoBalanceOverrides, config.notifications_cryptoBalanceOverrides, true)
|
||||
}
|
||||
if(config.notifications_fiatBalanceCassette1 || config.notifications_fiatBalanceCassette2) {
|
||||
clearCassetteNotifications(currentConfig, config, false)
|
||||
}
|
||||
if(config.notifications_fiatBalanceOverrides) {
|
||||
clearCassetteNotifications(currentConfig.notifications_fiatBalanceOverrides, config.notifications_fiatBalanceOverrides, true)
|
||||
}
|
||||
const newConfig = _.assign(currentConfig, config)
|
||||
return db.none(configSql, ['config', { config: newConfig }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
||||
})
|
||||
|
|
|
|||
351
lib/notifier.js
351
lib/notifier.js
|
|
@ -1,351 +0,0 @@
|
|||
const crypto = require('crypto')
|
||||
const _ = require('lodash/fp')
|
||||
const prettyMs = require('pretty-ms')
|
||||
const numeral = require('numeral')
|
||||
|
||||
const dbm = require('./postgresql_interface')
|
||||
const db = require('./db')
|
||||
const T = require('./time')
|
||||
const logger = require('./logger')
|
||||
|
||||
const STALE_STATE = 7 * T.minute
|
||||
const NETWORK_DOWN_TIME = 1 * T.minute
|
||||
const ALERT_SEND_INTERVAL = T.hour
|
||||
|
||||
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 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'
|
||||
}
|
||||
|
||||
let alertFingerprint
|
||||
let lastAlertTime
|
||||
|
||||
function codeDisplay (code) {
|
||||
return CODES_DISPLAY[code]
|
||||
}
|
||||
|
||||
function jsonParse (event) {
|
||||
return _.set('note', JSON.parse(event.note), event)
|
||||
}
|
||||
|
||||
function sameState (a, b) {
|
||||
return a.note.txId === b.note.txId && a.note.state === b.note.state
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function checkNotification (plugins) {
|
||||
const notifications = plugins.getNotificationConfig()
|
||||
const isActive = it => it.active && (it.balance || it.errors)
|
||||
const smsEnabled = isActive(notifications.sms)
|
||||
const emailEnabled = isActive(notifications.email)
|
||||
|
||||
if (!smsEnabled && !emailEnabled) return Promise.resolve()
|
||||
|
||||
return checkStatus(plugins)
|
||||
.then(alertRec => {
|
||||
const currentAlertFingerprint = buildAlertFingerprint(alertRec, notifications)
|
||||
if (!currentAlertFingerprint) {
|
||||
const inAlert = !!alertFingerprint
|
||||
alertFingerprint = null
|
||||
lastAlertTime = null
|
||||
if (inAlert) return sendNoAlerts(plugins, smsEnabled, emailEnabled)
|
||||
}
|
||||
|
||||
const alertChanged = currentAlertFingerprint === alertFingerprint &&
|
||||
lastAlertTime - Date.now() < ALERT_SEND_INTERVAL
|
||||
if (alertChanged) return
|
||||
|
||||
let rec = {}
|
||||
if (smsEnabled) {
|
||||
rec = _.set(['sms', 'body'])(printSmsAlerts(alertRec, notifications.sms))(rec)
|
||||
}
|
||||
|
||||
if (emailEnabled) {
|
||||
rec = _.set(['email', 'subject'])(alertSubject(alertRec, notifications.email))(rec)
|
||||
rec = _.set(['email', 'body'])(printEmailAlerts(alertRec, notifications.email))(rec)
|
||||
}
|
||||
|
||||
alertFingerprint = currentAlertFingerprint
|
||||
lastAlertTime = Date.now()
|
||||
|
||||
return plugins.sendMessage(rec)
|
||||
})
|
||||
.then(results => {
|
||||
if (results && results.length > 0) logger.debug('Successfully sent alerts')
|
||||
})
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
const getDeviceTime = _.flow(_.get('device_time'), Date.parse)
|
||||
|
||||
function dropRepeatsWith (comparator, arr) {
|
||||
const iteratee = (acc, val) => val === acc.last
|
||||
? acc
|
||||
: { arr: _.concat(acc.arr, val), last: val }
|
||||
|
||||
return _.reduce(iteratee, { arr: [] }, arr).arr
|
||||
}
|
||||
|
||||
function checkStuckScreen (deviceEvents, machineName) {
|
||||
const sortedEvents = _.sortBy(getDeviceTime, _.map(jsonParse, deviceEvents))
|
||||
const noRepeatEvents = dropRepeatsWith(sameState, sortedEvents)
|
||||
const lastEvent = _.last(noRepeatEvents)
|
||||
|
||||
if (!lastEvent) {
|
||||
return []
|
||||
}
|
||||
|
||||
const state = lastEvent.note.state
|
||||
const isIdle = lastEvent.note.isIdle
|
||||
|
||||
if (isIdle) {
|
||||
return []
|
||||
}
|
||||
|
||||
const age = Math.floor(lastEvent.age)
|
||||
if (age > STALE_STATE) {
|
||||
return [{ code: STALE, state, age, machineName }]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function checkPing (device) {
|
||||
const sql = `select (EXTRACT(EPOCH FROM (now() - updated))) * 1000 AS age from machine_pings
|
||||
where device_id=$1`
|
||||
const deviceId = device.deviceId
|
||||
return db.oneOrNone(sql, [deviceId])
|
||||
.then(row => {
|
||||
if (!row) return [{ code: PING }]
|
||||
if (row.age > NETWORK_DOWN_TIME) return [{ code: PING, age: row.age, machineName: device.name }]
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
function checkPings (devices) {
|
||||
const deviceIds = _.map('deviceId', devices)
|
||||
const promises = _.map(checkPing, devices)
|
||||
|
||||
return Promise.all(promises)
|
||||
.then(_.zipObject(deviceIds))
|
||||
}
|
||||
|
||||
function checkStatus (plugins) {
|
||||
const alerts = { devices: {}, deviceNames: {} }
|
||||
|
||||
return Promise.all([plugins.checkBalances(), dbm.machineEvents(), plugins.getMachineNames()])
|
||||
.then(([balances, events, devices]) => {
|
||||
return checkPings(devices)
|
||||
.then(pings => {
|
||||
alerts.general = _.filter(r => !r.deviceId, balances)
|
||||
devices.forEach(function (device) {
|
||||
const deviceId = device.deviceId
|
||||
const deviceName = device.name
|
||||
const deviceEvents = events.filter(function (eventRow) {
|
||||
return eventRow.device_id === deviceId
|
||||
})
|
||||
|
||||
const ping = pings[deviceId] || []
|
||||
const stuckScreen = checkStuckScreen(deviceEvents, deviceName)
|
||||
|
||||
if (!alerts.devices[deviceId]) alerts.devices[deviceId] = {}
|
||||
alerts.devices[deviceId].balanceAlerts = _.filter(['deviceId', deviceId], balances)
|
||||
alerts.devices[deviceId].deviceAlerts = _.isEmpty(ping) ? stuckScreen : ping
|
||||
|
||||
alerts.deviceNames[deviceId] = deviceName
|
||||
})
|
||||
|
||||
return alerts
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function formatCurrency (num, code) {
|
||||
return numeral(num).format('0,0.00') + ' ' + code
|
||||
}
|
||||
|
||||
function emailAlert (alert) {
|
||||
switch (alert.code) {
|
||||
case PING:
|
||||
if (alert.age) {
|
||||
const pingAge = prettyMs(alert.age, { compact: true, verbose: true })
|
||||
return `Machine down for ${pingAge}`
|
||||
}
|
||||
return 'Machine down for a while.'
|
||||
case STALE:
|
||||
const stuckAge = prettyMs(alert.age, { compact: true, verbose: true })
|
||||
return `Machine is stuck on ${alert.state} screen for ${stuckAge}`
|
||||
case LOW_CRYPTO_BALANCE:
|
||||
const balance = formatCurrency(alert.fiatBalance.balance, alert.fiatCode)
|
||||
return `Low balance in ${alert.cryptoCode} [${balance}]`
|
||||
case HIGH_CRYPTO_BALANCE:
|
||||
const highBalance = 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]`
|
||||
}
|
||||
}
|
||||
|
||||
function emailAlerts (alerts) {
|
||||
return alerts.map(emailAlert).join('\n') + '\n'
|
||||
}
|
||||
|
||||
function printEmailAlerts (alertRec, config) {
|
||||
let body = 'Errors were reported by your Lamassu Machines.\n'
|
||||
|
||||
if (config.balance && alertRec.general.length !== 0) {
|
||||
body = body + '\nGeneral errors:\n'
|
||||
body = body + emailAlerts(alertRec.general) + '\n'
|
||||
}
|
||||
|
||||
_.keys(alertRec.devices).forEach(function (device) {
|
||||
const deviceName = alertRec.deviceNames[device]
|
||||
body = body + '\nErrors for ' + deviceName + ':\n'
|
||||
|
||||
let alerts = []
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||
}
|
||||
|
||||
if (config.errors) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
}
|
||||
|
||||
body = body + emailAlerts(alerts)
|
||||
})
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
function alertSubject (alertRec, config) {
|
||||
let alerts = []
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.keys(alertRec.devices).forEach(function (device) {
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||
}
|
||||
|
||||
if (config.errors) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
}
|
||||
})
|
||||
|
||||
if (alerts.length === 0) return null
|
||||
|
||||
const alertTypes = _.map(codeDisplay, _.uniq(_.map('code', alerts))).sort()
|
||||
return '[Lamassu] Errors reported: ' + alertTypes.join(', ')
|
||||
}
|
||||
|
||||
function printSmsAlerts (alertRec, config) {
|
||||
let alerts = []
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.keys(alertRec.devices).forEach(function (device) {
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||
}
|
||||
|
||||
if (config.errors) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
}
|
||||
})
|
||||
|
||||
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]))
|
||||
|
||||
return {
|
||||
codeDisplay: codeDisplay(code),
|
||||
machineNames
|
||||
}
|
||||
}, _.toPairs(alertsMap))
|
||||
|
||||
const mapByCodeDisplay = _.map(it => _.isEmpty(it.machineNames)
|
||||
? it.codeDisplay : `${it.codeDisplay} (${it.machineNames.join(', ')})`
|
||||
)
|
||||
|
||||
const displayAlertTypes = _.compose(_.uniq, mapByCodeDisplay, _.sortBy('codeDisplay'))(alertTypes)
|
||||
|
||||
return '[Lamassu] Errors reported: ' + displayAlertTypes.join(', ')
|
||||
}
|
||||
|
||||
function getAlertTypes (alertRec, config) {
|
||||
let alerts = []
|
||||
|
||||
if (!config.active || (!config.balance && !config.errors)) return alerts
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.keys(alertRec.devices).forEach(function (device) {
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||
}
|
||||
|
||||
if (config.errors) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
}
|
||||
})
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkNotification,
|
||||
checkPings,
|
||||
checkStuckScreen
|
||||
}
|
||||
|
|
@ -20,6 +20,14 @@ const NETWORK_DOWN_TIME = 1 * T.minute
|
|||
const STALE_STATE = 7 * T.minute
|
||||
const ALERT_SEND_INTERVAL = T.hour
|
||||
|
||||
const NOTIFICATION_TYPES = {
|
||||
HIGH_VALUE_TX: 'highValueTransaction',
|
||||
FIAT_BALANCE: 'fiatBalance',
|
||||
CRYPTO_BALANCE: 'cryptoBalance',
|
||||
COMPLIANCE: 'compliance',
|
||||
ERROR: 'error'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PING,
|
||||
STALE,
|
||||
|
|
@ -30,5 +38,6 @@ module.exports = {
|
|||
CODES_DISPLAY,
|
||||
NETWORK_DOWN_TIME,
|
||||
STALE_STATE,
|
||||
ALERT_SEND_INTERVAL
|
||||
ALERT_SEND_INTERVAL,
|
||||
NOTIFICATION_TYPES
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const _ = require('lodash/fp')
|
||||
const utils = require("./utils")
|
||||
const utils = require('./utils')
|
||||
|
||||
const email = require('../email')
|
||||
|
||||
|
|
@ -12,61 +12,47 @@ const {
|
|||
LOW_CASH_OUT
|
||||
} = require('./codes')
|
||||
|
||||
function alertSubject(alertRec, config) {
|
||||
function alertSubject (alertRec, config) {
|
||||
let alerts = []
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.keys(alertRec.devices).forEach(function (device) {
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||
}
|
||||
|
||||
if (config.errors) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
}
|
||||
})
|
||||
_.forEach(device => {
|
||||
alerts = _.concat(alerts, utils.deviceAlerts(config, alertRec, device))
|
||||
}, _.keys(alertRec.devices))
|
||||
|
||||
if (alerts.length === 0) return null
|
||||
|
||||
const alertTypes = _.map(codeDisplay, _.uniq(_.map('code', alerts))).sort()
|
||||
const alertTypes = _.flow(_.map('code'), _.uniq, _.map(utils.codeDisplay), _.sortBy(o => o))(alerts)
|
||||
return '[Lamassu] Errors reported: ' + alertTypes.join(', ')
|
||||
}
|
||||
|
||||
function printEmailAlerts(alertRec, config) {
|
||||
function printEmailAlerts (alertRec, config) {
|
||||
let body = 'Errors were reported by your Lamassu Machines.\n'
|
||||
|
||||
if (config.balance && alertRec.general.length !== 0) {
|
||||
body = body + '\nGeneral errors:\n'
|
||||
body = body + emailAlerts(alertRec.general) + '\n'
|
||||
body += '\nGeneral errors:\n'
|
||||
body += emailAlerts(alertRec.general) + '\n'
|
||||
}
|
||||
|
||||
_.keys(alertRec.devices).forEach(function (device) {
|
||||
_.forEach(device => {
|
||||
const deviceName = alertRec.deviceNames[device]
|
||||
body = body + '\nErrors for ' + deviceName + ':\n'
|
||||
body += '\nErrors for ' + deviceName + ':\n'
|
||||
|
||||
let alerts = []
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||
}
|
||||
|
||||
if (config.errors) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
}
|
||||
|
||||
body = body + emailAlerts(alerts)
|
||||
})
|
||||
const alerts = utils.deviceAlerts(config, alertRec, device)
|
||||
|
||||
body += emailAlerts(alerts)
|
||||
}, _.keys(alertRec.devices))
|
||||
return body
|
||||
}
|
||||
|
||||
function emailAlerts(alerts) {
|
||||
return alerts.map(emailAlert).join('\n') + '\n'
|
||||
function emailAlerts (alerts) {
|
||||
return _.join('\n', _.map(emailAlert, alerts)) + '\n'
|
||||
}
|
||||
|
||||
function emailAlert(alert) {
|
||||
function emailAlert (alert) {
|
||||
switch (alert.code) {
|
||||
case PING:
|
||||
if (alert.age) {
|
||||
|
|
@ -98,5 +84,4 @@ function emailAlert(alert) {
|
|||
|
||||
const sendMessage = email.sendMessage
|
||||
|
||||
|
||||
module.exports = { alertSubject, printEmailAlerts, sendMessage }
|
||||
|
|
|
|||
|
|
@ -11,8 +11,15 @@ const utils = require('./utils')
|
|||
const emailFuncs = require('./email')
|
||||
const smsFuncs = require('./sms')
|
||||
const { STALE, STALE_STATE, PING } = require('./codes')
|
||||
const { NOTIFICATION_TYPES: {
|
||||
HIGH_VALUE_TX,
|
||||
FIAT_BALANCE,
|
||||
CRYPTO_BALANCE,
|
||||
COMPLIANCE,
|
||||
ERROR }
|
||||
} = require('./codes')
|
||||
|
||||
function buildMessage(alerts, notifications) {
|
||||
function buildMessage (alerts, notifications) {
|
||||
const smsEnabled = utils.isActive(notifications.sms)
|
||||
const emailEnabled = utils.isActive(notifications.email)
|
||||
|
||||
|
|
@ -34,7 +41,7 @@ function buildMessage(alerts, notifications) {
|
|||
return rec
|
||||
}
|
||||
|
||||
function checkNotification(plugins) {
|
||||
function checkNotification (plugins) {
|
||||
const notifications = plugins.getNotificationConfig()
|
||||
const smsEnabled = utils.isActive(notifications.sms)
|
||||
const emailEnabled = utils.isActive(notifications.email)
|
||||
|
|
@ -50,28 +57,25 @@ function checkNotification(plugins) {
|
|||
)
|
||||
if (!currentAlertFingerprint) {
|
||||
const inAlert = !!utils.getAlertFingerprint()
|
||||
// (fingerprint = null, lastAlertTime = null)
|
||||
// variables for setAlertFingerprint: (fingerprint = null, lastAlertTime = null)
|
||||
utils.setAlertFingerprint(null, null)
|
||||
if (inAlert) {
|
||||
return utils.sendNoAlerts(plugins, smsEnabled, emailEnabled)
|
||||
}
|
||||
}
|
||||
if (utils.shouldNotAlert(currentAlertFingerprint)) {
|
||||
return
|
||||
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)
|
||||
if (results && results.length > 0) {
|
||||
logger.debug('Successfully sent alerts')
|
||||
}
|
||||
})
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
function getAlerts(plugins) {
|
||||
function getAlerts (plugins) {
|
||||
return Promise.all([
|
||||
plugins.checkBalances(),
|
||||
queries.machineEvents(),
|
||||
|
|
@ -82,10 +86,10 @@ function getAlerts(plugins) {
|
|||
})
|
||||
}
|
||||
|
||||
function buildAlerts(pings, balances, events, devices) {
|
||||
function buildAlerts (pings, balances, events, devices) {
|
||||
const alerts = { devices: {}, deviceNames: {} }
|
||||
alerts.general = _.filter(r => !r.deviceId, balances)
|
||||
devices.forEach(function (device) {
|
||||
_.forEach(device => {
|
||||
const deviceId = device.deviceId
|
||||
const deviceName = device.name
|
||||
const deviceEvents = events.filter(function (eventRow) {
|
||||
|
|
@ -94,83 +98,78 @@ function buildAlerts(pings, balances, events, devices) {
|
|||
const ping = pings[deviceId] || []
|
||||
const stuckScreen = checkStuckScreen(deviceEvents, deviceName)
|
||||
|
||||
if (!alerts.devices[deviceId]) alerts.devices[deviceId] = {}
|
||||
alerts.devices[deviceId].balanceAlerts = _.filter(
|
||||
alerts.devices = _.set([deviceId, 'balanceAlerts'], _.filter(
|
||||
['deviceId', deviceId],
|
||||
balances
|
||||
)
|
||||
), alerts.devices)
|
||||
alerts.devices[deviceId].deviceAlerts = _.isEmpty(ping) ? stuckScreen : ping
|
||||
|
||||
alerts.deviceNames[deviceId] = deviceName
|
||||
})
|
||||
}, devices)
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
function checkPings(devices) {
|
||||
function checkPings (devices) {
|
||||
const deviceIds = _.map('deviceId', devices)
|
||||
const pings = _.map(utils.checkPing, devices)
|
||||
return _.zipObject(deviceIds)(pings)
|
||||
}
|
||||
|
||||
function checkStuckScreen(deviceEvents, machineName) {
|
||||
function checkStuckScreen (deviceEvents, machineName) {
|
||||
const sortedEvents = _.sortBy(
|
||||
utils.getDeviceTime,
|
||||
_.map(utils.parseEventNote, deviceEvents)
|
||||
)
|
||||
const lastEvent = _.last(sortedEvents)
|
||||
|
||||
if (!lastEvent) {
|
||||
return []
|
||||
}
|
||||
if (!lastEvent) return []
|
||||
|
||||
const state = lastEvent.note.state
|
||||
const isIdle = lastEvent.note.isIdle
|
||||
|
||||
if (isIdle) {
|
||||
return []
|
||||
}
|
||||
if (isIdle) return []
|
||||
|
||||
const age = Math.floor(lastEvent.age)
|
||||
if (age > STALE_STATE) {
|
||||
return [{ code: STALE, state, age, machineName }]
|
||||
}
|
||||
if (age > STALE_STATE) return [{ code: STALE, state, age, machineName }]
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
async function transactionNotify (tx, rec) {
|
||||
const settings = await settingsLoader.loadLatest()
|
||||
const notifSettings = configManager.getGlobalNotifications(settings.config)
|
||||
const highValueTx = tx.fiat.gt(notifSettings.highValueTransaction || Infinity)
|
||||
const isCashOut = tx.direction === 'cashOut'
|
||||
function transactionNotify (tx, rec) {
|
||||
return settingsLoader.loadLatest().then(settings => {
|
||||
const notifSettings = configManager.getGlobalNotifications(settings.config)
|
||||
const highValueTx = tx.fiat.gt(notifSettings.highValueTransaction || Infinity)
|
||||
const isCashOut = tx.direction === 'cashOut'
|
||||
// high value tx on database
|
||||
if (highValueTx && (tx.direction === 'cashIn' || (tx.direction === 'cashOut' && rec.isRedemption))) {
|
||||
const direction = tx.direction === 'cashOut' ? 'cash-out' : 'cash-in'
|
||||
const message = `${tx.fiat} ${tx.fiatCode} ${direction} transaction`
|
||||
const detailB = utils.buildDetail({ deviceId: tx.deviceId, direction, fiat: tx.fiat, fiatCode: tx.fiatCode, cryptoAddress: tx.toAddress })
|
||||
queries.addNotification(HIGH_VALUE_TX, message, detailB)
|
||||
}
|
||||
|
||||
// high value tx on database
|
||||
if(highValueTx && tx.direction === 'cashIn' || highValueTx && tx.direction === 'cashOut' && rec.isRedemption) {
|
||||
queries.addHighValueTx(tx)
|
||||
}
|
||||
// alert through sms or email any transaction or high value transaction, if SMS || email alerts are enabled
|
||||
const cashOutConfig = configManager.getCashOut(tx.deviceId, settings.config)
|
||||
const zeroConfLimit = cashOutConfig.zeroConfLimit
|
||||
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({})
|
||||
|
||||
// alert through sms or email any transaction or high value transaction, if SMS || email alerts are enabled
|
||||
const cashOutConfig = configManager.getCashOut(tx.deviceId, settings.config)
|
||||
const zeroConfLimit = cashOutConfig.zeroConfLimit
|
||||
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)
|
||||
|
||||
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([
|
||||
machineLoader.getMachineName(tx.deviceId),
|
||||
customerPromise
|
||||
])
|
||||
.then(([machineName, customer]) => {
|
||||
return utils.buildTransactionMessage(tx, rec, highValueTx, machineName, customer)
|
||||
return Promise.all([
|
||||
machineLoader.getMachineName(tx.deviceId),
|
||||
customerPromise
|
||||
]).then(([machineName, customer]) => {
|
||||
return utils.buildTransactionMessage(tx, rec, highValueTx, machineName, customer)
|
||||
}).then(([msg, highValueTx]) => sendTransactionMessage(msg, highValueTx))
|
||||
})
|
||||
.then(([msg, highValueTx]) => sendTransactionMessage(msg, highValueTx))
|
||||
}
|
||||
|
||||
function sendRedemptionMessage(txId, error) {
|
||||
function sendRedemptionMessage (txId, error) {
|
||||
const subject = `Here's an update on transaction ${txId}`
|
||||
const body = error
|
||||
? `Error: ${error}`
|
||||
|
|
@ -188,218 +187,173 @@ function sendRedemptionMessage(txId, error) {
|
|||
return sendTransactionMessage(rec)
|
||||
}
|
||||
|
||||
async function sendTransactionMessage(rec, isHighValueTx) {
|
||||
const settings = await settingsLoader.loadLatest()
|
||||
const notifications = configManager.getGlobalNotifications(settings.config)
|
||||
function sendTransactionMessage (rec, isHighValueTx) {
|
||||
return settingsLoader.loadLatest().then(settings => {
|
||||
const notifications = configManager.getGlobalNotifications(settings.config)
|
||||
|
||||
let promises = []
|
||||
const promises = []
|
||||
|
||||
const emailActive =
|
||||
notifications.email.active &&
|
||||
(notifications.email.transactions || isHighValueTx)
|
||||
if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec))
|
||||
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))
|
||||
const smsActive =
|
||||
notifications.sms.active &&
|
||||
(notifications.sms.transactions || isHighValueTx)
|
||||
if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec))
|
||||
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
const cashCassettesNotify = (cassettes, deviceId) => {
|
||||
settingsLoader.loadLatest()
|
||||
.then(settings =>
|
||||
[
|
||||
configManager.getNotifications(null, deviceId, settings.config),
|
||||
configManager.getCashOut(deviceId,settings.config).active
|
||||
])
|
||||
.then(([notifications, cashOutEnabled]) => {
|
||||
const cassette1Count = cassettes.cassette1
|
||||
const cassette2Count = cassettes.cassette2
|
||||
const cassette1Threshold = notifications.fiatBalanceCassette1
|
||||
const cassette2Threshold = notifications.fiatBalanceCassette2
|
||||
|
||||
if(cashOutEnabled) {
|
||||
// we only want to add this notification if there isn't one already set and unread in the database
|
||||
Promise.all([queries.getUnreadCassetteNotifications(1, deviceId), queries.getUnreadCassetteNotifications(2, deviceId)]).then(res => {
|
||||
if(res[0].length === 0 && cassette1Count < cassette1Threshold) {
|
||||
console.log("Adding fiatBalance alert for cashbox 1 in database - count & threshold: ", cassette1Count, cassette1Threshold )
|
||||
return queries.addCashCassetteWarning(1, deviceId)
|
||||
}
|
||||
if(res[1].length === 0 && cassette2Count < cassette2Threshold) {
|
||||
console.log("Adding fiatBalance alert for cashbox 2 in database - count & threshold: ", cassette2Count, cassette2Threshold )
|
||||
return queries.addCashCassetteWarning(2, deviceId)
|
||||
}
|
||||
})
|
||||
}
|
||||
return Promise.all(promises)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
/*
|
||||
Notes for new "valid" column on notifications table:
|
||||
|
||||
- We only want to add a new notification if it is present in the high or low warning consts.
|
||||
- Before we add the notification we need to see if there is no "valid" notification in the database.
|
||||
- Since the poller runs every few seconds, if the user marks it as read, this code would add a new notification
|
||||
immediately. This new column helps us decide if a new notification should be added.
|
||||
- "Valid" is defaulted to "true". When the cryptobalance goes over the low threshold or under the high threshold,
|
||||
the notification will be marked as invalid. This will allow a new one to be sent. If the cryptobalance never goes
|
||||
into the middle of the high and low thresholds, the old, "read" notification will still be relevant so we won't add
|
||||
a new one.
|
||||
*/
|
||||
|
||||
const clearOldCryptoNotifications = (balances) => {
|
||||
// get valid crypto notifications from DB
|
||||
// if that notification doesn't exist in balances, then make it invalid on the DB
|
||||
queries.getAllValidNotifications('cryptoBalance').then(res => {
|
||||
const notifications = _.map(it => {
|
||||
return {
|
||||
cryptoCode: it.detail.split('_')[0],
|
||||
code: it.detail.split('_').splice(1).join('_')
|
||||
}
|
||||
const notInvalidated = _.filter(notification => {
|
||||
return !_.find(id => notification.id === id)(indexesToInvalidate)
|
||||
}, res)
|
||||
_.forEach(notification => {
|
||||
const idx = _.findIndex(balance => {
|
||||
return balance.code === notification.code && balance.cryptoCode === notification.cryptoCode
|
||||
}, balances)
|
||||
return (indexesToInvalidate.length ? queries.batchInvalidate(indexesToInvalidate) : Promise.resolve()).then(() => notInvalidated)
|
||||
})
|
||||
}
|
||||
|
||||
if(idx !== -1) {
|
||||
return
|
||||
}
|
||||
// if the notification doesn't exist in the new balances object, then it is outdated and is not valid anymore
|
||||
return queries.invalidateNotification(notification.id)
|
||||
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 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 = `Cash-out cassette ${balance.cassette} almost empty!`
|
||||
const detailB = utils.buildDetail({ deviceId: balance.deviceId, cassette: balance.cassette })
|
||||
return queries.addNotification(FIAT_BALANCE, message, detailB)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const balancesNotify = (balances) => {
|
||||
const highFilter = o => o.code === 'HIGH_CRYPTO_BALANCE'
|
||||
const lowFilter = o => o.code === 'LOW_CRYPTO_BALANCE'
|
||||
const highWarnings = _.filter(highFilter, balances)
|
||||
const lowWarnings = _.filter(lowFilter, balances)
|
||||
|
||||
clearOldCryptoNotifications(balances)
|
||||
|
||||
highWarnings.forEach(warning => {
|
||||
queries.getValidNotifications('cryptoBalance', `${warning.cryptoCode}_${warning.code}`).then(res => {
|
||||
if (res.length > 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
console.log("Adding high balance alert for " + warning.cryptoCode + " - " + warning.fiatBalance.balance)
|
||||
const balance = utils.formatCurrency(warning.fiatBalance.balance, warning.fiatCode)
|
||||
return queries.addCryptoBalanceWarning(`${warning.cryptoCode}_${warning.code}`, `High balance in ${warning.cryptoCode} [${balance}]`)
|
||||
})
|
||||
})
|
||||
lowWarnings.forEach(warning => {
|
||||
queries.getValidNotifications('cryptoBalance', `${warning.cryptoCode}_${warning.code}`).then(res => {
|
||||
if (res.length > 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
console.log("Adding low balance alert for " + warning.cryptoCode + " - " + warning.fiatBalance.balance)
|
||||
const balance = utils.formatCurrency(warning.fiatBalance.balance, warning.fiatCode)
|
||||
return queries.addCryptoBalanceWarning(`${warning.cryptoCode}_${warning.code}`, `Low balance in ${warning.cryptoCode} [${balance}]`)
|
||||
})
|
||||
})
|
||||
const cryptoFilter = o => o.code === 'HIGH_CRYPTO_BALANCE' || o.code === 'LOW_CRYPTO_BALANCE'
|
||||
const fiatFilter = o => o.code === 'LOW_CASH_OUT'
|
||||
const cryptoWarnings = _.filter(cryptoFilter, balances)
|
||||
const fiatWarnings = _.filter(fiatFilter, balances)
|
||||
return Promise.all([cryptoBalancesNotify(cryptoWarnings), fiatBalancesNotify(fiatWarnings)]).catch(console.error)
|
||||
}
|
||||
|
||||
const clearOldErrorNotifications = (alerts) => {
|
||||
queries.getAllValidNotifications('error').then(res => {
|
||||
_.forEach(notification => {
|
||||
const idx = _.findIndex(alert => {
|
||||
return alert.code === notification.detail.split('_')[0] && alert.deviceId === notification.device_id
|
||||
}, alerts)
|
||||
if(idx !== -1) {
|
||||
return
|
||||
}
|
||||
// if the notification doesn't exist, then it is outdated and is not valid anymore
|
||||
return queries.invalidateNotification(notification.id)
|
||||
}, res)
|
||||
})
|
||||
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)
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
const errorAlertsNotify = (alertRec) => {
|
||||
let alerts = []
|
||||
_.keys(alertRec.devices).forEach(function (device) {
|
||||
// embed device ID in each alert object inside the deviceAlerts array
|
||||
alertRec.devices[device].deviceAlerts = _.map(alert => {
|
||||
return {...alert, deviceId: device}
|
||||
}, alertRec.devices[device].deviceAlerts)
|
||||
// concat every array into one
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
})
|
||||
const embedDeviceId = deviceId => _.assign({ deviceId })
|
||||
const mapToAlerts = _.map(it => _.map(embedDeviceId(it), alertRec.devices[it].deviceAlerts))
|
||||
const alerts = _.compose(_.flatten, mapToAlerts, _.keys)(alertRec.devices)
|
||||
|
||||
// now that we have all the alerts, we want to add PING and STALE alerts to the DB
|
||||
// if there is a valid alert on the DB that doesn't exist on the new alerts array,
|
||||
// that alert should be considered invalid
|
||||
// after that, for the alerts array, we have to see if there is a valid alert of
|
||||
// the sorts already on the DB
|
||||
clearOldErrorNotifications(alerts)
|
||||
|
||||
_.forEach(alert => {
|
||||
switch(alert.code) {
|
||||
case PING:
|
||||
return queries.getValidNotifications('error', PING, alert.deviceId).then(res => {
|
||||
if(res.length > 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
console.log("Adding PING alert on database for " + alert.machineName)
|
||||
const message = `Machine down`
|
||||
return queries.addErrorNotification(`${PING}_${alert.age ? alert.age : '-1'}`, message, alert.deviceId)
|
||||
})
|
||||
case STALE:
|
||||
return queries.getValidNotifications('error', STALE, alert.deviceId).then(res => {
|
||||
if(res.length > 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
console.log("Adding STALE alert on database for " + alert.machineName)
|
||||
const message = `Machine is stuck on ${alert.state} screen`
|
||||
return queries.addErrorNotification(STALE, message, alert.deviceId)
|
||||
})
|
||||
default:
|
||||
return
|
||||
}
|
||||
}, alerts)
|
||||
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)
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
const blacklistNotify = (tx, isAddressReuse) => {
|
||||
let detail = ''
|
||||
let message = ''
|
||||
if(isAddressReuse) {
|
||||
detail = `${tx.cryptoCode}_REUSED_${tx.toAddress}`
|
||||
message = `Blocked reused address: ${tx.cryptoCode} ${tx.toAddress.substr(0,10)}...`
|
||||
} else {
|
||||
detail = `${tx.cryptoCode}_BLOCKED_${tx.toAddress}`
|
||||
message = `Blocked blacklisted address: ${tx.cryptoCode} ${tx.toAddress.substr(0,10)}...`
|
||||
}
|
||||
const code = isAddressReuse ? 'REUSED' : 'BLOCKED'
|
||||
const name = isAddressReuse ? 'reused' : 'blacklisted'
|
||||
|
||||
return queries.addComplianceNotification(tx.deviceId, detail, message)
|
||||
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 clearBlacklistNotification = (cryptoCode, cryptoAddress) => {
|
||||
return queries.clearBlacklistNotification(cryptoCode, cryptoAddress).catch(console.error)
|
||||
}
|
||||
|
||||
const clearOldCustomerSuspendedNotifications = (customerId, deviceId) => {
|
||||
const detail = `SUSPENDED_${customerId}`
|
||||
return queries.invalidateNotification(null, detail, deviceId)
|
||||
const detailB = utils.buildDetail({ code: 'SUSPENDED', customerId, deviceId })
|
||||
return queries.invalidateNotification(detailB, 'compliance')
|
||||
}
|
||||
|
||||
const customerComplianceNotify = (customer, deviceId, prefix, days = null) => {
|
||||
// prefix can be "BLOCKED", "SUSPENDED", etc
|
||||
const detail = `${prefix}_${customer.id}`
|
||||
const customerComplianceNotify = (customer, deviceId, code, 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 = prefix === "SUSPENDED" ? `Customer suspended until ${date.toLocaleString()}` : `Customer blocked`
|
||||
const message = code === 'SUSPENDED' ? `Customer suspended until ${date.toLocaleString()}` : `Customer blocked`
|
||||
|
||||
// we have to clear every notification for this user where the suspension ended before the current date
|
||||
clearOldCustomerSuspendedNotifications(customer.id, deviceId).then(() => {
|
||||
return queries.getValidNotifications('compliance', detail, deviceId)
|
||||
}).then(res => {
|
||||
if (res.length > 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return queries.addComplianceNotification(deviceId, detail, message)
|
||||
})
|
||||
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)
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
|
@ -408,7 +362,7 @@ module.exports = {
|
|||
checkPings,
|
||||
checkStuckScreen,
|
||||
sendRedemptionMessage,
|
||||
cashCassettesNotify,
|
||||
blacklistNotify,
|
||||
customerComplianceNotify,
|
||||
clearBlacklistNotification
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
const { v4: uuidv4 } = require('uuid')
|
||||
const pgp = require('pg-promise')()
|
||||
const _ = require('lodash/fp')
|
||||
|
||||
const dbm = require('../postgresql_interface')
|
||||
const db = require('../db')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
|
||||
// types of notifications able to be inserted into db:
|
||||
/*
|
||||
|
|
@ -11,76 +14,68 @@ compliance - notifications related to warnings triggered by compliance settings
|
|||
error - notifications related to errors
|
||||
*/
|
||||
|
||||
const addHighValueTx = (tx) => {
|
||||
const sql = `INSERT INTO notifications (id, type, device_id, message, created) values ($1, $2, $3, $4, CURRENT_TIMESTAMP)`
|
||||
const direction = tx.direction === "cashOut" ? 'cash-out' : 'cash-in'
|
||||
const message = `${tx.fiat} ${tx.fiatCode} ${direction} transaction`
|
||||
return db.oneOrNone(sql, [uuidv4(), 'highValueTransaction', tx.deviceId, message])
|
||||
}
|
||||
|
||||
const addCashCassetteWarning = (cassetteNumber, deviceId) => {
|
||||
const sql = `INSERT INTO notifications (id, type, detail, device_id, message, created) values ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)`
|
||||
const message = `Cash-out cassette ${cassetteNumber} almost empty!`
|
||||
return db.oneOrNone(sql, [uuidv4(), 'fiatBalance', cassetteNumber, deviceId, message])
|
||||
}
|
||||
|
||||
const getUnreadCassetteNotifications = (cassetteNumber, deviceId) => {
|
||||
const sql = `SELECT * FROM notifications WHERE read = 'f' AND device_id = $1 AND TYPE = 'fiatBalance' AND detail = '$2'`
|
||||
return db.any(sql, [deviceId, cassetteNumber])
|
||||
}
|
||||
|
||||
const addCryptoBalanceWarning = (detail, message) => {
|
||||
const sql = `INSERT INTO notifications (id, type, detail, message, created) values ($1, $2, $3, $4, CURRENT_TIMESTAMP)`
|
||||
return db.oneOrNone(sql, [uuidv4(), 'cryptoBalance', detail, message])
|
||||
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])
|
||||
}
|
||||
|
||||
const getAllValidNotifications = (type) => {
|
||||
const sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't'`
|
||||
return db.any(sql, [type])
|
||||
const sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't'`
|
||||
return db.any(sql, [type])
|
||||
}
|
||||
|
||||
const addErrorNotification = (detail, message, deviceId) => {
|
||||
const sql = `INSERT INTO notifications (id, type, detail, device_id, message, created) values ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)`
|
||||
return db.oneOrNone(sql, [uuidv4(), 'error', detail, deviceId, message])
|
||||
const invalidateNotification = (detail, type) => {
|
||||
detail = _.omitBy(_.isEmpty, detail)
|
||||
const sql = `UPDATE notifications SET valid = 'f', read = 't', modified = CURRENT_TIMESTAMP WHERE valid = 't' AND type = $1 AND detail::jsonb @> $2::jsonb`
|
||||
return db.none(sql, [type, detail])
|
||||
}
|
||||
|
||||
const getValidNotifications = (type, detail, deviceId = null) => {
|
||||
let sql;
|
||||
if(!deviceId) {
|
||||
sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't' AND detail LIKE $2`
|
||||
}
|
||||
else {
|
||||
sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't' AND detail LIKE $2 AND device_id = $3`
|
||||
}
|
||||
return db.any(sql, [type, `%${detail}%`, deviceId])
|
||||
const batchInvalidate = (ids) => {
|
||||
const formattedIds = _.map(pgp.as.text, ids).join(',')
|
||||
const sql = `UPDATE notifications SET valid = 'f', read = 't', modified = CURRENT_TIMESTAMP WHERE id IN ($1^)`
|
||||
return db.none(sql, [formattedIds])
|
||||
}
|
||||
|
||||
const invalidateNotification = (id, detail = null, deviceId = null) => {
|
||||
let sql = ''
|
||||
if(id) {
|
||||
sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE valid = 't' AND id = $1`
|
||||
}
|
||||
else {
|
||||
sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE valid = 't' AND detail LIKE $2`
|
||||
sql = deviceId ? sql + ' AND device_id = $3' : sql
|
||||
}
|
||||
return db.none(sql, [id, `%${detail}%`, deviceId])
|
||||
const clearBlacklistNotification = (cryptoCode, cryptoAddress) => {
|
||||
const sql = `UPDATE notifications SET valid = 'f', read = 't', modified = CURRENT_TIMESTAMP 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])
|
||||
}
|
||||
|
||||
const addComplianceNotification = (deviceId, detail, message) => {
|
||||
const sql = `INSERT INTO notifications (id, type, detail, device_id, message, created) values ($1, 'compliance', $2, $3, $4, CURRENT_TIMESTAMP)`
|
||||
return db.oneOrNone(sql, [uuidv4(), detail, deviceId, message])
|
||||
const getValidNotifications = (type, detail) => {
|
||||
const sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't' AND detail @> $2`
|
||||
return db.any(sql, [type, detail])
|
||||
}
|
||||
|
||||
const getNotifications = () => {
|
||||
const sql = `SELECT * FROM notifications ORDER BY created DESC`
|
||||
return db.any(sql)
|
||||
}
|
||||
|
||||
const markAsRead = (id) => {
|
||||
const sql = `UPDATE notifications SET read = 't', modified = CURRENT_TIMESTAMP WHERE id = $1`
|
||||
return db.none(sql, [id])
|
||||
}
|
||||
|
||||
const markAllAsRead = () => {
|
||||
const sql = `UPDATE notifications SET read = 't'`
|
||||
return db.none(sql)
|
||||
}
|
||||
|
||||
const hasUnreadNotifications = () => {
|
||||
const sql = `SELECT EXISTS (SELECT 1 FROM notifications WHERE read = 'f' LIMIT 1)`
|
||||
return db.oneOrNone(sql).then(res => res.exists)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
machineEvents: dbm.machineEvents,
|
||||
addHighValueTx,
|
||||
addCashCassetteWarning,
|
||||
addCryptoBalanceWarning,
|
||||
addErrorNotification,
|
||||
getUnreadCassetteNotifications,
|
||||
getAllValidNotifications,
|
||||
getValidNotifications,
|
||||
invalidateNotification,
|
||||
addComplianceNotification
|
||||
machineEvents: dbm.machineEvents,
|
||||
addNotification,
|
||||
getAllValidNotifications,
|
||||
invalidateNotification,
|
||||
batchInvalidate,
|
||||
clearBlacklistNotification,
|
||||
getValidNotifications,
|
||||
getNotifications,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
hasUnreadNotifications
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,22 +2,16 @@ const _ = require('lodash/fp')
|
|||
const utils = require('./utils')
|
||||
const sms = require('../sms')
|
||||
|
||||
function printSmsAlerts(alertRec, config) {
|
||||
function printSmsAlerts (alertRec, config) {
|
||||
let alerts = []
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.keys(alertRec.devices).forEach(function (device) {
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||
}
|
||||
|
||||
if (config.errors) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
}
|
||||
})
|
||||
_.forEach(device => {
|
||||
alerts = _.concat(alerts, utils.deviceAlerts(config, alertRec, device))
|
||||
}, _.keys(alertRec.devices))
|
||||
|
||||
if (alerts.length === 0) return null
|
||||
|
||||
|
|
@ -27,12 +21,12 @@ function printSmsAlerts(alertRec, config) {
|
|||
const code = entry[0]
|
||||
const machineNames = _.filter(
|
||||
_.negate(_.isEmpty),
|
||||
_.map('machineName', entry[1]),
|
||||
_.map('machineName', entry[1])
|
||||
)
|
||||
|
||||
const cryptoCodes = _.filter(
|
||||
_.negate(_.isEmpty),
|
||||
_.map('cryptoCode', entry[1]),
|
||||
_.map('cryptoCode', entry[1])
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
@ -43,16 +37,11 @@ function printSmsAlerts(alertRec, config) {
|
|||
}, _.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(', ')})`
|
||||
}
|
||||
else return `${it.codeDisplay} (${it.machineNames.join(', ')})`
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ const BigNumber = require('../../../lib/bn')
|
|||
|
||||
const notifier = require('..')
|
||||
const utils = require('../utils')
|
||||
const queries = require("../queries")
|
||||
const emailFuncs = require('../email')
|
||||
const smsFuncs = require('../sms')
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -83,7 +81,7 @@ const notifSettings = {
|
|||
email_errors: false,
|
||||
sms_errors: true,
|
||||
sms_transactions: true,
|
||||
highValueTransaction: Infinity, //this will make highValueTx always false
|
||||
highValueTransaction: Infinity, // this will make highValueTx always false
|
||||
sms: {
|
||||
active: true,
|
||||
errors: true,
|
||||
|
|
@ -96,63 +94,66 @@ const notifSettings = {
|
|||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
describe('checkNotifications', () => {
|
||||
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)
|
||||
})
|
||||
).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 }
|
||||
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' }
|
||||
]
|
||||
).resolves.toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
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': []
|
||||
describe('checkPings', () => {
|
||||
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)
|
||||
|
|
@ -190,25 +191,42 @@ test('If no alert fingerprint and inAlert is true, exits on call to sendNoAlerts
|
|||
expect(mockSendNoAlerts).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// vvv tests for checkstuckscreen...
|
||||
test('checkStuckScreen returns [] when no events are found', () => {
|
||||
expect(notifier.checkStuckScreen([], 'Abc123')).toEqual([])
|
||||
})
|
||||
describe('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
|
||||
},
|
||||
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:
|
||||
|
|
@ -216,71 +234,55 @@ test('checkStuckScreen returns [] if most recent event is idle', () => {
|
|||
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
|
||||
}
|
||||
])
|
||||
).toEqual([])
|
||||
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('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 () => {
|
||||
test('calls sendRedemptionMessage if !zeroConf and rec.isRedemption', () => {
|
||||
const configManager = require('../../new-config-manager')
|
||||
const settingsLoader = require('../../new-settings-loader')
|
||||
|
||||
|
|
@ -291,25 +293,25 @@ test("calls sendRedemptionMessage if !zeroConf and rec.isRedemption", async () =
|
|||
// sendRedemptionMessage will cause this func to be called
|
||||
jest.spyOn(smsFuncs, 'sendMessage').mockImplementation((_, rec) => rec)
|
||||
|
||||
getCashOut.mockReturnValue({zeroConfLimit: -Infinity})
|
||||
loadLatest.mockReturnValue({})
|
||||
getGlobalNotifications.mockReturnValue({... notifSettings, sms: { active: true, errors: true, transactions: true }})
|
||||
getCashOut.mockReturnValue({ zeroConfLimit: -Infinity })
|
||||
loadLatest.mockReturnValue(Promise.resolve({}))
|
||||
getGlobalNotifications.mockReturnValue({ ...notifSettings, sms: { active: true, errors: true, transactions: 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'
|
||||
}
|
||||
return notifier.transactionNotify(tx, { isRedemption: true }).then(response => {
|
||||
// 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 () => {
|
||||
test('calls sendTransactionMessage if !zeroConf and !rec.isRedemption', async () => {
|
||||
const configManager = require('../../new-config-manager')
|
||||
const settingsLoader = require('../../new-settings-loader')
|
||||
const machineLoader = require('../../machine-loader')
|
||||
|
|
@ -321,17 +323,17 @@ test("calls sendTransactionMessage if !zeroConf and !rec.isRedemption", async ()
|
|||
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])
|
||||
jest.spyOn(smsFuncs, 'sendMessage').mockImplementation((_, rec) => ({ prop: rec }))
|
||||
buildTransactionMessage.mockImplementation(() => ['mock message', false])
|
||||
|
||||
getMachineName.mockReturnValue("mockMachineName")
|
||||
getCashOut.mockReturnValue({zeroConfLimit: -Infinity})
|
||||
loadLatest.mockReturnValue({})
|
||||
getGlobalNotifications.mockReturnValue({... notifSettings, sms: { active: true, errors: true, transactions: true }})
|
||||
getMachineName.mockReturnValue('mockMachineName')
|
||||
getCashOut.mockReturnValue({ zeroConfLimit: -Infinity })
|
||||
loadLatest.mockReturnValue(Promise.resolve({}))
|
||||
getGlobalNotifications.mockReturnValue({ ...notifSettings, sms: { active: true, errors: true, transactions: true } })
|
||||
|
||||
const response = await notifier.transactionNotify(tx, {isRedemption: false})
|
||||
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'}])
|
||||
expect(response).toEqual([{ prop: 'mock message' }])
|
||||
})
|
||||
|
|
@ -26,75 +26,79 @@ const notifications = {
|
|||
email: { active: false, errors: false }
|
||||
}
|
||||
|
||||
test('Build alert fingerprint returns null if no sms or email alerts', () => {
|
||||
expect(
|
||||
utils.buildAlertFingerprint(
|
||||
{
|
||||
devices: {},
|
||||
deviceNames: {},
|
||||
general: []
|
||||
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'
|
||||
},
|
||||
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 }
|
||||
sms: {
|
||||
body: '[Lamassu] All clear'
|
||||
}
|
||||
})
|
||||
).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 }
|
||||
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'
|
||||
}
|
||||
})
|
||||
).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')
|
||||
})
|
||||
|
||||
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'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,14 +11,26 @@ const {
|
|||
ALERT_SEND_INTERVAL
|
||||
} = require('./codes')
|
||||
|
||||
function parseEventNote(event) {
|
||||
const DETAIL_TEMPLATE = {
|
||||
deviceId: '',
|
||||
cryptoCode: '',
|
||||
code: '',
|
||||
cassette: '',
|
||||
age: '',
|
||||
customerId: '',
|
||||
cryptoAddress: '',
|
||||
direction: '',
|
||||
fiat: '',
|
||||
fiatCode: ''
|
||||
}
|
||||
|
||||
function parseEventNote (event) {
|
||||
return _.set('note', JSON.parse(event.note), event)
|
||||
}
|
||||
|
||||
function checkPing(device) {
|
||||
const age = +Date.now() - +new Date(device.lastPing)
|
||||
if (age > NETWORK_DOWN_TIME)
|
||||
return [{ code: PING, age, machineName: device.name }]
|
||||
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 []
|
||||
}
|
||||
|
||||
|
|
@ -49,28 +61,7 @@ const shouldNotAlert = currentAlertFingerprint => {
|
|||
)
|
||||
}
|
||||
|
||||
function getAlertTypes(alertRec, config) {
|
||||
let alerts = []
|
||||
if (!isActive(config)) return alerts
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.keys(alertRec.devices).forEach(function (device) {
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||
}
|
||||
|
||||
if (config.errors) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
}
|
||||
})
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
function buildAlertFingerprint(alertRec, notifications) {
|
||||
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
|
||||
|
|
@ -82,7 +73,7 @@ function buildAlertFingerprint(alertRec, notifications) {
|
|||
return crypto.createHash('sha256').update(subject).digest('hex')
|
||||
}
|
||||
|
||||
function sendNoAlerts(plugins, smsEnabled, emailEnabled) {
|
||||
function sendNoAlerts (plugins, smsEnabled, emailEnabled) {
|
||||
const subject = '[Lamassu] All clear'
|
||||
|
||||
let rec = {}
|
||||
|
|
@ -117,8 +108,8 @@ const buildTransactionMessage = (tx, rec, highValueTx, machineName, customer) =>
|
|||
status = !isCashOut
|
||||
? 'Successful'
|
||||
: !rec.isRedemption
|
||||
? 'Successful & awaiting redemption'
|
||||
: 'Successful & dispensed'
|
||||
? 'Successful & awaiting redemption'
|
||||
: 'Successful & dispensed'
|
||||
}
|
||||
|
||||
const body = `
|
||||
|
|
@ -145,7 +136,7 @@ const buildTransactionMessage = (tx, rec, highValueTx, machineName, customer) =>
|
|||
}, highValueTx]
|
||||
}
|
||||
|
||||
function formatCurrency(num, code) {
|
||||
function formatCurrency (num, code) {
|
||||
return numeral(num).format('0,0.00') + ' ' + code
|
||||
}
|
||||
|
||||
|
|
@ -153,6 +144,42 @@ 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,
|
||||
|
|
@ -167,5 +194,7 @@ module.exports = {
|
|||
sendNoAlerts,
|
||||
buildTransactionMessage,
|
||||
formatCurrency,
|
||||
formatAge
|
||||
formatAge,
|
||||
buildDetail,
|
||||
deviceAlerts
|
||||
}
|
||||
|
|
|
|||
424
lib/plugins.js
424
lib/plugins.js
|
|
@ -23,7 +23,7 @@ const coinUtils = require('./coin-utils')
|
|||
const commissionMath = require('./commission-math')
|
||||
const promoCodes = require('./promo-codes')
|
||||
|
||||
const notifier = require('./notifier/index')
|
||||
const notifier = require('./notifier')
|
||||
|
||||
const mapValuesWithKey = _.mapValues.convert({
|
||||
cap: false
|
||||
|
|
@ -55,8 +55,7 @@ function plugins (settings, deviceId) {
|
|||
? undefined
|
||||
: BN(1).add(BN(commissions.cashOut).div(100))
|
||||
|
||||
if (Date.now() - rateRec.timestamp > STALE_TICKER)
|
||||
return logger.warn('Stale rate for ' + cryptoCode)
|
||||
if (Date.now() - rateRec.timestamp > STALE_TICKER) return logger.warn('Stale rate for ' + cryptoCode)
|
||||
const rate = rateRec.rates
|
||||
|
||||
withCommission ? rates[cryptoCode] = {
|
||||
|
|
@ -90,10 +89,8 @@ function plugins (settings, deviceId) {
|
|||
|
||||
cryptoCodes.forEach((cryptoCode, i) => {
|
||||
const balanceRec = balanceRecs[i]
|
||||
if (!balanceRec)
|
||||
return logger.warn('No balance for ' + cryptoCode + ' yet')
|
||||
if (Date.now() - balanceRec.timestamp > STALE_BALANCE)
|
||||
return logger.warn('Stale balance for ' + cryptoCode)
|
||||
if (!balanceRec) return logger.warn('No balance for ' + cryptoCode + ' yet')
|
||||
if (Date.now() - balanceRec.timestamp > STALE_BALANCE) return logger.warn('Stale balance for ' + cryptoCode)
|
||||
|
||||
balances[cryptoCode] = balanceRec.balance
|
||||
})
|
||||
|
|
@ -113,13 +110,10 @@ function plugins (settings, deviceId) {
|
|||
const sumTxs = (sum, tx) => {
|
||||
const bills = tx.bills
|
||||
const sameDenominations = a => a[0].denomination === a[1].denomination
|
||||
const doDenominationsMatch = _.every(
|
||||
sameDenominations,
|
||||
_.zip(cassettes, bills)
|
||||
)
|
||||
const doDenominationsMatch = _.every(sameDenominations, _.zip(cassettes, bills))
|
||||
|
||||
if (!doDenominationsMatch) {
|
||||
throw new Error("Denominations don't add up, cassettes were changed.")
|
||||
throw new Error('Denominations don\'t add up, cassettes were changed.')
|
||||
}
|
||||
|
||||
return _.map(r => r[0] + r[1].provisioned, _.zip(sum, tx.bills))
|
||||
|
|
@ -162,41 +156,30 @@ function plugins (settings, deviceId) {
|
|||
? argv.cassettes.split(',')
|
||||
: rec.counts
|
||||
|
||||
return Promise.all([
|
||||
dbm.cassetteCounts(deviceId),
|
||||
cashOutHelper.redeemableTxs(deviceId, excludeTxId)
|
||||
]).then(([rec, _redeemableTxs]) => {
|
||||
const redeemableTxs = _.reject(
|
||||
_.matchesProperty('id', excludeTxId),
|
||||
_redeemableTxs
|
||||
)
|
||||
const cassettes = [
|
||||
{
|
||||
denomination: parseInt(denominations[0], 10),
|
||||
count: parseInt(counts[0], 10)
|
||||
},
|
||||
{
|
||||
denomination: parseInt(denominations[1], 10),
|
||||
count: parseInt(counts[1], 10)
|
||||
}
|
||||
]
|
||||
|
||||
const counts = argv.cassettes ? argv.cassettes.split(',') : rec.counts
|
||||
|
||||
const cassettes = [
|
||||
{
|
||||
denomination: parseInt(denominations[0], 10),
|
||||
count: parseInt(counts[0], 10)
|
||||
},
|
||||
{
|
||||
denomination: parseInt(denominations[1], 10),
|
||||
count: parseInt(counts[1], 10)
|
||||
try {
|
||||
return {
|
||||
cassettes: computeAvailableCassettes(cassettes, redeemableTxs),
|
||||
virtualCassettes
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err)
|
||||
return {
|
||||
cassettes,
|
||||
virtualCassettes
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
try {
|
||||
return {
|
||||
cassettes: computeAvailableCassettes(cassettes, redeemableTxs),
|
||||
virtualCassettes
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err)
|
||||
return {
|
||||
cassettes,
|
||||
virtualCassettes
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function fetchCurrentConfigVersion () {
|
||||
|
|
@ -206,23 +189,18 @@ function plugins (settings, deviceId) {
|
|||
order by id desc
|
||||
limit 1`
|
||||
|
||||
return db.one(sql, ['config']).then(row => row.id)
|
||||
return db.one(sql, ['config'])
|
||||
.then(row => row.id)
|
||||
}
|
||||
|
||||
function mapCoinSettings (coinParams) {
|
||||
const cryptoCode = coinParams[0]
|
||||
const cryptoNetwork = coinParams[1]
|
||||
const commissions = configManager.getCommissions(
|
||||
cryptoCode,
|
||||
deviceId,
|
||||
settings.config
|
||||
)
|
||||
const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config)
|
||||
const minimumTx = BN(commissions.minimumTx)
|
||||
const cashInFee = BN(commissions.fixedFee)
|
||||
const cashInCommission = BN(commissions.cashIn)
|
||||
const cashOutCommission = _.isNumber(commissions.cashOut)
|
||||
? BN(commissions.cashOut)
|
||||
: null
|
||||
const cashOutCommission = _.isNumber(commissions.cashOut) ? BN(commissions.cashOut) : null
|
||||
const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode)
|
||||
|
||||
return {
|
||||
|
|
@ -236,25 +214,15 @@ function plugins (settings, deviceId) {
|
|||
}
|
||||
}
|
||||
|
||||
function pollQueries (
|
||||
serialNumber,
|
||||
deviceTime,
|
||||
deviceRec,
|
||||
machineVersion,
|
||||
machineModel
|
||||
) {
|
||||
function pollQueries (serialNumber, deviceTime, deviceRec, machineVersion, machineModel) {
|
||||
const localeConfig = configManager.getLocale(deviceId, settings.config)
|
||||
|
||||
const fiatCode = localeConfig.fiatCurrency
|
||||
const cryptoCodes = localeConfig.cryptoCurrencies
|
||||
|
||||
const tickerPromises = cryptoCodes.map(c =>
|
||||
ticker.getRates(settings, fiatCode, c)
|
||||
)
|
||||
const tickerPromises = cryptoCodes.map(c => ticker.getRates(settings, fiatCode, c))
|
||||
const balancePromises = cryptoCodes.map(c => fiatBalance(fiatCode, c))
|
||||
const testnetPromises = cryptoCodes.map(c =>
|
||||
wallet.cryptoNetwork(settings, c)
|
||||
)
|
||||
const testnetPromises = cryptoCodes.map(c => wallet.cryptoNetwork(settings, c))
|
||||
const pingPromise = recordPing(deviceTime, machineVersion, machineModel)
|
||||
const currentConfigVersionPromise = fetchCurrentConfigVersion()
|
||||
const currentAvailablePromoCodes = promoCodes.getNumberOfAvailablePromoCodes()
|
||||
|
|
@ -289,12 +257,7 @@ function plugins (settings, deviceId) {
|
|||
}
|
||||
|
||||
function sendCoins (tx) {
|
||||
return wallet.sendCoins(
|
||||
settings,
|
||||
tx.toAddress,
|
||||
tx.cryptoAtoms,
|
||||
tx.cryptoCode
|
||||
)
|
||||
return wallet.sendCoins(settings, tx.toAddress, tx.cryptoAtoms, tx.cryptoCode)
|
||||
}
|
||||
|
||||
function recordPing (deviceTime, version, model) {
|
||||
|
|
@ -305,18 +268,11 @@ function plugins (settings, deviceId) {
|
|||
}
|
||||
|
||||
return Promise.all([
|
||||
db.none(
|
||||
`insert into machine_pings(device_id, device_time) values($1, $2)
|
||||
ON CONFLICT (device_id) DO UPDATE SET device_time = $2, updated = now()`,
|
||||
[deviceId, deviceTime]
|
||||
),
|
||||
db.none(
|
||||
pgp.helpers.update(devices, null, 'devices') +
|
||||
'WHERE device_id = ${deviceId}',
|
||||
{
|
||||
deviceId
|
||||
}
|
||||
)
|
||||
db.none(`insert into machine_pings(device_id, device_time) values($1, $2)
|
||||
ON CONFLICT (device_id) DO UPDATE SET device_time = $2, updated = now()`, [deviceId, deviceTime]),
|
||||
db.none(pgp.helpers.update(devices, null, 'devices') + 'WHERE device_id = ${deviceId}', {
|
||||
deviceId
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
|
|
@ -348,37 +304,34 @@ function plugins (settings, deviceId) {
|
|||
}
|
||||
|
||||
function fiatBalance (fiatCode, cryptoCode) {
|
||||
const commissions = configManager.getCommissions(
|
||||
cryptoCode,
|
||||
deviceId,
|
||||
settings.config
|
||||
)
|
||||
const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config)
|
||||
return Promise.all([
|
||||
ticker.getRates(settings, fiatCode, cryptoCode),
|
||||
wallet.balance(settings, cryptoCode)
|
||||
]).then(([rates, balanceRec]) => {
|
||||
if (!rates || !balanceRec) return null
|
||||
])
|
||||
.then(([rates, balanceRec]) => {
|
||||
if (!rates || !balanceRec) return null
|
||||
|
||||
const rawRate = rates.rates.ask
|
||||
const cashInCommission = BN(1).minus(BN(commissions.cashIn).div(100))
|
||||
const balance = balanceRec.balance
|
||||
const rawRate = rates.rates.ask
|
||||
const cashInCommission = BN(1).minus(BN(commissions.cashIn).div(100))
|
||||
const balance = balanceRec.balance
|
||||
|
||||
if (!rawRate || !balance) return null
|
||||
if (!rawRate || !balance) return null
|
||||
|
||||
const rate = rawRate.div(cashInCommission)
|
||||
const rate = rawRate.div(cashInCommission)
|
||||
|
||||
const lowBalanceMargin = BN(1.03)
|
||||
const lowBalanceMargin = BN(1.03)
|
||||
|
||||
const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode)
|
||||
const unitScale = cryptoRec.unitScale
|
||||
const shiftedRate = rate.shift(-unitScale)
|
||||
const fiatTransferBalance = balance.mul(shiftedRate).div(lowBalanceMargin)
|
||||
const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode)
|
||||
const unitScale = cryptoRec.unitScale
|
||||
const shiftedRate = rate.shift(-unitScale)
|
||||
const fiatTransferBalance = balance.mul(shiftedRate).div(lowBalanceMargin)
|
||||
|
||||
return {
|
||||
timestamp: balanceRec.timestamp,
|
||||
balance: fiatTransferBalance.truncated().toString()
|
||||
}
|
||||
})
|
||||
return {
|
||||
timestamp: balanceRec.timestamp,
|
||||
balance: fiatTransferBalance.truncated().toString()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function notifyConfirmation (tx) {
|
||||
|
|
@ -393,12 +346,13 @@ function plugins (settings, deviceId) {
|
|||
}
|
||||
}
|
||||
|
||||
return sms.sendMessage(settings, rec).then(() => {
|
||||
const sql = 'update cash_out_txs set notified=$1 where id=$2'
|
||||
const values = [true, tx.id]
|
||||
return sms.sendMessage(settings, rec)
|
||||
.then(() => {
|
||||
const sql = 'update cash_out_txs set notified=$1 where id=$2'
|
||||
const values = [true, tx.id]
|
||||
|
||||
return db.none(sql, values)
|
||||
})
|
||||
return db.none(sql, values)
|
||||
})
|
||||
}
|
||||
|
||||
function notifyOperator (tx, rec) {
|
||||
|
|
@ -407,17 +361,14 @@ function plugins (settings, deviceId) {
|
|||
}
|
||||
|
||||
function clearOldLogs () {
|
||||
return logs.clearOldLogs().catch(logger.error)
|
||||
return logs.clearOldLogs()
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
function pong () {
|
||||
return db
|
||||
.none(
|
||||
`UPDATE server_events SET created=now() WHERE event_type=$1;
|
||||
return db.none(`UPDATE server_events SET created=now() WHERE event_type=$1;
|
||||
INSERT INTO server_events (event_type) SELECT $1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM server_events WHERE event_type=$1);`,
|
||||
['ping']
|
||||
)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM server_events WHERE event_type=$1);`, ['ping'])
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
|
|
@ -458,18 +409,15 @@ function plugins (settings, deviceId) {
|
|||
const marketTradesQueues = tradesQueues[market]
|
||||
if (!marketTradesQueues || marketTradesQueues.length === 0) return null
|
||||
|
||||
logger.debug(
|
||||
'[%s] tradesQueues size: %d',
|
||||
market,
|
||||
marketTradesQueues.length
|
||||
)
|
||||
logger.debug('[%s] tradesQueues size: %d', market, marketTradesQueues.length)
|
||||
logger.debug('[%s] tradesQueues head: %j', market, marketTradesQueues[0])
|
||||
|
||||
const t1 = Date.now()
|
||||
|
||||
const filtered = marketTradesQueues.filter(tradeEntry => {
|
||||
return t1 - tradeEntry.timestamp < TRADE_TTL
|
||||
})
|
||||
const filtered = marketTradesQueues
|
||||
.filter(tradeEntry => {
|
||||
return t1 - tradeEntry.timestamp < TRADE_TTL
|
||||
})
|
||||
|
||||
const filteredCount = marketTradesQueues.length - filtered.length
|
||||
|
||||
|
|
@ -480,14 +428,10 @@ function plugins (settings, deviceId) {
|
|||
|
||||
if (filtered.length === 0) return null
|
||||
|
||||
const cryptoAtoms = filtered.reduce(
|
||||
(prev, current) => prev.plus(current.cryptoAtoms),
|
||||
BN(0)
|
||||
)
|
||||
const cryptoAtoms = filtered
|
||||
.reduce((prev, current) => prev.plus(current.cryptoAtoms), BN(0))
|
||||
|
||||
const timestamp = filtered
|
||||
.map(r => r.timestamp)
|
||||
.reduce((acc, r) => Math.max(acc, r), 0)
|
||||
const timestamp = filtered.map(r => r.timestamp).reduce((acc, r) => Math.max(acc, r), 0)
|
||||
|
||||
const consolidatedTrade = {
|
||||
fiatCode,
|
||||
|
|
@ -503,15 +447,11 @@ function plugins (settings, deviceId) {
|
|||
}
|
||||
|
||||
function executeTrades () {
|
||||
return machineLoader
|
||||
.getMachines()
|
||||
return machineLoader.getMachines()
|
||||
.then(devices => {
|
||||
const deviceIds = devices.map(device => device.deviceId)
|
||||
const lists = deviceIds.map(deviceId => {
|
||||
const localeConfig = configManager.getLocale(
|
||||
deviceId,
|
||||
settings.config
|
||||
)
|
||||
const localeConfig = configManager.getLocale(deviceId, settings.config)
|
||||
const fiatCode = localeConfig.fiatCurrency
|
||||
const cryptoCodes = localeConfig.cryptoCurrencies
|
||||
|
||||
|
|
@ -521,9 +461,8 @@ function plugins (settings, deviceId) {
|
|||
}))
|
||||
})
|
||||
|
||||
const tradesPromises = _.uniq(_.flatten(lists)).map(r =>
|
||||
executeTradesForMarket(settings, r.fiatCode, r.cryptoCode)
|
||||
)
|
||||
const tradesPromises = _.uniq(_.flatten(lists))
|
||||
.map(r => executeTradesForMarket(settings, r.fiatCode, r.cryptoCode))
|
||||
|
||||
return Promise.all(tradesPromises)
|
||||
})
|
||||
|
|
@ -538,43 +477,41 @@ function plugins (settings, deviceId) {
|
|||
|
||||
if (tradeEntry === null || tradeEntry.cryptoAtoms.eq(0)) return
|
||||
|
||||
return executeTradeForType(tradeEntry).catch(err => {
|
||||
tradesQueues[market].push(tradeEntry)
|
||||
if (err.name === 'orderTooSmall') return logger.debug(err.message)
|
||||
logger.error(err)
|
||||
})
|
||||
return executeTradeForType(tradeEntry)
|
||||
.catch(err => {
|
||||
tradesQueues[market].push(tradeEntry)
|
||||
if (err.name === 'orderTooSmall') return logger.debug(err.message)
|
||||
logger.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
function executeTradeForType (_tradeEntry) {
|
||||
const expand = te =>
|
||||
_.assign(te, {
|
||||
cryptoAtoms: te.cryptoAtoms.abs(),
|
||||
type: te.cryptoAtoms.gte(0) ? 'buy' : 'sell'
|
||||
})
|
||||
const expand = te => _.assign(te, {
|
||||
cryptoAtoms: te.cryptoAtoms.abs(),
|
||||
type: te.cryptoAtoms.gte(0) ? 'buy' : 'sell'
|
||||
})
|
||||
|
||||
const tradeEntry = expand(_tradeEntry)
|
||||
const execute = tradeEntry.type === 'buy' ? exchange.buy : exchange.sell
|
||||
|
||||
return execute(
|
||||
settings,
|
||||
tradeEntry.cryptoAtoms,
|
||||
tradeEntry.fiatCode,
|
||||
tradeEntry.cryptoCode
|
||||
)
|
||||
return execute(settings, tradeEntry.cryptoAtoms, tradeEntry.fiatCode, tradeEntry.cryptoCode)
|
||||
.then(() => recordTrade(tradeEntry))
|
||||
.catch(err => {
|
||||
return recordTrade(tradeEntry, err).then(() => {
|
||||
throw err
|
||||
})
|
||||
return recordTrade(tradeEntry, err)
|
||||
.then(() => {
|
||||
throw err
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function convertBigNumFields (obj) {
|
||||
const convert = (value, key) =>
|
||||
_.includes(key, ['cryptoAtoms', 'fiat']) ? value.toString() : value
|
||||
const convert = (value, key) => _.includes(key, ['cryptoAtoms', 'fiat'])
|
||||
? value.toString()
|
||||
: value
|
||||
|
||||
const convertKey = key =>
|
||||
_.includes(key, ['cryptoAtoms', 'fiat']) ? key + '#' : key
|
||||
const convertKey = key => _.includes(key, ['cryptoAtoms', 'fiat'])
|
||||
? key + '#'
|
||||
: key
|
||||
|
||||
return _.mapKeys(convertKey, mapValuesWithKey(convert, obj))
|
||||
}
|
||||
|
|
@ -604,10 +541,8 @@ function plugins (settings, deviceId) {
|
|||
const notifications = configManager.getGlobalNotifications(settings.config)
|
||||
|
||||
let promises = []
|
||||
if (notifications.email.active && rec.email)
|
||||
promises.push(email.sendMessage(settings, rec))
|
||||
if (notifications.sms.active && rec.sms)
|
||||
promises.push(sms.sendMessage(settings, rec))
|
||||
if (notifications.email.active && rec.email) promises.push(email.sendMessage(settings, rec))
|
||||
if (notifications.sms.active && rec.sms) promises.push(sms.sendMessage(settings, rec))
|
||||
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
|
@ -617,64 +552,53 @@ function plugins (settings, deviceId) {
|
|||
}
|
||||
|
||||
function checkDeviceCashBalances (fiatCode, device) {
|
||||
const cashOutConfig = configManager.getCashOut(
|
||||
device.deviceId,
|
||||
settings.config
|
||||
)
|
||||
const cashOutConfig = configManager.getCashOut(device.deviceId, settings.config)
|
||||
const denomination1 = cashOutConfig.top
|
||||
const denomination2 = cashOutConfig.bottom
|
||||
const cashOutEnabled = cashOutConfig.active
|
||||
|
||||
const notifications = configManager.getNotifications(
|
||||
null,
|
||||
device.deviceId,
|
||||
settings.config
|
||||
)
|
||||
const notifications = configManager.getNotifications(null, device.deviceId, settings.config)
|
||||
|
||||
const machineName = device.name
|
||||
|
||||
const cashInAlert =
|
||||
device.cashbox > notifications.cashInAlertThreshold
|
||||
? {
|
||||
code: 'CASH_BOX_FULL',
|
||||
machineName,
|
||||
deviceId: device.deviceId,
|
||||
notes: device.cashbox
|
||||
}
|
||||
: null
|
||||
const cashInAlert = device.cashbox > notifications.cashInAlertThreshold
|
||||
? {
|
||||
code: 'CASH_BOX_FULL',
|
||||
machineName,
|
||||
deviceId: device.deviceId,
|
||||
notes: device.cashbox
|
||||
}
|
||||
: null
|
||||
|
||||
const cassette1Alert =
|
||||
cashOutEnabled && device.cassette1 < notifications.fiatBalanceCassette1
|
||||
? {
|
||||
code: 'LOW_CASH_OUT',
|
||||
cassette: 1,
|
||||
machineName,
|
||||
deviceId: device.deviceId,
|
||||
notes: device.cassette1,
|
||||
denomination: denomination1,
|
||||
fiatCode
|
||||
}
|
||||
: null
|
||||
const cassette1Alert = cashOutEnabled && device.cassette1 < notifications.fiatBalanceCassette1
|
||||
? {
|
||||
code: 'LOW_CASH_OUT',
|
||||
cassette: 1,
|
||||
machineName,
|
||||
deviceId: device.deviceId,
|
||||
notes: device.cassette1,
|
||||
denomination: denomination1,
|
||||
fiatCode
|
||||
}
|
||||
: null
|
||||
|
||||
const cassette2Alert =
|
||||
cashOutEnabled && device.cassette2 < notifications.fiatBalanceCassette2
|
||||
? {
|
||||
code: 'LOW_CASH_OUT',
|
||||
cassette: 2,
|
||||
machineName,
|
||||
deviceId: device.deviceId,
|
||||
notes: device.cassette2,
|
||||
denomination: denomination2,
|
||||
fiatCode
|
||||
}
|
||||
: null
|
||||
const cassette2Alert = cashOutEnabled && device.cassette2 < notifications.fiatBalanceCassette2
|
||||
? {
|
||||
code: 'LOW_CASH_OUT',
|
||||
cassette: 2,
|
||||
machineName,
|
||||
deviceId: device.deviceId,
|
||||
notes: device.cassette2,
|
||||
denomination: denomination2,
|
||||
fiatCode
|
||||
}
|
||||
: null
|
||||
|
||||
return _.compact([cashInAlert, cassette1Alert, cassette2Alert])
|
||||
}
|
||||
|
||||
function checkCryptoBalances (fiatCode, devices) {
|
||||
const fiatBalancePromises = cryptoCodes =>
|
||||
_.map(c => fiatBalance(fiatCode, c), cryptoCodes)
|
||||
const fiatBalancePromises = cryptoCodes => _.map(c => fiatBalance(fiatCode, c), cryptoCodes)
|
||||
|
||||
const fetchCryptoCodes = _deviceId => {
|
||||
const localeConfig = configManager.getLocale(_deviceId, settings.config)
|
||||
|
|
@ -685,23 +609,18 @@ function plugins (settings, deviceId) {
|
|||
const cryptoCodes = union(devices)
|
||||
const checkCryptoBalanceWithFiat = _.partial(checkCryptoBalance, [fiatCode])
|
||||
|
||||
return Promise.all(fiatBalancePromises(cryptoCodes)).then(balances =>
|
||||
_.map(checkCryptoBalanceWithFiat, _.zip(cryptoCodes, balances))
|
||||
)
|
||||
return Promise.all(fiatBalancePromises(cryptoCodes))
|
||||
.then(balances => _.map(checkCryptoBalanceWithFiat, _.zip(cryptoCodes, balances)))
|
||||
}
|
||||
|
||||
function checkCryptoBalance (fiatCode, rec) {
|
||||
const [cryptoCode, fiatBalance] = rec
|
||||
|
||||
if (!fiatBalance) return null
|
||||
|
||||
const notifications = configManager.getNotifications(
|
||||
cryptoCode,
|
||||
null,
|
||||
settings.config
|
||||
)
|
||||
const lowAlertThreshold = notifications.cryptoLowBalance
|
||||
const highAlertThreshold = notifications.cryptoHighBalance
|
||||
const notifications = configManager.getNotifications(cryptoCode, null, settings.config)
|
||||
const override = _.find(override => override.cryptoCurrency === cryptoCode, settings.config.notifications_cryptoBalanceOverrides)
|
||||
const lowAlertThreshold = override ? override.lowBalance : notifications.cryptoLowBalance
|
||||
const highAlertThreshold = override ? override.highBalance : notifications.cryptoHighBalance
|
||||
|
||||
const req = {
|
||||
cryptoCode,
|
||||
|
|
@ -709,17 +628,13 @@ function plugins (settings, deviceId) {
|
|||
fiatCode
|
||||
}
|
||||
|
||||
if (
|
||||
_.isFinite(lowAlertThreshold) &&
|
||||
BN(fiatBalance.balance).lt(lowAlertThreshold)
|
||||
)
|
||||
if (_.isFinite(lowAlertThreshold) && BN(fiatBalance.balance).lt(lowAlertThreshold)) {
|
||||
return _.set('code')('LOW_CRYPTO_BALANCE')(req)
|
||||
}
|
||||
|
||||
if (
|
||||
_.isFinite(highAlertThreshold) &&
|
||||
BN(fiatBalance.balance).gt(highAlertThreshold)
|
||||
)
|
||||
if (_.isFinite(highAlertThreshold) && BN(fiatBalance.balance).gt(highAlertThreshold)) {
|
||||
return _.set('code')('HIGH_CRYPTO_BALANCE')(req)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -728,23 +643,24 @@ function plugins (settings, deviceId) {
|
|||
const localeConfig = configManager.getGlobalLocale(settings.config)
|
||||
const fiatCode = localeConfig.fiatCurrency
|
||||
|
||||
return machineLoader.getMachines().then(devices => {
|
||||
return Promise.all([
|
||||
checkCryptoBalances(fiatCode, devices),
|
||||
checkDevicesCashBalances(fiatCode, devices)
|
||||
]).then(_.flow(_.flattenDeep, _.compact))
|
||||
})
|
||||
return machineLoader.getMachines()
|
||||
.then(devices => {
|
||||
return Promise.all([
|
||||
checkCryptoBalances(fiatCode, devices),
|
||||
checkDevicesCashBalances(fiatCode, devices)
|
||||
])
|
||||
.then(_.flow(_.flattenDeep, _.compact))
|
||||
})
|
||||
}
|
||||
|
||||
function randomCode () {
|
||||
return BN(crypto.randomBytes(3).toString('hex'), 16)
|
||||
.shift(-6)
|
||||
.toFixed(6)
|
||||
.slice(-6)
|
||||
return BN(crypto.randomBytes(3).toString('hex'), 16).shift(-6).toFixed(6).slice(-6)
|
||||
}
|
||||
|
||||
function getPhoneCode (phone) {
|
||||
const code = argv.mockSms ? '123' : randomCode()
|
||||
const code = argv.mockSms
|
||||
? '123'
|
||||
: randomCode()
|
||||
|
||||
const rec = {
|
||||
sms: {
|
||||
|
|
@ -753,14 +669,14 @@ function plugins (settings, deviceId) {
|
|||
}
|
||||
}
|
||||
|
||||
return sms.sendMessage(settings, rec).then(() => code)
|
||||
return sms.sendMessage(settings, rec)
|
||||
.then(() => code)
|
||||
}
|
||||
|
||||
function sweepHdRow (row) {
|
||||
const cryptoCode = row.crypto_code
|
||||
|
||||
return wallet
|
||||
.sweep(settings, cryptoCode, row.hd_index)
|
||||
return wallet.sweep(settings, cryptoCode, row.hd_index)
|
||||
.then(txHash => {
|
||||
if (txHash) {
|
||||
logger.debug('[%s] Swept address with tx: %s', cryptoCode, txHash)
|
||||
|
|
@ -771,17 +687,14 @@ function plugins (settings, deviceId) {
|
|||
return db.none(sql, row.id)
|
||||
}
|
||||
})
|
||||
.catch(err =>
|
||||
logger.error('[%s] Sweep error: %s', cryptoCode, err.message)
|
||||
)
|
||||
.catch(err => logger.error('[%s] Sweep error: %s', cryptoCode, err.message))
|
||||
}
|
||||
|
||||
function sweepHd () {
|
||||
const sql = `select id, crypto_code, hd_index from cash_out_txs
|
||||
where hd_index is not null and not swept and status in ('confirmed', 'instant')`
|
||||
|
||||
return db
|
||||
.any(sql)
|
||||
return db.any(sql)
|
||||
.then(rows => Promise.all(rows.map(sweepHdRow)))
|
||||
.catch(err => logger.error(err))
|
||||
}
|
||||
|
|
@ -795,15 +708,14 @@ function plugins (settings, deviceId) {
|
|||
const fiatCode = localeConfig.fiatCurrency
|
||||
|
||||
const cryptoCodes = configManager.getAllCryptoCurrencies(settings.config)
|
||||
const tickerPromises = cryptoCodes.map(c =>
|
||||
ticker.getRates(settings, fiatCode, c)
|
||||
)
|
||||
const tickerPromises = cryptoCodes.map(c => ticker.getRates(settings, fiatCode, c))
|
||||
|
||||
return Promise.all(tickerPromises)
|
||||
}
|
||||
|
||||
function getRates () {
|
||||
return getRawRates().then(buildRates)
|
||||
return getRawRates()
|
||||
.then(buildRates)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const _ = require('lodash/fp')
|
|||
|
||||
exports.NAME = 'MockSMS'
|
||||
|
||||
exports.sendMessage = function sendMessage(account, rec) {
|
||||
exports.sendMessage = function sendMessage (account, rec) {
|
||||
console.log('Sending SMS: %j', rec)
|
||||
return new Promise((resolve, reject) => {
|
||||
if (_.endsWith('666', _.getOr(false, 'sms.toNumber', rec))) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const _ = require('lodash/fp')
|
||||
|
||||
const plugins = require('./plugins')
|
||||
const notifier = require('./notifier/index')
|
||||
const notifier = require('./notifier')
|
||||
const T = require('./time')
|
||||
const logger = require('./logger')
|
||||
const cashOutTx = require('./cash-out/cash-out-tx')
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const compliance = require('./compliance')
|
|||
const promoCodes = require('./promo-codes')
|
||||
const BN = require('./bn')
|
||||
const commissionMath = require('./commission-math')
|
||||
const notifier = require('./notifier/index')
|
||||
const notifier = require('./notifier')
|
||||
|
||||
const version = require('../package.json').version
|
||||
|
||||
|
|
@ -324,7 +324,6 @@ function updateCustomer (req, res, next) {
|
|||
}
|
||||
|
||||
function triggerSanctions (req, res, next) {
|
||||
console.log("SANCTIONS TRIGGERED")
|
||||
const id = req.params.id
|
||||
|
||||
customers.getById(id)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
var db = require('./db')
|
||||
|
||||
function singleQuotify(item) {
|
||||
return "'" + item + "'"
|
||||
}
|
||||
const singleQuotify = (item) => `'${item}'`
|
||||
|
||||
var types = [
|
||||
'highValueTransaction',
|
||||
|
|
@ -18,20 +16,17 @@ exports.up = function (next) {
|
|||
const sql = [
|
||||
`
|
||||
CREATE TYPE notification_type AS ENUM ${'(' + types + ')'};
|
||||
CREATE TABLE IF NOT EXISTS "notifications" (
|
||||
CREATE TABLE "notifications" (
|
||||
"id" uuid NOT NULL PRIMARY KEY,
|
||||
"type" notification_type NOT NULL,
|
||||
"detail" TEXT,
|
||||
"device_id" TEXT,
|
||||
"detail" JSONB,
|
||||
"message" TEXT NOT NULL,
|
||||
"created" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
"created" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"read" BOOLEAN NOT NULL DEFAULT 'false',
|
||||
"valid" BOOLEAN NOT NULL DEFAULT 'true',
|
||||
CONSTRAINT fk_devices
|
||||
FOREIGN KEY(device_id)
|
||||
REFERENCES devices(device_id)
|
||||
ON DELETE CASCADE
|
||||
"modified" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX ON notifications (valid);
|
||||
CREATE INDEX ON notifications (read);`
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
import { useQuery, useMutation } from '@apollo/react-hooks'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import gql from 'graphql-tag'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import ActionButton from 'src/components/buttons/ActionButton'
|
||||
import { H5 } from 'src/components/typography'
|
||||
import { ReactComponent as NotificationIconZodiac } from 'src/styling/icons/menu/notification-zodiac.svg'
|
||||
import { ReactComponent as ClearAllIconInverse } from 'src/styling/icons/stage/spring/empty.svg'
|
||||
import { ReactComponent as ClearAllIcon } from 'src/styling/icons/stage/zodiac/empty.svg'
|
||||
import { ReactComponent as ShowUnreadIcon } from 'src/styling/icons/stage/zodiac/full.svg'
|
||||
|
||||
import styles from './NotificationCenter.styles'
|
||||
import NotificationRow from './NotificationRow'
|
||||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const GET_NOTIFICATIONS = gql`
|
||||
query getNotifications {
|
||||
notifications {
|
||||
id
|
||||
deviceName
|
||||
type
|
||||
detail
|
||||
message
|
||||
created
|
||||
read
|
||||
valid
|
||||
}
|
||||
hasUnreadNotifications
|
||||
machines {
|
||||
deviceId
|
||||
name
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CLEAR_NOTIFICATION = gql`
|
||||
mutation clearNotification($id: ID!) {
|
||||
clearNotification(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CLEAR_ALL_NOTIFICATIONS = gql`
|
||||
mutation clearAllNotifications {
|
||||
clearAllNotifications {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const NotificationCenter = ({ close, notifyUnread }) => {
|
||||
const classes = useStyles()
|
||||
const { data, loading } = useQuery(GET_NOTIFICATIONS)
|
||||
const [showingUnread, setShowingUnread] = useState(false)
|
||||
const machines = R.compose(
|
||||
R.map(R.prop('name')),
|
||||
R.indexBy(R.prop('deviceId'))
|
||||
)(data?.machines ?? [])
|
||||
|
||||
const notifications = R.path(['notifications'])(data) ?? []
|
||||
const hasUnread = data?.hasUnreadNotifications ?? false
|
||||
if (!hasUnread) {
|
||||
notifyUnread && notifyUnread()
|
||||
}
|
||||
const [clearNotification] = useMutation(CLEAR_NOTIFICATION, {
|
||||
onError: () => console.error('Error while clearing notification'),
|
||||
refetchQueries: () => ['getNotifications']
|
||||
})
|
||||
const [clearAllNotifications] = useMutation(CLEAR_ALL_NOTIFICATIONS, {
|
||||
onError: () => console.error('Error while clearing all notifications'),
|
||||
refetchQueries: () => ['getNotifications']
|
||||
})
|
||||
|
||||
const handleClearNotification = id => {
|
||||
clearNotification({ variables: { id } })
|
||||
}
|
||||
|
||||
const buildNotifications = () => {
|
||||
const notificationsToShow =
|
||||
!showingUnread || !hasUnread
|
||||
? notifications
|
||||
: R.filter(R.propEq('read', false))(notifications)
|
||||
return notificationsToShow.map(n => {
|
||||
return (
|
||||
<NotificationRow
|
||||
key={n.id}
|
||||
id={n.id}
|
||||
type={n.type}
|
||||
detail={n.detail}
|
||||
message={n.message}
|
||||
deviceName={machines[n.detail.deviceId]}
|
||||
created={n.created}
|
||||
read={n.read}
|
||||
valid={n.valid}
|
||||
onClear={handleClearNotification}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.container}>
|
||||
<div className={classes.header}>
|
||||
<H5 className={classes.headerText}>Notifications</H5>
|
||||
<button onClick={close} className={classes.notificationIcon}>
|
||||
<NotificationIconZodiac />
|
||||
{hasUnread && <div className={classes.hasUnread} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className={classes.actionButtons}>
|
||||
{hasUnread && (
|
||||
<ActionButton
|
||||
color="primary"
|
||||
Icon={ShowUnreadIcon}
|
||||
InverseIcon={ClearAllIconInverse}
|
||||
className={classes.clearAllButton}
|
||||
onClick={() => setShowingUnread(!showingUnread)}>
|
||||
{showingUnread ? 'Show all' : 'Show unread'}
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
color="primary"
|
||||
Icon={ClearAllIcon}
|
||||
InverseIcon={ClearAllIconInverse}
|
||||
className={classes.clearAllButton}
|
||||
onClick={clearAllNotifications}>
|
||||
Mark all as read
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className={classes.notificationsList}>
|
||||
{!loading && buildNotifications()}
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.background} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationCenter
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import {
|
||||
spacer,
|
||||
white,
|
||||
zircon,
|
||||
secondaryColor,
|
||||
spring3,
|
||||
comet
|
||||
} from 'src/styling/variables'
|
||||
|
||||
const styles = {
|
||||
background: {
|
||||
position: 'absolute',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
left: 0,
|
||||
top: 0,
|
||||
zIndex: -1,
|
||||
backgroundColor: white,
|
||||
boxShadow: '0 0 14px 0 rgba(0, 0, 0, 0.24)'
|
||||
},
|
||||
container: {
|
||||
left: -200,
|
||||
top: -42,
|
||||
backgroundColor: white,
|
||||
height: '110vh'
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
headerText: {
|
||||
marginTop: spacer * 2.5,
|
||||
marginLeft: spacer * 3
|
||||
},
|
||||
actionButtons: {
|
||||
display: 'flex',
|
||||
marginLeft: spacer * 2
|
||||
},
|
||||
notificationIcon: {
|
||||
position: 'absolute',
|
||||
left: spacer * 33,
|
||||
top: spacer * 2 + 4,
|
||||
cursor: 'pointer',
|
||||
background: 'transparent',
|
||||
boxShadow: '0px 0px 0px transparent',
|
||||
border: '0px solid transparent',
|
||||
textShadow: '0px 0px 0px transparent',
|
||||
outline: 'none'
|
||||
},
|
||||
clearAllButton: {
|
||||
marginTop: -spacer * 2,
|
||||
marginLeft: spacer,
|
||||
backgroundColor: zircon
|
||||
},
|
||||
notificationsList: {
|
||||
width: 440,
|
||||
height: '90vh',
|
||||
maxHeight: '100vh',
|
||||
marginTop: 8,
|
||||
marginLeft: 0,
|
||||
marginRight: 10,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
backgroundColor: white,
|
||||
zIndex: 10
|
||||
},
|
||||
notificationRow: {
|
||||
position: 'relative',
|
||||
marginBottom: spacer / 2,
|
||||
paddingTop: spacer * 1.5
|
||||
},
|
||||
unread: {
|
||||
backgroundColor: spring3
|
||||
},
|
||||
notificationRowIcon: {
|
||||
alignSelf: 'center',
|
||||
'& > *': {
|
||||
marginLeft: spacer * 3
|
||||
}
|
||||
},
|
||||
unreadIcon: {
|
||||
marginLeft: spacer,
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: secondaryColor,
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
zIndex: 1
|
||||
},
|
||||
notificationTitle: {
|
||||
margin: 0,
|
||||
color: comet
|
||||
},
|
||||
notificationBody: {
|
||||
margin: 0
|
||||
},
|
||||
notificationSubtitle: {
|
||||
margin: 0,
|
||||
marginBottom: spacer,
|
||||
color: comet
|
||||
},
|
||||
stripes: {
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
top: '0px',
|
||||
opacity: '60%'
|
||||
},
|
||||
hasUnread: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 16,
|
||||
width: '9px',
|
||||
height: '9px',
|
||||
backgroundColor: secondaryColor,
|
||||
borderRadius: '50%'
|
||||
}
|
||||
}
|
||||
|
||||
export default styles
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import Grid from '@material-ui/core/Grid'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import classnames from 'classnames'
|
||||
import prettyMs from 'pretty-ms'
|
||||
import React from 'react'
|
||||
|
||||
import { Label1, Label2, TL2 } from 'src/components/typography'
|
||||
import { ReactComponent as Wrench } from 'src/styling/icons/action/wrench/zodiac.svg'
|
||||
import { ReactComponent as Transaction } from 'src/styling/icons/arrow/transaction.svg'
|
||||
import { ReactComponent as StripesSvg } from 'src/styling/icons/stripes.svg'
|
||||
import { ReactComponent as WarningIcon } from 'src/styling/icons/warning-icon/tomato.svg'
|
||||
|
||||
import styles from './NotificationCenter.styles'
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const types = {
|
||||
highValueTransaction: { display: 'Transactions', icon: <Transaction /> },
|
||||
fiatBalance: { display: 'Maintenance', icon: <Wrench /> },
|
||||
cryptoBalance: { display: 'Maintenance', icon: <Wrench /> },
|
||||
compliance: { display: 'Compliance', icon: <WarningIcon /> },
|
||||
error: { display: 'Error', icon: <WarningIcon /> }
|
||||
}
|
||||
|
||||
const NotificationRow = ({
|
||||
id,
|
||||
type,
|
||||
detail,
|
||||
message,
|
||||
deviceName,
|
||||
created,
|
||||
read,
|
||||
valid,
|
||||
onClear
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
|
||||
const buildType = () => {
|
||||
return types[type].display
|
||||
}
|
||||
|
||||
const buildAge = () => {
|
||||
const createdDate = new Date(created)
|
||||
const interval = +new Date() - createdDate
|
||||
return prettyMs(interval, { compact: true, verbose: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
className={classnames(
|
||||
classes.notificationRow,
|
||||
!read && valid ? classes.unread : ''
|
||||
)}>
|
||||
<Grid item xs={2} className={classes.notificationRowIcon}>
|
||||
{types[type].icon}
|
||||
</Grid>
|
||||
<Grid item container xs={7} direction="row">
|
||||
<Grid item xs={12}>
|
||||
<Label2 className={classes.notificationTitle}>
|
||||
{`${buildType()} ${deviceName ? '- ' + deviceName : ''}`}
|
||||
</Label2>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TL2 className={classes.notificationBody}>{message}</TL2>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Label1 className={classes.notificationSubtitle}>
|
||||
{buildAge(created)}
|
||||
</Label1>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={3} style={{ zIndex: 1 }}>
|
||||
{!read && (
|
||||
<div onClick={() => onClear(id)} className={classes.unreadIcon} />
|
||||
)}
|
||||
</Grid>
|
||||
{!valid && <StripesSvg className={classes.stripes} />}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationRow
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import NotificationCenter from './NotificationCenter'
|
||||
export default NotificationCenter
|
||||
|
|
@ -1,19 +1,31 @@
|
|||
import { useQuery } from '@apollo/react-hooks'
|
||||
import ClickAwayListener from '@material-ui/core/ClickAwayListener'
|
||||
import Popper from '@material-ui/core/Popper'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import classnames from 'classnames'
|
||||
import gql from 'graphql-tag'
|
||||
import React, { memo, useState } from 'react'
|
||||
import { NavLink, useHistory } from 'react-router-dom'
|
||||
|
||||
import NotificationCenter from 'src/components/NotificationCenter'
|
||||
import ActionButton from 'src/components/buttons/ActionButton'
|
||||
import { H4 } from 'src/components/typography'
|
||||
import AddMachine from 'src/pages/AddMachine'
|
||||
import { ReactComponent as AddIconReverse } from 'src/styling/icons/button/add/white.svg'
|
||||
import { ReactComponent as AddIcon } from 'src/styling/icons/button/add/zodiac.svg'
|
||||
import { ReactComponent as Logo } from 'src/styling/icons/menu/logo.svg'
|
||||
import { ReactComponent as NotificationIcon } from 'src/styling/icons/menu/notification.svg'
|
||||
|
||||
import styles from './Header.styles'
|
||||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const HAS_UNREAD = gql`
|
||||
query getUnread {
|
||||
hasUnreadNotifications
|
||||
}
|
||||
`
|
||||
|
||||
const Subheader = ({ item, classes }) => {
|
||||
const [prev, setPrev] = useState(null)
|
||||
|
||||
|
|
@ -46,8 +58,10 @@ const Subheader = ({ item, classes }) => {
|
|||
|
||||
const Header = memo(({ tree }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [anchorEl, setAnchorEl] = React.useState(null)
|
||||
const [active, setActive] = useState()
|
||||
|
||||
const { data, refetch } = useQuery(HAS_UNREAD)
|
||||
const hasUnread = data?.hasUnreadNotifications ?? false
|
||||
const history = useHistory()
|
||||
const classes = useStyles()
|
||||
|
||||
|
|
@ -56,8 +70,26 @@ const Header = memo(({ tree }) => {
|
|||
history.push('/maintenance/machine-status', { id: machine.deviceId })
|
||||
}
|
||||
|
||||
// these inline styles prevent scroll bubbling: when the user reaches the bottom of the notifications list and keeps scrolling,
|
||||
// the body scrolls, stealing the focus from the notification center, preventing the admin from scrolling the notifications back up
|
||||
// on the first scroll, needing to move the mouse to recapture the focus on the notification center
|
||||
// it also disables the scrollbars caused by the notification center's background to the right of the page, but keeps the scrolling on the body enabled
|
||||
const onClickAway = () => {
|
||||
setAnchorEl(null)
|
||||
document.querySelector('#root').classList.remove('root-notifcenter-open')
|
||||
document.querySelector('body').classList.remove('body-notifcenter-open')
|
||||
}
|
||||
|
||||
const handleClick = event => {
|
||||
setAnchorEl(anchorEl ? null : event.currentTarget)
|
||||
document.querySelector('#root').classList.add('root-notifcenter-open')
|
||||
document.querySelector('body').classList.add('body-notifcenter-open')
|
||||
}
|
||||
|
||||
const popperOpen = Boolean(anchorEl)
|
||||
const id = popperOpen ? 'notifications-popper' : undefined
|
||||
return (
|
||||
<header>
|
||||
<header className={classes.headerContainer}>
|
||||
<div className={classes.header}>
|
||||
<div className={classes.content}>
|
||||
<div
|
||||
|
|
@ -87,6 +119,8 @@ const Header = memo(({ tree }) => {
|
|||
</NavLink>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className={classes.actionButtonsContainer}>
|
||||
<ActionButton
|
||||
color="secondary"
|
||||
Icon={AddIcon}
|
||||
|
|
@ -94,7 +128,34 @@ const Header = memo(({ tree }) => {
|
|||
onClick={() => setOpen(true)}>
|
||||
Add machine
|
||||
</ActionButton>
|
||||
</nav>
|
||||
<ClickAwayListener onClickAway={onClickAway}>
|
||||
<div>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={classes.notificationIcon}>
|
||||
<NotificationIcon />
|
||||
{hasUnread && <div className={classes.hasUnread} />}
|
||||
</button>
|
||||
<Popper
|
||||
id={id}
|
||||
open={popperOpen}
|
||||
anchorEl={anchorEl}
|
||||
className={classes.popper}
|
||||
disablePortal={false}
|
||||
modifiers={{
|
||||
preventOverflow: {
|
||||
enabled: true,
|
||||
boundariesElement: 'viewport'
|
||||
}
|
||||
}}>
|
||||
<NotificationCenter
|
||||
close={onClickAway}
|
||||
notifyUnread={refetch}
|
||||
/>
|
||||
</Popper>
|
||||
</div>
|
||||
</ClickAwayListener>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{active && active.children && (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
spacer,
|
||||
white,
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
placeholderColor,
|
||||
subheaderColor,
|
||||
fontColor
|
||||
|
|
@ -21,6 +22,9 @@ if (version === 8) {
|
|||
}
|
||||
|
||||
const styles = {
|
||||
headerContainer: {
|
||||
position: 'relative'
|
||||
},
|
||||
header: {
|
||||
backgroundColor: primaryColor,
|
||||
color: white,
|
||||
|
|
@ -80,27 +84,6 @@ const styles = {
|
|||
border: 'none',
|
||||
color: white,
|
||||
backgroundColor: 'transparent'
|
||||
// '&:hover': {
|
||||
// color: white
|
||||
// },
|
||||
// '&:hover::after': {
|
||||
// width: '50%',
|
||||
// marginLeft: '-25%'
|
||||
// },
|
||||
// position: 'relative',
|
||||
// '&:after': {
|
||||
// content: '""',
|
||||
// display: 'block',
|
||||
// background: white,
|
||||
// width: 0,
|
||||
// height: 4,
|
||||
// left: '50%',
|
||||
// marginLeft: 0,
|
||||
// bottom: -8,
|
||||
// position: 'absolute',
|
||||
// borderRadius: 1000,
|
||||
// transition: [['all', '0.2s', 'cubic-bezier(0.95, 0.1, 0.45, 0.94)']]
|
||||
// }
|
||||
},
|
||||
forceSize: {
|
||||
display: 'inline-block',
|
||||
|
|
@ -167,6 +150,35 @@ const styles = {
|
|||
},
|
||||
logoLink: {
|
||||
cursor: 'pointer'
|
||||
},
|
||||
actionButtonsContainer: {
|
||||
zIndex: 1,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
minWidth: 200,
|
||||
transform: 'translateZ(0)'
|
||||
},
|
||||
notificationIcon: {
|
||||
marginTop: spacer / 2,
|
||||
cursor: 'pointer',
|
||||
background: 'transparent',
|
||||
boxShadow: '0px 0px 0px transparent',
|
||||
border: '0px solid transparent',
|
||||
textShadow: '0px 0px 0px transparent',
|
||||
outline: 'none'
|
||||
},
|
||||
hasUnread: {
|
||||
position: 'absolute',
|
||||
top: 5,
|
||||
left: 185,
|
||||
width: '9px',
|
||||
height: '9px',
|
||||
backgroundColor: secondaryColor,
|
||||
borderRadius: '50%'
|
||||
},
|
||||
popper: {
|
||||
zIndex: 1
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
TransactionsList,
|
||||
ComplianceDetails
|
||||
} from './components'
|
||||
import { /* getFormattedPhone, */ getName } from './helper'
|
||||
import { getFormattedPhone, getName } from './helper'
|
||||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
|
|
@ -147,13 +147,12 @@ const CustomerProfile = memo(() => {
|
|||
Customers
|
||||
</Label1>
|
||||
<Label2 noMargin className={classes.labelLink}>
|
||||
{name.length ? name : R.path(['phone'])(customerData)}
|
||||
{/* {name.length
|
||||
{name.length
|
||||
? name
|
||||
: getFormattedPhone(
|
||||
R.path(['phone'])(customerData),
|
||||
locale.country
|
||||
)} */}
|
||||
)}
|
||||
</Label2>
|
||||
</Breadcrumbs>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -11,11 +11,7 @@ import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-ou
|
|||
import { ifNotNull } from 'src/utils/nullCheck'
|
||||
|
||||
import styles from './CustomersList.styles'
|
||||
import {
|
||||
getAuthorizedStatus,
|
||||
getName
|
||||
/* getFormattedPhone */
|
||||
} from './helper'
|
||||
import { getAuthorizedStatus, getFormattedPhone, getName } from './helper'
|
||||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
|
|
@ -26,7 +22,7 @@ const CustomersList = ({ data, locale, onClick, loading }) => {
|
|||
{
|
||||
header: 'Phone',
|
||||
width: 172,
|
||||
view: it => it.phone // getFormattedPhone(it.phone, locale.country)
|
||||
view: it => getFormattedPhone(it.phone, locale.country)
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { ReactComponent as LawIconInverse } from 'src/styling/icons/circle butto
|
|||
import { ReactComponent as LawIcon } from 'src/styling/icons/circle buttons/law/zodiac.svg'
|
||||
|
||||
import mainStyles from '../CustomersList.styles'
|
||||
import { /* getFormattedPhone, */ getName } from '../helper'
|
||||
import { getFormattedPhone, getName } from '../helper'
|
||||
|
||||
import FrontCameraPhoto from './FrontCameraPhoto'
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ const CustomerDetails = memo(({ customer, locale, setShowCompliance }) => {
|
|||
{
|
||||
header: 'Phone number',
|
||||
size: 172,
|
||||
value: customer.phone // getFormattedPhone(customer.phone, locale.country)
|
||||
value: getFormattedPhone(customer.phone, locale.country)
|
||||
},
|
||||
{
|
||||
header: 'ID number',
|
||||
|
|
@ -47,8 +47,9 @@ const CustomerDetails = memo(({ customer, locale, setShowCompliance }) => {
|
|||
<div className={classes.name}>
|
||||
<IdIcon className={classes.idIcon} />
|
||||
<H2 noMargin>
|
||||
{name.length ? name : R.path(['phone'])(customer)}
|
||||
{/* getFormattedPhone(R.path(['phone'])(customer), locale.country)} */}
|
||||
{name.length
|
||||
? name
|
||||
: getFormattedPhone(R.path(['phone'])(customer), locale.country)}
|
||||
</H2>
|
||||
<SubpageButton
|
||||
className={classes.subpageButton}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ const getAuthorizedStatus = it =>
|
|||
: { label: 'Authorized', type: 'success' }
|
||||
|
||||
const getFormattedPhone = (phone, country) => {
|
||||
return phone && country
|
||||
? parsePhoneNumberFromString(phone, country).formatInternational()
|
||||
: ''
|
||||
const phoneNumber =
|
||||
phone && country ? parsePhoneNumberFromString(phone, country) : null
|
||||
|
||||
return phoneNumber ? phoneNumber.formatInternational() : phone
|
||||
}
|
||||
|
||||
const getName = it => {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { transformNumber } from 'src/utils/number'
|
|||
|
||||
import NotificationsCtx from '../NotificationsContext'
|
||||
|
||||
const HIGH_BALANCE_KEY = 'cryptoHighBalance'
|
||||
const LOW_BALANCE_KEY = 'cryptoLowBalance'
|
||||
const HIGH_BALANCE_KEY = 'highBalance'
|
||||
const LOW_BALANCE_KEY = 'lowBalance'
|
||||
const CRYPTOCURRENCY_KEY = 'cryptoCurrency'
|
||||
const NAME = 'cryptoBalanceOverrides'
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ export default {
|
|||
width: fill,
|
||||
minHeight: fill
|
||||
},
|
||||
'.root-notifcenter-open': {
|
||||
// for when notification center is open
|
||||
overflowY: 'scroll',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
},
|
||||
'.body-notifcenter-open': {
|
||||
// for when notification center is open
|
||||
overflow: 'hidden'
|
||||
},
|
||||
html: {
|
||||
height: fill
|
||||
},
|
||||
|
|
|
|||
12
new-lamassu-admin/src/styling/icons/action/wrench/zodiac.svg
Normal file
12
new-lamassu-admin/src/styling/icons/action/wrench/zodiac.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="32px" height="32px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="↳-notification-center" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g id="notification-center_v01a#2-(open)" transform="translate(-1023.000000, -459.000000)" stroke="#1B2559">
|
||||
<g id="Group-5" transform="translate(1000.000000, 0.000000)">
|
||||
<g id="icon/sf-small/wrench" transform="translate(24.000000, 460.000000)">
|
||||
<path d="M15.7602493,3.10720971 L13.1962412,5.67121772 L10.3290323,5.67121772 L10.3290323,2.80400876 L12.8930403,0.24000075 C12.4378389,0.0872002725 11.9506373,0 11.4434358,0 C8.9282279,0 6.88822153,2.04000637 6.88822153,4.55681424 C6.88822153,5.08081588 6.98102182,5.58321745 7.14422233,6.05201891 L0.580201813,12.6168394 C-0.193400604,13.3904418 -0.193400604,14.6456458 0.580201813,15.4200482 C1.35460423,16.1936506 2.60980816,16.1936506 3.38341057,15.4200482 L9.94823109,8.85602767 C10.4170326,9.01922818 10.9186341,9.11202847 11.4434358,9.11202847 C13.9602436,9.11202847 16.00025,7.0720221 16.00025,4.55681424 C16.00025,4.04961265 15.9130497,3.56241113 15.7602493,3.10720971 Z" id="Stroke-1"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
19
new-lamassu-admin/src/styling/icons/arrow/transaction.svg
Normal file
19
new-lamassu-admin/src/styling/icons/arrow/transaction.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="↳-notification-center" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g id="notification-center_v01a#1-(marked-one-as-read)" transform="translate(-1023.000000, -123.000000)" stroke="#1B2559">
|
||||
<g id="Group-5" transform="translate(1000.000000, 0.000000)">
|
||||
<g id="Group-4" transform="translate(24.000000, 124.000000)">
|
||||
<g id="Group-3">
|
||||
<line x1="0" y1="4" x2="16" y2="4" id="Path-2"></line>
|
||||
<polyline id="Path-3" points="12 0 16 4 12 8"></polyline>
|
||||
</g>
|
||||
<g id="Group-2" transform="translate(8.000000, 12.000000) scale(-1, 1) translate(-8.000000, -12.000000) translate(0.000000, 8.000000)">
|
||||
<line x1="0" y1="4" x2="16" y2="4" id="Path-2-Copy"></line>
|
||||
<polyline id="Path-3-Copy" points="12 0 16 4 12 8"></polyline>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 60.1 (88133) - https://sketch.com -->
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="nav-/-primary-/-1440" transform="translate(-1295.000000, -19.000000)" stroke="#1B2559" stroke-width="2">
|
||||
<g id="icon/menu/notification" transform="translate(1296.000000, 20.000000)">
|
||||
<path d="M10.1052632,14.3157895 C10.1052632,15.2454737 9.35073684,16 8.42105263,16 C7.49136842,16 6.73684211,15.2454737 6.73684211,14.3157895" id="Stroke-1"></path>
|
||||
<path d="M1.6,14.3157895 C0.7168,14.3157895 0,13.6031813 0,12.7251462 C0,11.8471111 0.7168,11.1345029 1.6,11.1345029 L1.6,6.3625731 C1.6,2.84884211 4.4656,0 8,0 C11.5344,0 14.4,2.84884211 14.4,6.3625731 L14.4,11.1345029 C15.2832,11.1345029 16,11.8471111 16,12.7251462 C16,13.6031813 15.2832,14.3157895 14.4,14.3157895 L1.6,14.3157895 Z" id="Stroke-3" stroke-linejoin="round"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 63.1 (92452) - https://sketch.com -->
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="icon/stage/zodiac/full" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<circle id="Oval-2-Copy" fill="#1B2559" cx="9" cy="9" r="8"></circle>
|
||||
<circle id="Oval-Copy-5" stroke="#1B2559" stroke-width="2" transform="translate(9.000000, 9.000000) rotate(-270.000000) translate(-9.000000, -9.000000) " cx="9" cy="9" r="8"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 671 B |
Loading…
Add table
Add a link
Reference in a new issue