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:
Cesar 2020-12-17 22:18:35 +00:00 committed by Josh Harvey
parent 2a9e8dadba
commit c457faab40
37 changed files with 1337 additions and 1332 deletions

View file

@ -1,4 +1,5 @@
const db = require('./db') const db = require('./db')
const notifier = require('./notifier')
// Get all blacklist rows from the DB "blacklist" table that were manually inserted by the operator // Get all blacklist rows from the DB "blacklist" table that were manually inserted by the operator
const getBlacklist = () => { const getBlacklist = () => {
@ -13,11 +14,9 @@ const getBlacklist = () => {
// Delete row from blacklist table by crypto code and address // Delete row from blacklist table by crypto code and address
const deleteFromBlacklist = (cryptoCode, address) => { const deleteFromBlacklist = (cryptoCode, address) => {
const sql = `DELETE FROM blacklist WHERE crypto_code = $1 AND address = $2; 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^)` notifier.clearBlacklistNotification(cryptoCode, address)
return db.none(sql, [cryptoCode, address])
const detail = `'${cryptoCode}_BLOCKED_${address}', '${cryptoCode}_REUSED_${address}'`
return db.none(sql, [cryptoCode, address, detail])
} }
const insertIntoBlacklist = (cryptoCode, address) => { const insertIntoBlacklist = (cryptoCode, address) => {

View file

@ -8,7 +8,7 @@ const plugins = require('../plugins')
const logger = require('../logger') const logger = require('../logger')
const settingsLoader = require('../new-settings-loader') const settingsLoader = require('../new-settings-loader')
const configManager = require('../new-config-manager') const configManager = require('../new-config-manager')
const notifier = require("../notifier/index") const notifier = require('../notifier')
const cashInAtomic = require('./cash-in-atomic') const cashInAtomic = require('./cash-in-atomic')
const cashInLow = require('./cash-in-low') const cashInLow = require('./cash-in-low')
@ -29,10 +29,10 @@ function post (machineTx, pi) {
.then(([{ config }, blacklistItems]) => { .then(([{ config }, blacklistItems]) => {
const rejectAddressReuseActive = configManager.getCompliance(config).rejectAddressReuse 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 blacklisted = true
notifier.blacklistNotify(r.tx, false) 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) notifier.blacklistNotify(r.tx, true)
addressReuse = true addressReuse = true
} }

View file

@ -9,8 +9,6 @@ const helper = require('./cash-out-helper')
const cashOutActions = require('./cash-out-actions') const cashOutActions = require('./cash-out-actions')
const cashOutLow = require('./cash-out-low') const cashOutLow = require('./cash-out-low')
const notifier = require("../notifier/index")
const toObj = helper.toObj const toObj = helper.toObj
module.exports = {atomic} module.exports = {atomic}
@ -124,10 +122,8 @@ function updateCassettes (t, tx) {
tx.deviceId tx.deviceId
] ]
return t.one(sql, values).then(r => { return t.one(sql, values)
notifier.cashCassettesNotify(r, tx.deviceId) .then(r => socket.emit(_.assign(r, {op: 'cassetteUpdate', deviceId: tx.deviceId})))
return socket.emit(_.assign(r, {op: 'cassetteUpdate', deviceId: tx.deviceId}))
})
} }
function wasJustAuthorized (oldTx, newTx, isZeroConf) { function wasJustAuthorized (oldTx, newTx, isZeroConf) {

View file

@ -15,7 +15,8 @@ const complianceOverrides = require('./compliance_overrides')
const users = require('./users') const users = require('./users')
const options = require('./options') const options = require('./options')
const writeFile = util.promisify(fs.writeFile) const writeFile = util.promisify(fs.writeFile)
const notifierQueries = require('./notifier/queries')
const notifierUtils = require('./notifier/utils')
const NUM_RESULTS = 1000 const NUM_RESULTS = 1000
const idPhotoCardBasedir = _.get('idPhotoCardDir', options) const idPhotoCardBasedir = _.get('idPhotoCardDir', options)
const frontCameraBaseDir = _.get('frontCameraDir', 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') + const sql = Pgp.helpers.update(updateData, _.keys(updateData), 'customers') +
' where id=$1' ' where id=$1'
invalidateCustomerNotifications(id, formattedData) invalidateCustomerNotifications(id, formattedData).catch(console.error)
await db.none(sql, [id]) await db.none(sql, [id])
@ -123,12 +124,10 @@ async function updateCustomer (id, data, userToken) {
} }
const invalidateCustomerNotifications = (id, data) => { const invalidateCustomerNotifications = (id, data) => {
let detail = ''; if (data.authorized_override !== 'verified') return Promise.resolve()
if(data.authorized_override === 'verified') {
detail = `BLOCKED_${id}` const detailB = notifierUtils.buildDetail({ code: 'BLOCKED', customerId: id })
} return notifierQueries.invalidateNotification(detailB, 'compliance').catch(console.error)
const sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE valid = 't' AND detail = $1`
return db.none(sql, [detail])
} }
/** /**

View file

@ -1,18 +1,17 @@
const _ = require('lodash/fp') const _ = require('lodash/fp')
const axios = require('axios') const axios = require('axios')
const logger = require('./logger')
const db = require('./db') const db = require('./db')
const pairing = require('./pairing') const pairing = require('./pairing')
const notifier = require('./notifier') const notifier = require('./notifier')
const dbm = require('./postgresql_interface') const dbm = require('./postgresql_interface')
const configManager = require('./new-config-manager') const configManager = require('./new-config-manager')
const settingsLoader = require('./new-settings-loader') const settingsLoader = require('./new-settings-loader')
const notifierUtils = require('./notifier/utils')
module.exports = {getMachineName, getMachines, getMachine, getMachineNames, setMachine} const notifierQueries = require('./notifier/queries')
function getMachines () { 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 => ({ .then(rr => rr.map(r => ({
deviceId: r.device_id, deviceId: r.device_id,
cashbox: r.cashbox, cashbox: r.cashbox,
@ -83,31 +82,29 @@ function getMachineNames (config) {
* @returns {string} machine name * @returns {string} machine name
*/ */
function getMachineName (machineId) { 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]) return db.oneOrNone(sql, [machineId])
.then(it => it.name) .then(it => it.name)
} }
function getMachine (machineId) { 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)) return db.oneOrNone(sql, [machineId]).then(res => _.mapKeys(_.camelCase)(res))
} }
function renameMachine (rec) { 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]) return db.none(sql, [rec.newName, rec.deviceId])
} }
function resetCashOutBills (rec) { function resetCashOutBills (rec) {
const sql = ` const detailB = notifierUtils.buildDetail({ deviceId: rec.deviceId })
update devices set cassette1=$1, cassette2=$2 where device_id=$3; 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]).then(() => notifierQueries.invalidateNotification(detailB, 'fiatBalance'))
`
return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.deviceId])
} }
function emptyCashInBills (rec) { 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]) return db.none(sql, [rec.deviceId])
} }
@ -145,3 +142,5 @@ function setMachine (rec) {
default: throw new Error('No such action: ' + rec.action) default: throw new Error('No such action: ' + rec.action)
} }
} }
module.exports = { getMachineName, getMachines, getMachine, getMachineNames, setMachine }

View file

@ -12,8 +12,9 @@ const logs = require('../../logs')
const settingsLoader = require('../../new-settings-loader') const settingsLoader = require('../../new-settings-loader')
// const tokenManager = require('../../token-manager') // const tokenManager = require('../../token-manager')
const blacklist = require('../../blacklist') const blacklist = require('../../blacklist')
const machineEventsByIdBatch = require("../../postgresql_interface").machineEventsByIdBatch const machineEventsByIdBatch = require('../../postgresql_interface').machineEventsByIdBatch
const promoCodeManager = require('../../promo-codes') const promoCodeManager = require('../../promo-codes')
const notifierQueries = require('../../notifier/queries')
const serverVersion = require('../../../package.json').version const serverVersion = require('../../../package.json').version
const transactions = require('../transactions') const transactions = require('../transactions')
@ -247,6 +248,17 @@ const typeDefs = gql`
rate: Float rate: Float
} }
type Notification {
id: ID!
deviceName: String
type: String
detail: JSON
message: String
created: Date
read: Boolean
valid: Boolean
}
type Query { type Query {
countries: [Country] countries: [Country]
currencies: [Currency] currencies: [Currency]
@ -279,6 +291,8 @@ const typeDefs = gql`
promoCodes: [PromoCode] promoCodes: [PromoCode]
cryptoRates: JSONObject cryptoRates: JSONObject
fiatRates: [Rate] fiatRates: [Rate]
notifications: [Notification]
hasUnreadNotifications: Boolean
} }
type SupportLogsResponse { type SupportLogsResponse {
@ -312,6 +326,8 @@ const typeDefs = gql`
insertBlacklistRow(cryptoCode: String!, address: String!): Blacklist insertBlacklistRow(cryptoCode: String!, address: String!): Blacklist
createPromoCode(code: String!, discount: Int!): PromoCode createPromoCode(code: String!, discount: Int!): PromoCode
deletePromoCode(codeId: ID!): 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: { Mutation: {
machineAction: (...[, { deviceId, action, cashbox, cassette1, cassette2, newName }]) => machineAction({ deviceId, action, cashbox, cassette1, cassette2, newName }), machineAction: (...[, { deviceId, action, cashbox, cassette1, cassette2, newName }]) => machineAction({ deviceId, action, cashbox, cassette1, cassette2, newName }),
@ -397,7 +415,9 @@ const resolvers = {
blacklist.insertIntoBlacklist(cryptoCode, address), blacklist.insertIntoBlacklist(cryptoCode, address),
// revokeToken: (...[, { token }]) => tokenManager.revokeToken(token) // revokeToken: (...[, { token }]) => tokenManager.revokeToken(token)
createPromoCode: (...[, { code, discount }]) => promoCodeManager.createPromoCode(code, discount), 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()
} }
} }

View file

@ -44,18 +44,6 @@ const configSql = 'insert into user_config (type, data, valid, schema_version) v
function saveConfig (config) { function saveConfig (config) {
return loadLatestConfigOrNone() return loadLatestConfigOrNone()
.then(currentConfig => { .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) const newConfig = _.assign(currentConfig, config)
return db.none(configSql, ['config', { config: newConfig }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION]) return db.none(configSql, ['config', { config: newConfig }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION])
}) })

View file

@ -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
}

View file

@ -20,6 +20,14 @@ const NETWORK_DOWN_TIME = 1 * T.minute
const STALE_STATE = 7 * T.minute const STALE_STATE = 7 * T.minute
const ALERT_SEND_INTERVAL = T.hour 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 = { module.exports = {
PING, PING,
STALE, STALE,
@ -30,5 +38,6 @@ module.exports = {
CODES_DISPLAY, CODES_DISPLAY,
NETWORK_DOWN_TIME, NETWORK_DOWN_TIME,
STALE_STATE, STALE_STATE,
ALERT_SEND_INTERVAL ALERT_SEND_INTERVAL,
NOTIFICATION_TYPES
} }

View file

@ -1,5 +1,5 @@
const _ = require('lodash/fp') const _ = require('lodash/fp')
const utils = require("./utils") const utils = require('./utils')
const email = require('../email') const email = require('../email')
@ -19,19 +19,13 @@ function alertSubject(alertRec, config) {
alerts = _.concat(alerts, alertRec.general) alerts = _.concat(alerts, alertRec.general)
} }
_.keys(alertRec.devices).forEach(function (device) { _.forEach(device => {
if (config.balance) { alerts = _.concat(alerts, utils.deviceAlerts(config, alertRec, device))
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts) }, _.keys(alertRec.devices))
}
if (config.errors) {
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
}
})
if (alerts.length === 0) return null 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(', ') return '[Lamassu] Errors reported: ' + alertTypes.join(', ')
} }
@ -39,31 +33,23 @@ function printEmailAlerts(alertRec, config) {
let body = 'Errors were reported by your Lamassu Machines.\n' let body = 'Errors were reported by your Lamassu Machines.\n'
if (config.balance && alertRec.general.length !== 0) { if (config.balance && alertRec.general.length !== 0) {
body = body + '\nGeneral errors:\n' body += '\nGeneral errors:\n'
body = body + emailAlerts(alertRec.general) + '\n' body += emailAlerts(alertRec.general) + '\n'
} }
_.keys(alertRec.devices).forEach(function (device) { _.forEach(device => {
const deviceName = alertRec.deviceNames[device] const deviceName = alertRec.deviceNames[device]
body = body + '\nErrors for ' + deviceName + ':\n' body += '\nErrors for ' + deviceName + ':\n'
let alerts = [] const alerts = utils.deviceAlerts(config, alertRec, device)
if (config.balance) {
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
}
if (config.errors) {
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
}
body = body + emailAlerts(alerts)
})
body += emailAlerts(alerts)
}, _.keys(alertRec.devices))
return body return body
} }
function emailAlerts (alerts) { function emailAlerts (alerts) {
return alerts.map(emailAlert).join('\n') + '\n' return _.join('\n', _.map(emailAlert, alerts)) + '\n'
} }
function emailAlert (alert) { function emailAlert (alert) {
@ -98,5 +84,4 @@ function emailAlert(alert) {
const sendMessage = email.sendMessage const sendMessage = email.sendMessage
module.exports = { alertSubject, printEmailAlerts, sendMessage } module.exports = { alertSubject, printEmailAlerts, sendMessage }

View file

@ -11,6 +11,13 @@ const utils = require('./utils')
const emailFuncs = require('./email') const emailFuncs = require('./email')
const smsFuncs = require('./sms') const smsFuncs = require('./sms')
const { STALE, STALE_STATE, PING } = require('./codes') 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 smsEnabled = utils.isActive(notifications.sms)
@ -50,23 +57,20 @@ function checkNotification(plugins) {
) )
if (!currentAlertFingerprint) { if (!currentAlertFingerprint) {
const inAlert = !!utils.getAlertFingerprint() const inAlert = !!utils.getAlertFingerprint()
// (fingerprint = null, lastAlertTime = null) // variables for setAlertFingerprint: (fingerprint = null, lastAlertTime = null)
utils.setAlertFingerprint(null, null) utils.setAlertFingerprint(null, null)
if (inAlert) { if (inAlert) return utils.sendNoAlerts(plugins, smsEnabled, emailEnabled)
return utils.sendNoAlerts(plugins, smsEnabled, emailEnabled)
}
}
if (utils.shouldNotAlert(currentAlertFingerprint)) {
return
} }
if (utils.shouldNotAlert(currentAlertFingerprint)) return
const message = buildMessage(alerts, notifications) const message = buildMessage(alerts, notifications)
utils.setAlertFingerprint(currentAlertFingerprint, Date.now()) utils.setAlertFingerprint(currentAlertFingerprint, Date.now())
return plugins.sendMessage(message) return plugins.sendMessage(message)
}) })
.then(results => { .then(results => {
if (results && results.length > 0) if (results && results.length > 0) {
logger.debug('Successfully sent alerts') logger.debug('Successfully sent alerts')
}
}) })
.catch(logger.error) .catch(logger.error)
} }
@ -85,7 +89,7 @@ function getAlerts(plugins) {
function buildAlerts (pings, balances, events, devices) { function buildAlerts (pings, balances, events, devices) {
const alerts = { devices: {}, deviceNames: {} } const alerts = { devices: {}, deviceNames: {} }
alerts.general = _.filter(r => !r.deviceId, balances) alerts.general = _.filter(r => !r.deviceId, balances)
devices.forEach(function (device) { _.forEach(device => {
const deviceId = device.deviceId const deviceId = device.deviceId
const deviceName = device.name const deviceName = device.name
const deviceEvents = events.filter(function (eventRow) { const deviceEvents = events.filter(function (eventRow) {
@ -94,15 +98,15 @@ function buildAlerts(pings, balances, events, devices) {
const ping = pings[deviceId] || [] const ping = pings[deviceId] || []
const stuckScreen = checkStuckScreen(deviceEvents, deviceName) const stuckScreen = checkStuckScreen(deviceEvents, deviceName)
if (!alerts.devices[deviceId]) alerts.devices[deviceId] = {} alerts.devices = _.set([deviceId, 'balanceAlerts'], _.filter(
alerts.devices[deviceId].balanceAlerts = _.filter(
['deviceId', deviceId], ['deviceId', deviceId],
balances balances
) ), alerts.devices)
alerts.devices[deviceId].deviceAlerts = _.isEmpty(ping) ? stuckScreen : ping alerts.devices[deviceId].deviceAlerts = _.isEmpty(ping) ? stuckScreen : ping
alerts.deviceNames[deviceId] = deviceName alerts.deviceNames[deviceId] = deviceName
}) }, devices)
return alerts return alerts
} }
@ -119,34 +123,30 @@ function checkStuckScreen(deviceEvents, machineName) {
) )
const lastEvent = _.last(sortedEvents) const lastEvent = _.last(sortedEvents)
if (!lastEvent) { if (!lastEvent) return []
return []
}
const state = lastEvent.note.state const state = lastEvent.note.state
const isIdle = lastEvent.note.isIdle const isIdle = lastEvent.note.isIdle
if (isIdle) { if (isIdle) return []
return []
}
const age = Math.floor(lastEvent.age) const age = Math.floor(lastEvent.age)
if (age > STALE_STATE) { if (age > STALE_STATE) return [{ code: STALE, state, age, machineName }]
return [{ code: STALE, state, age, machineName }]
}
return [] return []
} }
async function transactionNotify (tx, rec) { function transactionNotify (tx, rec) {
const settings = await settingsLoader.loadLatest() return settingsLoader.loadLatest().then(settings => {
const notifSettings = configManager.getGlobalNotifications(settings.config) const notifSettings = configManager.getGlobalNotifications(settings.config)
const highValueTx = tx.fiat.gt(notifSettings.highValueTransaction || Infinity) const highValueTx = tx.fiat.gt(notifSettings.highValueTransaction || Infinity)
const isCashOut = tx.direction === 'cashOut' const isCashOut = tx.direction === 'cashOut'
// high value tx on database // high value tx on database
if(highValueTx && tx.direction === 'cashIn' || highValueTx && tx.direction === 'cashOut' && rec.isRedemption) { if (highValueTx && (tx.direction === 'cashIn' || (tx.direction === 'cashOut' && rec.isRedemption))) {
queries.addHighValueTx(tx) 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)
} }
// alert through sms or email any transaction or high value transaction, if SMS || email alerts are enabled // alert through sms or email any transaction or high value transaction, if SMS || email alerts are enabled
@ -163,11 +163,10 @@ async function transactionNotify (tx, rec) {
return Promise.all([ return Promise.all([
machineLoader.getMachineName(tx.deviceId), machineLoader.getMachineName(tx.deviceId),
customerPromise customerPromise
]) ]).then(([machineName, customer]) => {
.then(([machineName, customer]) => {
return utils.buildTransactionMessage(tx, rec, highValueTx, 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) {
@ -188,11 +187,11 @@ function sendRedemptionMessage(txId, error) {
return sendTransactionMessage(rec) return sendTransactionMessage(rec)
} }
async function sendTransactionMessage(rec, isHighValueTx) { function sendTransactionMessage (rec, isHighValueTx) {
const settings = await settingsLoader.loadLatest() return settingsLoader.loadLatest().then(settings => {
const notifications = configManager.getGlobalNotifications(settings.config) const notifications = configManager.getGlobalNotifications(settings.config)
let promises = [] const promises = []
const emailActive = const emailActive =
notifications.email.active && notifications.email.active &&
@ -205,201 +204,156 @@ async function sendTransactionMessage(rec, isHighValueTx) {
if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec)) if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec))
return Promise.all(promises) 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)
}
})
}
}) })
} }
const clearOldCryptoNotifications = balances => {
return queries.getAllValidNotifications(CRYPTO_BALANCE).then(res => {
const filterByBalance = _.filter(notification => {
const { cryptoCode, code } = notification.detail
return !_.find(balance => balance.cryptoCode === cryptoCode && balance.code === code)(balances)
})
const indexesToInvalidate = _.compose(_.map('id'), filterByBalance)(res)
/* const notInvalidated = _.filter(notification => {
Notes for new "valid" column on notifications table: return !_.find(id => notification.id === id)(indexesToInvalidate)
- 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('_')
}
}, res) }, res)
_.forEach(notification => { return (indexesToInvalidate.length ? queries.batchInvalidate(indexesToInvalidate) : Promise.resolve()).then(() => notInvalidated)
const idx = _.findIndex(balance => { })
return balance.code === notification.code && balance.cryptoCode === notification.cryptoCode
}, balances)
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) }, 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 balancesNotify = (balances) => {
const highFilter = o => o.code === 'HIGH_CRYPTO_BALANCE' const cryptoFilter = o => o.code === 'HIGH_CRYPTO_BALANCE' || o.code === 'LOW_CRYPTO_BALANCE'
const lowFilter = o => o.code === 'LOW_CRYPTO_BALANCE' const fiatFilter = o => o.code === 'LOW_CASH_OUT'
const highWarnings = _.filter(highFilter, balances) const cryptoWarnings = _.filter(cryptoFilter, balances)
const lowWarnings = _.filter(lowFilter, balances) const fiatWarnings = _.filter(fiatFilter, balances)
return Promise.all([cryptoBalancesNotify(cryptoWarnings), fiatBalancesNotify(fiatWarnings)]).catch(console.error)
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 clearOldErrorNotifications = (alerts) => { const clearOldErrorNotifications = alerts => {
queries.getAllValidNotifications('error').then(res => { return queries.getAllValidNotifications(ERROR)
_.forEach(notification => { .then(res => {
const idx = _.findIndex(alert => { // for each valid notification in DB see if it exists in alerts
return alert.code === notification.detail.split('_')[0] && alert.deviceId === notification.device_id // if the notification doesn't exist in alerts, it is not valid anymore
}, alerts) const filterByAlert = _.filter(notification => {
if(idx !== -1) { const { code, deviceId } = notification.detail
return return !_.find(alert => alert.code === code && alert.deviceId === deviceId)(alerts)
}
// if the notification doesn't exist, then it is outdated and is not valid anymore
return queries.invalidateNotification(notification.id)
}, res)
}) })
const indexesToInvalidate = _.compose(_.map('id'), filterByAlert)(res)
if (!indexesToInvalidate.length) return Promise.resolve()
return queries.batchInvalidate(indexesToInvalidate)
})
.catch(console.error)
} }
const errorAlertsNotify = (alertRec) => { const errorAlertsNotify = (alertRec) => {
let alerts = [] const embedDeviceId = deviceId => _.assign({ deviceId })
_.keys(alertRec.devices).forEach(function (device) { const mapToAlerts = _.map(it => _.map(embedDeviceId(it), alertRec.devices[it].deviceAlerts))
// embed device ID in each alert object inside the deviceAlerts array const alerts = _.compose(_.flatten, mapToAlerts, _.keys)(alertRec.devices)
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)
})
// 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)
return clearOldErrorNotifications(alerts).then(() => {
_.forEach(alert => { _.forEach(alert => {
switch (alert.code) { switch (alert.code) {
case PING: case PING: {
return queries.getValidNotifications('error', PING, alert.deviceId).then(res => { const detailB = utils.buildDetail({ code: PING, age: alert.age ? alert.age : -1, deviceId: alert.deviceId })
if(res.length > 0) { return queries.getValidNotifications(ERROR, _.omit(['age'], detailB)).then(res => {
return Promise.resolve() if (res.length > 0) return Promise.resolve()
}
console.log("Adding PING alert on database for " + alert.machineName)
const message = `Machine down` const message = `Machine down`
return queries.addErrorNotification(`${PING}_${alert.age ? alert.age : '-1'}`, message, alert.deviceId) return queries.addNotification(ERROR, message, detailB)
}) })
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) 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` const message = `Machine is stuck on ${alert.state} screen`
return queries.addErrorNotification(STALE, message, alert.deviceId) return queries.addNotification(ERROR, message, detailB)
}) })
default: }
return
} }
}, alerts) }, alerts)
}).catch(console.error)
} }
const blacklistNotify = (tx, isAddressReuse) => { const blacklistNotify = (tx, isAddressReuse) => {
let detail = '' const code = isAddressReuse ? 'REUSED' : 'BLOCKED'
let message = '' const name = isAddressReuse ? 'reused' : 'blacklisted'
if(isAddressReuse) {
detail = `${tx.cryptoCode}_REUSED_${tx.toAddress}` const detailB = utils.buildDetail({ cryptoCode: tx.cryptoCode, code, cryptoAddress: tx.toAddress })
message = `Blocked reused address: ${tx.cryptoCode} ${tx.toAddress.substr(0,10)}...` const message = `Blocked ${name} address: ${tx.cryptoCode} ${tx.toAddress.substr(0, 10)}...`
} else { return queries.addNotification(COMPLIANCE, message, detailB)
detail = `${tx.cryptoCode}_BLOCKED_${tx.toAddress}`
message = `Blocked blacklisted address: ${tx.cryptoCode} ${tx.toAddress.substr(0,10)}...`
} }
return queries.addComplianceNotification(tx.deviceId, detail, message) const clearBlacklistNotification = (cryptoCode, cryptoAddress) => {
return queries.clearBlacklistNotification(cryptoCode, cryptoAddress).catch(console.error)
} }
const clearOldCustomerSuspendedNotifications = (customerId, deviceId) => { const clearOldCustomerSuspendedNotifications = (customerId, deviceId) => {
const detail = `SUSPENDED_${customerId}` const detailB = utils.buildDetail({ code: 'SUSPENDED', customerId, deviceId })
return queries.invalidateNotification(null, detail, deviceId) return queries.invalidateNotification(detailB, 'compliance')
} }
const customerComplianceNotify = (customer, deviceId, prefix, days = null) => { const customerComplianceNotify = (customer, deviceId, code, days = null) => {
// prefix can be "BLOCKED", "SUSPENDED", etc // code for now can be "BLOCKED", "SUSPENDED"
const detail = `${prefix}_${customer.id}` const detailB = utils.buildDetail({ customerId: customer.id, code, deviceId })
const date = new Date() const date = new Date()
if (days) { if (days) {
date.setDate(date.getDate() + 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 return clearOldCustomerSuspendedNotifications(customer.id, deviceId)
clearOldCustomerSuspendedNotifications(customer.id, deviceId).then(() => { .then(() => queries.getValidNotifications(COMPLIANCE, detailB))
return queries.getValidNotifications('compliance', detail, deviceId) .then(res => {
}).then(res => { if (res.length > 0) return Promise.resolve()
if (res.length > 0) { return queries.addNotification(COMPLIANCE, message, detailB)
return Promise.resolve()
}
return queries.addComplianceNotification(deviceId, detail, message)
}) })
.catch(console.error)
} }
module.exports = { module.exports = {
@ -408,7 +362,7 @@ module.exports = {
checkPings, checkPings,
checkStuckScreen, checkStuckScreen,
sendRedemptionMessage, sendRedemptionMessage,
cashCassettesNotify,
blacklistNotify, blacklistNotify,
customerComplianceNotify, customerComplianceNotify,
clearBlacklistNotification
} }

View file

@ -1,6 +1,9 @@
const { v4: uuidv4 } = require('uuid')
const pgp = require('pg-promise')()
const _ = require('lodash/fp')
const dbm = require('../postgresql_interface') const dbm = require('../postgresql_interface')
const db = require('../db') const db = require('../db')
const { v4: uuidv4 } = require('uuid')
// types of notifications able to be inserted into db: // types of notifications able to be inserted into db:
/* /*
@ -11,27 +14,9 @@ compliance - notifications related to warnings triggered by compliance settings
error - notifications related to errors error - notifications related to errors
*/ */
const addHighValueTx = (tx) => { const addNotification = (type, message, detail) => {
const sql = `INSERT INTO notifications (id, type, device_id, message, created) values ($1, $2, $3, $4, CURRENT_TIMESTAMP)` const sql = `INSERT INTO notifications (id, type, message, detail) VALUES ($1, $2, $3, $4)`
const direction = tx.direction === "cashOut" ? 'cash-out' : 'cash-in' return db.oneOrNone(sql, [uuidv4(), type, message, detail])
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 getAllValidNotifications = (type) => { const getAllValidNotifications = (type) => {
@ -39,48 +24,58 @@ const getAllValidNotifications = (type) => {
return db.any(sql, [type]) return db.any(sql, [type])
} }
const addErrorNotification = (detail, message, deviceId) => { const invalidateNotification = (detail, type) => {
const sql = `INSERT INTO notifications (id, type, detail, device_id, message, created) values ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)` detail = _.omitBy(_.isEmpty, detail)
return db.oneOrNone(sql, [uuidv4(), 'error', detail, deviceId, message]) 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) => { const batchInvalidate = (ids) => {
let sql; const formattedIds = _.map(pgp.as.text, ids).join(',')
if(!deviceId) { const sql = `UPDATE notifications SET valid = 'f', read = 't', modified = CURRENT_TIMESTAMP WHERE id IN ($1^)`
sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't' AND detail LIKE $2` return db.none(sql, [formattedIds])
}
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 invalidateNotification = (id, detail = null, deviceId = null) => { const clearBlacklistNotification = (cryptoCode, cryptoAddress) => {
let sql = '' 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')`
if(id) { return db.none(sql, [cryptoCode, cryptoAddress])
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 addComplianceNotification = (deviceId, detail, message) => { const getValidNotifications = (type, detail) => {
const sql = `INSERT INTO notifications (id, type, detail, device_id, message, created) values ($1, 'compliance', $2, $3, $4, CURRENT_TIMESTAMP)` const sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't' AND detail @> $2`
return db.oneOrNone(sql, [uuidv4(), detail, deviceId, message]) 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 = { module.exports = {
machineEvents: dbm.machineEvents, machineEvents: dbm.machineEvents,
addHighValueTx, addNotification,
addCashCassetteWarning,
addCryptoBalanceWarning,
addErrorNotification,
getUnreadCassetteNotifications,
getAllValidNotifications, getAllValidNotifications,
getValidNotifications,
invalidateNotification, invalidateNotification,
addComplianceNotification batchInvalidate,
clearBlacklistNotification,
getValidNotifications,
getNotifications,
markAsRead,
markAllAsRead,
hasUnreadNotifications
} }

View file

@ -9,15 +9,9 @@ function printSmsAlerts(alertRec, config) {
alerts = _.concat(alerts, alertRec.general) alerts = _.concat(alerts, alertRec.general)
} }
_.keys(alertRec.devices).forEach(function (device) { _.forEach(device => {
if (config.balance) { alerts = _.concat(alerts, utils.deviceAlerts(config, alertRec, device))
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts) }, _.keys(alertRec.devices))
}
if (config.errors) {
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
}
})
if (alerts.length === 0) return null if (alerts.length === 0) return null
@ -27,12 +21,12 @@ function printSmsAlerts(alertRec, config) {
const code = entry[0] const code = entry[0]
const machineNames = _.filter( const machineNames = _.filter(
_.negate(_.isEmpty), _.negate(_.isEmpty),
_.map('machineName', entry[1]), _.map('machineName', entry[1])
) )
const cryptoCodes = _.filter( const cryptoCodes = _.filter(
_.negate(_.isEmpty), _.negate(_.isEmpty),
_.map('cryptoCode', entry[1]), _.map('cryptoCode', entry[1])
) )
return { return {
@ -43,16 +37,11 @@ function printSmsAlerts(alertRec, config) {
}, _.toPairs(alertsMap)) }, _.toPairs(alertsMap))
const mapByCodeDisplay = _.map(it => { const mapByCodeDisplay = _.map(it => {
if(_.isEmpty(it.machineNames) && _.isEmpty(it.cryptoCodes)) { if (_.isEmpty(it.machineNames) && _.isEmpty(it.cryptoCodes)) return it.codeDisplay
return it.codeDisplay if (_.isEmpty(it.machineNames)) return `${it.codeDisplay} (${it.cryptoCodes.join(', ')})`
} return `${it.codeDisplay} (${it.machineNames.join(', ')})`
if(_.isEmpty(it.machineNames)) {
return `${it.codeDisplay} (${it.cryptoCodes.join(', ')})`
}
else return `${it.codeDisplay} (${it.machineNames.join(', ')})`
}) })
const displayAlertTypes = _.compose( const displayAlertTypes = _.compose(
_.uniq, _.uniq,
mapByCodeDisplay, mapByCodeDisplay,

View file

@ -2,8 +2,6 @@ const BigNumber = require('../../../lib/bn')
const notifier = require('..') const notifier = require('..')
const utils = require('../utils') const utils = require('../utils')
const queries = require("../queries")
const emailFuncs = require('../email')
const smsFuncs = require('../sms') const smsFuncs = require('../sms')
afterEach(() => { afterEach(() => {
@ -96,6 +94,7 @@ const notifSettings = {
} }
} }
describe('checkNotifications', () => {
test('Exits checkNotifications with Promise.resolve() if SMS and Email are disabled', async () => { test('Exits checkNotifications with Promise.resolve() if SMS and Email are disabled', async () => {
expect.assertions(1) expect.assertions(1)
await expect( await expect(
@ -119,7 +118,9 @@ test('Exits checkNotifications with Promise.resolve() if SMS and Email are disab
}) })
).resolves.toBe(undefined) ).resolves.toBe(undefined)
}) })
})
describe('checkPings', () => {
test("Check Pings should return code PING for devices that haven't been pinged recently", () => { test("Check Pings should return code PING for devices that haven't been pinged recently", () => {
expect( expect(
notifier.checkPings([ notifier.checkPings([
@ -151,7 +152,7 @@ test('Checkpings returns empty array as the value for the id prop, if the lastPi
'7a531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4': [] '7a531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4': []
}) })
}) })
})
test('Check notification resolves to undefined if shouldNotAlert is called and is true', async () => { test('Check notification resolves to undefined if shouldNotAlert is called and is true', async () => {
const mockShouldNotAlert = jest.spyOn(utils, 'shouldNotAlert') const mockShouldNotAlert = jest.spyOn(utils, 'shouldNotAlert')
@ -190,7 +191,7 @@ test('If no alert fingerprint and inAlert is true, exits on call to sendNoAlerts
expect(mockSendNoAlerts).toHaveBeenCalledTimes(1) expect(mockSendNoAlerts).toHaveBeenCalledTimes(1)
}) })
// vvv tests for checkstuckscreen... describe('checkStuckScreen', () => {
test('checkStuckScreen returns [] when no events are found', () => { test('checkStuckScreen returns [] when no events are found', () => {
expect(notifier.checkStuckScreen([], 'Abc123')).toEqual([]) expect(notifier.checkStuckScreen([], 'Abc123')).toEqual([])
}) })
@ -279,8 +280,9 @@ test('checkStuckScreen returns empty array if age < STALE_STATE', () => {
expect(result1).toEqual([]) expect(result1).toEqual([])
expect(result2).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 configManager = require('../../new-config-manager')
const settingsLoader = require('../../new-settings-loader') const settingsLoader = require('../../new-settings-loader')
@ -292,11 +294,10 @@ test("calls sendRedemptionMessage if !zeroConf and rec.isRedemption", async () =
jest.spyOn(smsFuncs, 'sendMessage').mockImplementation((_, rec) => rec) jest.spyOn(smsFuncs, 'sendMessage').mockImplementation((_, rec) => rec)
getCashOut.mockReturnValue({ zeroConfLimit: -Infinity }) getCashOut.mockReturnValue({ zeroConfLimit: -Infinity })
loadLatest.mockReturnValue({}) loadLatest.mockReturnValue(Promise.resolve({}))
getGlobalNotifications.mockReturnValue({ ...notifSettings, sms: { active: true, errors: true, transactions: true } }) getGlobalNotifications.mockReturnValue({ ...notifSettings, sms: { active: true, errors: true, transactions: true } })
const response = await notifier.transactionNotify(tx, {isRedemption: true}) return notifier.transactionNotify(tx, { isRedemption: true }).then(response => {
// this type of response implies sendRedemptionMessage was called // this type of response implies sendRedemptionMessage was called
expect(response[0]).toMatchObject({ expect(response[0]).toMatchObject({
sms: { sms: {
@ -308,8 +309,9 @@ test("calls sendRedemptionMessage if !zeroConf and rec.isRedemption", async () =
} }
}) })
}) })
})
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 configManager = require('../../new-config-manager')
const settingsLoader = require('../../new-settings-loader') const settingsLoader = require('../../new-settings-loader')
const machineLoader = require('../../machine-loader') const machineLoader = require('../../machine-loader')
@ -322,11 +324,11 @@ test("calls sendTransactionMessage if !zeroConf and !rec.isRedemption", async ()
// sendMessage on emailFuncs isn't called because it is disabled in getGlobalNotifications.mockReturnValue // sendMessage on emailFuncs isn't called because it is disabled in getGlobalNotifications.mockReturnValue
jest.spyOn(smsFuncs, 'sendMessage').mockImplementation((_, rec) => ({ prop: rec })) jest.spyOn(smsFuncs, 'sendMessage').mockImplementation((_, rec) => ({ prop: rec }))
buildTransactionMessage.mockImplementation(() => ["mock message", false]) buildTransactionMessage.mockImplementation(() => ['mock message', false])
getMachineName.mockReturnValue("mockMachineName") getMachineName.mockReturnValue('mockMachineName')
getCashOut.mockReturnValue({ zeroConfLimit: -Infinity }) getCashOut.mockReturnValue({ zeroConfLimit: -Infinity })
loadLatest.mockReturnValue({}) loadLatest.mockReturnValue(Promise.resolve({}))
getGlobalNotifications.mockReturnValue({ ...notifSettings, sms: { active: true, errors: true, transactions: true } }) 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 })

View file

@ -26,6 +26,7 @@ const notifications = {
email: { active: false, errors: false } email: { active: false, errors: false }
} }
describe('buildAlertFingerprint', () => {
test('Build alert fingerprint returns null if no sms or email alerts', () => { test('Build alert fingerprint returns null if no sms or email alerts', () => {
expect( expect(
utils.buildAlertFingerprint( utils.buildAlertFingerprint(
@ -65,7 +66,9 @@ test('Build alert fingerprint returns hash if [email] or sms are enabled and the
}) })
).toBe('string') ).toBe('string')
}) })
})
describe('sendNoAlerts', () => {
test('Send no alerts returns empty object with sms and email disabled', () => { test('Send no alerts returns empty object with sms and email disabled', () => {
expect(utils.sendNoAlerts(plugins, false, false)).toEqual({}) expect(utils.sendNoAlerts(plugins, false, false)).toEqual({})
}) })
@ -98,3 +101,4 @@ test('Send no alerts returns object with email prop if only email is enabled', (
} }
}) })
}) })
})

View file

@ -11,14 +11,26 @@ const {
ALERT_SEND_INTERVAL ALERT_SEND_INTERVAL
} = require('./codes') } = require('./codes')
const DETAIL_TEMPLATE = {
deviceId: '',
cryptoCode: '',
code: '',
cassette: '',
age: '',
customerId: '',
cryptoAddress: '',
direction: '',
fiat: '',
fiatCode: ''
}
function parseEventNote (event) { function parseEventNote (event) {
return _.set('note', JSON.parse(event.note), event) return _.set('note', JSON.parse(event.note), event)
} }
function checkPing (device) { function checkPing (device) {
const age = +Date.now() - +new Date(device.lastPing) const age = Date.now() - (new Date(device.lastPing).getTime())
if (age > NETWORK_DOWN_TIME) if (age > NETWORK_DOWN_TIME) return [{ code: PING, age, machineName: device.name }]
return [{ code: PING, age, machineName: device.name }]
return [] return []
} }
@ -49,27 +61,6 @@ 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 sms = getAlertTypes(alertRec, notifications.sms)
const email = getAlertTypes(alertRec, notifications.email) const email = getAlertTypes(alertRec, notifications.email)
@ -153,6 +144,42 @@ function formatAge (age, settings) {
return prettyMs(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 = { module.exports = {
codeDisplay, codeDisplay,
parseEventNote, parseEventNote,
@ -167,5 +194,7 @@ module.exports = {
sendNoAlerts, sendNoAlerts,
buildTransactionMessage, buildTransactionMessage,
formatCurrency, formatCurrency,
formatAge formatAge,
buildDetail,
deviceAlerts
} }

View file

@ -23,7 +23,7 @@ const coinUtils = require('./coin-utils')
const commissionMath = require('./commission-math') const commissionMath = require('./commission-math')
const promoCodes = require('./promo-codes') const promoCodes = require('./promo-codes')
const notifier = require('./notifier/index') const notifier = require('./notifier')
const mapValuesWithKey = _.mapValues.convert({ const mapValuesWithKey = _.mapValues.convert({
cap: false cap: false
@ -55,8 +55,7 @@ function plugins (settings, deviceId) {
? undefined ? undefined
: BN(1).add(BN(commissions.cashOut).div(100)) : BN(1).add(BN(commissions.cashOut).div(100))
if (Date.now() - rateRec.timestamp > STALE_TICKER) if (Date.now() - rateRec.timestamp > STALE_TICKER) return logger.warn('Stale rate for ' + cryptoCode)
return logger.warn('Stale rate for ' + cryptoCode)
const rate = rateRec.rates const rate = rateRec.rates
withCommission ? rates[cryptoCode] = { withCommission ? rates[cryptoCode] = {
@ -90,10 +89,8 @@ function plugins (settings, deviceId) {
cryptoCodes.forEach((cryptoCode, i) => { cryptoCodes.forEach((cryptoCode, i) => {
const balanceRec = balanceRecs[i] const balanceRec = balanceRecs[i]
if (!balanceRec) if (!balanceRec) return logger.warn('No balance for ' + cryptoCode + ' yet')
return logger.warn('No balance for ' + cryptoCode + ' yet') if (Date.now() - balanceRec.timestamp > STALE_BALANCE) return logger.warn('Stale balance for ' + cryptoCode)
if (Date.now() - balanceRec.timestamp > STALE_BALANCE)
return logger.warn('Stale balance for ' + cryptoCode)
balances[cryptoCode] = balanceRec.balance balances[cryptoCode] = balanceRec.balance
}) })
@ -113,13 +110,10 @@ function plugins (settings, deviceId) {
const sumTxs = (sum, tx) => { const sumTxs = (sum, tx) => {
const bills = tx.bills const bills = tx.bills
const sameDenominations = a => a[0].denomination === a[1].denomination const sameDenominations = a => a[0].denomination === a[1].denomination
const doDenominationsMatch = _.every( const doDenominationsMatch = _.every(sameDenominations, _.zip(cassettes, bills))
sameDenominations,
_.zip(cassettes, bills)
)
if (!doDenominationsMatch) { 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)) return _.map(r => r[0] + r[1].provisioned, _.zip(sum, tx.bills))
@ -162,17 +156,6 @@ function plugins (settings, deviceId) {
? argv.cassettes.split(',') ? argv.cassettes.split(',')
: rec.counts : rec.counts
return Promise.all([
dbm.cassetteCounts(deviceId),
cashOutHelper.redeemableTxs(deviceId, excludeTxId)
]).then(([rec, _redeemableTxs]) => {
const redeemableTxs = _.reject(
_.matchesProperty('id', excludeTxId),
_redeemableTxs
)
const counts = argv.cassettes ? argv.cassettes.split(',') : rec.counts
const cassettes = [ const cassettes = [
{ {
denomination: parseInt(denominations[0], 10), denomination: parseInt(denominations[0], 10),
@ -206,23 +189,18 @@ function plugins (settings, deviceId) {
order by id desc order by id desc
limit 1` limit 1`
return db.one(sql, ['config']).then(row => row.id) return db.one(sql, ['config'])
.then(row => row.id)
} }
function mapCoinSettings (coinParams) { function mapCoinSettings (coinParams) {
const cryptoCode = coinParams[0] const cryptoCode = coinParams[0]
const cryptoNetwork = coinParams[1] const cryptoNetwork = coinParams[1]
const commissions = configManager.getCommissions( const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config)
cryptoCode,
deviceId,
settings.config
)
const minimumTx = BN(commissions.minimumTx) const minimumTx = BN(commissions.minimumTx)
const cashInFee = BN(commissions.fixedFee) const cashInFee = BN(commissions.fixedFee)
const cashInCommission = BN(commissions.cashIn) const cashInCommission = BN(commissions.cashIn)
const cashOutCommission = _.isNumber(commissions.cashOut) const cashOutCommission = _.isNumber(commissions.cashOut) ? BN(commissions.cashOut) : null
? BN(commissions.cashOut)
: null
const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode) const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode)
return { return {
@ -236,25 +214,15 @@ function plugins (settings, deviceId) {
} }
} }
function pollQueries ( function pollQueries (serialNumber, deviceTime, deviceRec, machineVersion, machineModel) {
serialNumber,
deviceTime,
deviceRec,
machineVersion,
machineModel
) {
const localeConfig = configManager.getLocale(deviceId, settings.config) const localeConfig = configManager.getLocale(deviceId, settings.config)
const fiatCode = localeConfig.fiatCurrency const fiatCode = localeConfig.fiatCurrency
const cryptoCodes = localeConfig.cryptoCurrencies const cryptoCodes = localeConfig.cryptoCurrencies
const tickerPromises = cryptoCodes.map(c => const tickerPromises = cryptoCodes.map(c => ticker.getRates(settings, fiatCode, c))
ticker.getRates(settings, fiatCode, c)
)
const balancePromises = cryptoCodes.map(c => fiatBalance(fiatCode, c)) const balancePromises = cryptoCodes.map(c => fiatBalance(fiatCode, c))
const testnetPromises = cryptoCodes.map(c => const testnetPromises = cryptoCodes.map(c => wallet.cryptoNetwork(settings, c))
wallet.cryptoNetwork(settings, c)
)
const pingPromise = recordPing(deviceTime, machineVersion, machineModel) const pingPromise = recordPing(deviceTime, machineVersion, machineModel)
const currentConfigVersionPromise = fetchCurrentConfigVersion() const currentConfigVersionPromise = fetchCurrentConfigVersion()
const currentAvailablePromoCodes = promoCodes.getNumberOfAvailablePromoCodes() const currentAvailablePromoCodes = promoCodes.getNumberOfAvailablePromoCodes()
@ -289,12 +257,7 @@ function plugins (settings, deviceId) {
} }
function sendCoins (tx) { function sendCoins (tx) {
return wallet.sendCoins( return wallet.sendCoins(settings, tx.toAddress, tx.cryptoAtoms, tx.cryptoCode)
settings,
tx.toAddress,
tx.cryptoAtoms,
tx.cryptoCode
)
} }
function recordPing (deviceTime, version, model) { function recordPing (deviceTime, version, model) {
@ -305,18 +268,11 @@ function plugins (settings, deviceId) {
} }
return Promise.all([ return Promise.all([
db.none( db.none(`insert into machine_pings(device_id, device_time) values($1, $2)
`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]),
ON CONFLICT (device_id) DO UPDATE SET device_time = $2, updated = now()`, db.none(pgp.helpers.update(devices, null, 'devices') + 'WHERE device_id = ${deviceId}', {
[deviceId, deviceTime]
),
db.none(
pgp.helpers.update(devices, null, 'devices') +
'WHERE device_id = ${deviceId}',
{
deviceId deviceId
} })
)
]) ])
} }
@ -348,15 +304,12 @@ function plugins (settings, deviceId) {
} }
function fiatBalance (fiatCode, cryptoCode) { function fiatBalance (fiatCode, cryptoCode) {
const commissions = configManager.getCommissions( const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config)
cryptoCode,
deviceId,
settings.config
)
return Promise.all([ return Promise.all([
ticker.getRates(settings, fiatCode, cryptoCode), ticker.getRates(settings, fiatCode, cryptoCode),
wallet.balance(settings, cryptoCode) wallet.balance(settings, cryptoCode)
]).then(([rates, balanceRec]) => { ])
.then(([rates, balanceRec]) => {
if (!rates || !balanceRec) return null if (!rates || !balanceRec) return null
const rawRate = rates.rates.ask const rawRate = rates.rates.ask
@ -393,7 +346,8 @@ function plugins (settings, deviceId) {
} }
} }
return sms.sendMessage(settings, rec).then(() => { return sms.sendMessage(settings, rec)
.then(() => {
const sql = 'update cash_out_txs set notified=$1 where id=$2' const sql = 'update cash_out_txs set notified=$1 where id=$2'
const values = [true, tx.id] const values = [true, tx.id]
@ -407,17 +361,14 @@ function plugins (settings, deviceId) {
} }
function clearOldLogs () { function clearOldLogs () {
return logs.clearOldLogs().catch(logger.error) return logs.clearOldLogs()
.catch(logger.error)
} }
function pong () { function pong () {
return db return db.none(`UPDATE server_events SET created=now() WHERE event_type=$1;
.none(
`UPDATE server_events SET created=now() WHERE event_type=$1;
INSERT INTO server_events (event_type) SELECT $1 INSERT INTO server_events (event_type) SELECT $1
WHERE NOT EXISTS (SELECT 1 FROM server_events WHERE event_type=$1);`, WHERE NOT EXISTS (SELECT 1 FROM server_events WHERE event_type=$1);`, ['ping'])
['ping']
)
.catch(logger.error) .catch(logger.error)
} }
@ -458,16 +409,13 @@ function plugins (settings, deviceId) {
const marketTradesQueues = tradesQueues[market] const marketTradesQueues = tradesQueues[market]
if (!marketTradesQueues || marketTradesQueues.length === 0) return null if (!marketTradesQueues || marketTradesQueues.length === 0) return null
logger.debug( logger.debug('[%s] tradesQueues size: %d', market, marketTradesQueues.length)
'[%s] tradesQueues size: %d',
market,
marketTradesQueues.length
)
logger.debug('[%s] tradesQueues head: %j', market, marketTradesQueues[0]) logger.debug('[%s] tradesQueues head: %j', market, marketTradesQueues[0])
const t1 = Date.now() const t1 = Date.now()
const filtered = marketTradesQueues.filter(tradeEntry => { const filtered = marketTradesQueues
.filter(tradeEntry => {
return t1 - tradeEntry.timestamp < TRADE_TTL return t1 - tradeEntry.timestamp < TRADE_TTL
}) })
@ -480,14 +428,10 @@ function plugins (settings, deviceId) {
if (filtered.length === 0) return null if (filtered.length === 0) return null
const cryptoAtoms = filtered.reduce( const cryptoAtoms = filtered
(prev, current) => prev.plus(current.cryptoAtoms), .reduce((prev, current) => prev.plus(current.cryptoAtoms), BN(0))
BN(0)
)
const timestamp = filtered const timestamp = filtered.map(r => r.timestamp).reduce((acc, r) => Math.max(acc, r), 0)
.map(r => r.timestamp)
.reduce((acc, r) => Math.max(acc, r), 0)
const consolidatedTrade = { const consolidatedTrade = {
fiatCode, fiatCode,
@ -503,15 +447,11 @@ function plugins (settings, deviceId) {
} }
function executeTrades () { function executeTrades () {
return machineLoader return machineLoader.getMachines()
.getMachines()
.then(devices => { .then(devices => {
const deviceIds = devices.map(device => device.deviceId) const deviceIds = devices.map(device => device.deviceId)
const lists = deviceIds.map(deviceId => { const lists = deviceIds.map(deviceId => {
const localeConfig = configManager.getLocale( const localeConfig = configManager.getLocale(deviceId, settings.config)
deviceId,
settings.config
)
const fiatCode = localeConfig.fiatCurrency const fiatCode = localeConfig.fiatCurrency
const cryptoCodes = localeConfig.cryptoCurrencies const cryptoCodes = localeConfig.cryptoCurrencies
@ -521,9 +461,8 @@ function plugins (settings, deviceId) {
})) }))
}) })
const tradesPromises = _.uniq(_.flatten(lists)).map(r => const tradesPromises = _.uniq(_.flatten(lists))
executeTradesForMarket(settings, r.fiatCode, r.cryptoCode) .map(r => executeTradesForMarket(settings, r.fiatCode, r.cryptoCode))
)
return Promise.all(tradesPromises) return Promise.all(tradesPromises)
}) })
@ -538,7 +477,8 @@ function plugins (settings, deviceId) {
if (tradeEntry === null || tradeEntry.cryptoAtoms.eq(0)) return if (tradeEntry === null || tradeEntry.cryptoAtoms.eq(0)) return
return executeTradeForType(tradeEntry).catch(err => { return executeTradeForType(tradeEntry)
.catch(err => {
tradesQueues[market].push(tradeEntry) tradesQueues[market].push(tradeEntry)
if (err.name === 'orderTooSmall') return logger.debug(err.message) if (err.name === 'orderTooSmall') return logger.debug(err.message)
logger.error(err) logger.error(err)
@ -546,8 +486,7 @@ function plugins (settings, deviceId) {
} }
function executeTradeForType (_tradeEntry) { function executeTradeForType (_tradeEntry) {
const expand = te => const expand = te => _.assign(te, {
_.assign(te, {
cryptoAtoms: te.cryptoAtoms.abs(), cryptoAtoms: te.cryptoAtoms.abs(),
type: te.cryptoAtoms.gte(0) ? 'buy' : 'sell' type: te.cryptoAtoms.gte(0) ? 'buy' : 'sell'
}) })
@ -555,26 +494,24 @@ function plugins (settings, deviceId) {
const tradeEntry = expand(_tradeEntry) const tradeEntry = expand(_tradeEntry)
const execute = tradeEntry.type === 'buy' ? exchange.buy : exchange.sell const execute = tradeEntry.type === 'buy' ? exchange.buy : exchange.sell
return execute( return execute(settings, tradeEntry.cryptoAtoms, tradeEntry.fiatCode, tradeEntry.cryptoCode)
settings,
tradeEntry.cryptoAtoms,
tradeEntry.fiatCode,
tradeEntry.cryptoCode
)
.then(() => recordTrade(tradeEntry)) .then(() => recordTrade(tradeEntry))
.catch(err => { .catch(err => {
return recordTrade(tradeEntry, err).then(() => { return recordTrade(tradeEntry, err)
.then(() => {
throw err throw err
}) })
}) })
} }
function convertBigNumFields (obj) { function convertBigNumFields (obj) {
const convert = (value, key) => const convert = (value, key) => _.includes(key, ['cryptoAtoms', 'fiat'])
_.includes(key, ['cryptoAtoms', 'fiat']) ? value.toString() : value ? value.toString()
: value
const convertKey = key => const convertKey = key => _.includes(key, ['cryptoAtoms', 'fiat'])
_.includes(key, ['cryptoAtoms', 'fiat']) ? key + '#' : key ? key + '#'
: key
return _.mapKeys(convertKey, mapValuesWithKey(convert, obj)) return _.mapKeys(convertKey, mapValuesWithKey(convert, obj))
} }
@ -604,10 +541,8 @@ function plugins (settings, deviceId) {
const notifications = configManager.getGlobalNotifications(settings.config) const notifications = configManager.getGlobalNotifications(settings.config)
let promises = [] let promises = []
if (notifications.email.active && rec.email) if (notifications.email.active && rec.email) promises.push(email.sendMessage(settings, rec))
promises.push(email.sendMessage(settings, rec)) if (notifications.sms.active && rec.sms) promises.push(sms.sendMessage(settings, rec))
if (notifications.sms.active && rec.sms)
promises.push(sms.sendMessage(settings, rec))
return Promise.all(promises) return Promise.all(promises)
} }
@ -617,24 +552,16 @@ function plugins (settings, deviceId) {
} }
function checkDeviceCashBalances (fiatCode, device) { function checkDeviceCashBalances (fiatCode, device) {
const cashOutConfig = configManager.getCashOut( const cashOutConfig = configManager.getCashOut(device.deviceId, settings.config)
device.deviceId,
settings.config
)
const denomination1 = cashOutConfig.top const denomination1 = cashOutConfig.top
const denomination2 = cashOutConfig.bottom const denomination2 = cashOutConfig.bottom
const cashOutEnabled = cashOutConfig.active const cashOutEnabled = cashOutConfig.active
const notifications = configManager.getNotifications( const notifications = configManager.getNotifications(null, device.deviceId, settings.config)
null,
device.deviceId,
settings.config
)
const machineName = device.name const machineName = device.name
const cashInAlert = const cashInAlert = device.cashbox > notifications.cashInAlertThreshold
device.cashbox > notifications.cashInAlertThreshold
? { ? {
code: 'CASH_BOX_FULL', code: 'CASH_BOX_FULL',
machineName, machineName,
@ -643,8 +570,7 @@ function plugins (settings, deviceId) {
} }
: null : null
const cassette1Alert = const cassette1Alert = cashOutEnabled && device.cassette1 < notifications.fiatBalanceCassette1
cashOutEnabled && device.cassette1 < notifications.fiatBalanceCassette1
? { ? {
code: 'LOW_CASH_OUT', code: 'LOW_CASH_OUT',
cassette: 1, cassette: 1,
@ -656,8 +582,7 @@ function plugins (settings, deviceId) {
} }
: null : null
const cassette2Alert = const cassette2Alert = cashOutEnabled && device.cassette2 < notifications.fiatBalanceCassette2
cashOutEnabled && device.cassette2 < notifications.fiatBalanceCassette2
? { ? {
code: 'LOW_CASH_OUT', code: 'LOW_CASH_OUT',
cassette: 2, cassette: 2,
@ -673,8 +598,7 @@ function plugins (settings, deviceId) {
} }
function checkCryptoBalances (fiatCode, devices) { function checkCryptoBalances (fiatCode, devices) {
const fiatBalancePromises = cryptoCodes => const fiatBalancePromises = cryptoCodes => _.map(c => fiatBalance(fiatCode, c), cryptoCodes)
_.map(c => fiatBalance(fiatCode, c), cryptoCodes)
const fetchCryptoCodes = _deviceId => { const fetchCryptoCodes = _deviceId => {
const localeConfig = configManager.getLocale(_deviceId, settings.config) const localeConfig = configManager.getLocale(_deviceId, settings.config)
@ -685,23 +609,18 @@ function plugins (settings, deviceId) {
const cryptoCodes = union(devices) const cryptoCodes = union(devices)
const checkCryptoBalanceWithFiat = _.partial(checkCryptoBalance, [fiatCode]) const checkCryptoBalanceWithFiat = _.partial(checkCryptoBalance, [fiatCode])
return Promise.all(fiatBalancePromises(cryptoCodes)).then(balances => return Promise.all(fiatBalancePromises(cryptoCodes))
_.map(checkCryptoBalanceWithFiat, _.zip(cryptoCodes, balances)) .then(balances => _.map(checkCryptoBalanceWithFiat, _.zip(cryptoCodes, balances)))
)
} }
function checkCryptoBalance (fiatCode, rec) { function checkCryptoBalance (fiatCode, rec) {
const [cryptoCode, fiatBalance] = rec const [cryptoCode, fiatBalance] = rec
if (!fiatBalance) return null if (!fiatBalance) return null
const notifications = configManager.getNotifications( const notifications = configManager.getNotifications(cryptoCode, null, settings.config)
cryptoCode, const override = _.find(override => override.cryptoCurrency === cryptoCode, settings.config.notifications_cryptoBalanceOverrides)
null, const lowAlertThreshold = override ? override.lowBalance : notifications.cryptoLowBalance
settings.config const highAlertThreshold = override ? override.highBalance : notifications.cryptoHighBalance
)
const lowAlertThreshold = notifications.cryptoLowBalance
const highAlertThreshold = notifications.cryptoHighBalance
const req = { const req = {
cryptoCode, cryptoCode,
@ -709,17 +628,13 @@ function plugins (settings, deviceId) {
fiatCode fiatCode
} }
if ( if (_.isFinite(lowAlertThreshold) && BN(fiatBalance.balance).lt(lowAlertThreshold)) {
_.isFinite(lowAlertThreshold) &&
BN(fiatBalance.balance).lt(lowAlertThreshold)
)
return _.set('code')('LOW_CRYPTO_BALANCE')(req) return _.set('code')('LOW_CRYPTO_BALANCE')(req)
}
if ( if (_.isFinite(highAlertThreshold) && BN(fiatBalance.balance).gt(highAlertThreshold)) {
_.isFinite(highAlertThreshold) &&
BN(fiatBalance.balance).gt(highAlertThreshold)
)
return _.set('code')('HIGH_CRYPTO_BALANCE')(req) return _.set('code')('HIGH_CRYPTO_BALANCE')(req)
}
return null return null
} }
@ -728,23 +643,24 @@ function plugins (settings, deviceId) {
const localeConfig = configManager.getGlobalLocale(settings.config) const localeConfig = configManager.getGlobalLocale(settings.config)
const fiatCode = localeConfig.fiatCurrency const fiatCode = localeConfig.fiatCurrency
return machineLoader.getMachines().then(devices => { return machineLoader.getMachines()
.then(devices => {
return Promise.all([ return Promise.all([
checkCryptoBalances(fiatCode, devices), checkCryptoBalances(fiatCode, devices),
checkDevicesCashBalances(fiatCode, devices) checkDevicesCashBalances(fiatCode, devices)
]).then(_.flow(_.flattenDeep, _.compact)) ])
.then(_.flow(_.flattenDeep, _.compact))
}) })
} }
function randomCode () { function randomCode () {
return BN(crypto.randomBytes(3).toString('hex'), 16) return BN(crypto.randomBytes(3).toString('hex'), 16).shift(-6).toFixed(6).slice(-6)
.shift(-6)
.toFixed(6)
.slice(-6)
} }
function getPhoneCode (phone) { function getPhoneCode (phone) {
const code = argv.mockSms ? '123' : randomCode() const code = argv.mockSms
? '123'
: randomCode()
const rec = { const rec = {
sms: { 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) { function sweepHdRow (row) {
const cryptoCode = row.crypto_code const cryptoCode = row.crypto_code
return wallet return wallet.sweep(settings, cryptoCode, row.hd_index)
.sweep(settings, cryptoCode, row.hd_index)
.then(txHash => { .then(txHash => {
if (txHash) { if (txHash) {
logger.debug('[%s] Swept address with tx: %s', cryptoCode, 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) return db.none(sql, row.id)
} }
}) })
.catch(err => .catch(err => logger.error('[%s] Sweep error: %s', cryptoCode, err.message))
logger.error('[%s] Sweep error: %s', cryptoCode, err.message)
)
} }
function sweepHd () { function sweepHd () {
const sql = `select id, crypto_code, hd_index from cash_out_txs 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')` where hd_index is not null and not swept and status in ('confirmed', 'instant')`
return db return db.any(sql)
.any(sql)
.then(rows => Promise.all(rows.map(sweepHdRow))) .then(rows => Promise.all(rows.map(sweepHdRow)))
.catch(err => logger.error(err)) .catch(err => logger.error(err))
} }
@ -795,15 +708,14 @@ function plugins (settings, deviceId) {
const fiatCode = localeConfig.fiatCurrency const fiatCode = localeConfig.fiatCurrency
const cryptoCodes = configManager.getAllCryptoCurrencies(settings.config) const cryptoCodes = configManager.getAllCryptoCurrencies(settings.config)
const tickerPromises = cryptoCodes.map(c => const tickerPromises = cryptoCodes.map(c => ticker.getRates(settings, fiatCode, c))
ticker.getRates(settings, fiatCode, c)
)
return Promise.all(tickerPromises) return Promise.all(tickerPromises)
} }
function getRates () { function getRates () {
return getRawRates().then(buildRates) return getRawRates()
.then(buildRates)
} }
return { return {

View file

@ -1,7 +1,7 @@
const _ = require('lodash/fp') const _ = require('lodash/fp')
const plugins = require('./plugins') const plugins = require('./plugins')
const notifier = require('./notifier/index') const notifier = require('./notifier')
const T = require('./time') const T = require('./time')
const logger = require('./logger') const logger = require('./logger')
const cashOutTx = require('./cash-out/cash-out-tx') const cashOutTx = require('./cash-out/cash-out-tx')

View file

@ -28,7 +28,7 @@ const compliance = require('./compliance')
const promoCodes = require('./promo-codes') const promoCodes = require('./promo-codes')
const BN = require('./bn') const BN = require('./bn')
const commissionMath = require('./commission-math') const commissionMath = require('./commission-math')
const notifier = require('./notifier/index') const notifier = require('./notifier')
const version = require('../package.json').version const version = require('../package.json').version
@ -324,7 +324,6 @@ function updateCustomer (req, res, next) {
} }
function triggerSanctions (req, res, next) { function triggerSanctions (req, res, next) {
console.log("SANCTIONS TRIGGERED")
const id = req.params.id const id = req.params.id
customers.getById(id) customers.getById(id)

View file

@ -1,8 +1,6 @@
var db = require('./db') var db = require('./db')
function singleQuotify(item) { const singleQuotify = (item) => `'${item}'`
return "'" + item + "'"
}
var types = [ var types = [
'highValueTransaction', 'highValueTransaction',
@ -18,20 +16,17 @@ exports.up = function (next) {
const sql = [ const sql = [
` `
CREATE TYPE notification_type AS ENUM ${'(' + types + ')'}; CREATE TYPE notification_type AS ENUM ${'(' + types + ')'};
CREATE TABLE IF NOT EXISTS "notifications" ( CREATE TABLE "notifications" (
"id" uuid NOT NULL PRIMARY KEY, "id" uuid NOT NULL PRIMARY KEY,
"type" notification_type NOT NULL, "type" notification_type NOT NULL,
"detail" TEXT, "detail" JSONB,
"device_id" TEXT,
"message" TEXT NOT NULL, "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', "read" BOOLEAN NOT NULL DEFAULT 'false',
"valid" BOOLEAN NOT NULL DEFAULT 'true', "valid" BOOLEAN NOT NULL DEFAULT 'true',
CONSTRAINT fk_devices "modified" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
FOREIGN KEY(device_id)
REFERENCES devices(device_id)
ON DELETE CASCADE
); );
CREATE INDEX ON notifications (valid);
CREATE INDEX ON notifications (read);` CREATE INDEX ON notifications (read);`
] ]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,2 @@
import NotificationCenter from './NotificationCenter'
export default NotificationCenter

View file

@ -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 { makeStyles } from '@material-ui/core/styles'
import classnames from 'classnames' import classnames from 'classnames'
import gql from 'graphql-tag'
import React, { memo, useState } from 'react' import React, { memo, useState } from 'react'
import { NavLink, useHistory } from 'react-router-dom' import { NavLink, useHistory } from 'react-router-dom'
import NotificationCenter from 'src/components/NotificationCenter'
import ActionButton from 'src/components/buttons/ActionButton' import ActionButton from 'src/components/buttons/ActionButton'
import { H4 } from 'src/components/typography' import { H4 } from 'src/components/typography'
import AddMachine from 'src/pages/AddMachine' import AddMachine from 'src/pages/AddMachine'
import { ReactComponent as AddIconReverse } from 'src/styling/icons/button/add/white.svg' 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 AddIcon } from 'src/styling/icons/button/add/zodiac.svg'
import { ReactComponent as Logo } from 'src/styling/icons/menu/logo.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' import styles from './Header.styles'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const HAS_UNREAD = gql`
query getUnread {
hasUnreadNotifications
}
`
const Subheader = ({ item, classes }) => { const Subheader = ({ item, classes }) => {
const [prev, setPrev] = useState(null) const [prev, setPrev] = useState(null)
@ -46,8 +58,10 @@ const Subheader = ({ item, classes }) => {
const Header = memo(({ tree }) => { const Header = memo(({ tree }) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [anchorEl, setAnchorEl] = React.useState(null)
const [active, setActive] = useState() const [active, setActive] = useState()
const { data, refetch } = useQuery(HAS_UNREAD)
const hasUnread = data?.hasUnreadNotifications ?? false
const history = useHistory() const history = useHistory()
const classes = useStyles() const classes = useStyles()
@ -56,8 +70,26 @@ const Header = memo(({ tree }) => {
history.push('/maintenance/machine-status', { id: machine.deviceId }) 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 ( return (
<header> <header className={classes.headerContainer}>
<div className={classes.header}> <div className={classes.header}>
<div className={classes.content}> <div className={classes.content}>
<div <div
@ -87,6 +119,8 @@ const Header = memo(({ tree }) => {
</NavLink> </NavLink>
))} ))}
</ul> </ul>
</nav>
<div className={classes.actionButtonsContainer}>
<ActionButton <ActionButton
color="secondary" color="secondary"
Icon={AddIcon} Icon={AddIcon}
@ -94,7 +128,34 @@ const Header = memo(({ tree }) => {
onClick={() => setOpen(true)}> onClick={() => setOpen(true)}>
Add machine Add machine
</ActionButton> </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>
</div> </div>
{active && active.children && ( {active && active.children && (

View file

@ -5,6 +5,7 @@ import {
spacer, spacer,
white, white,
primaryColor, primaryColor,
secondaryColor,
placeholderColor, placeholderColor,
subheaderColor, subheaderColor,
fontColor fontColor
@ -21,6 +22,9 @@ if (version === 8) {
} }
const styles = { const styles = {
headerContainer: {
position: 'relative'
},
header: { header: {
backgroundColor: primaryColor, backgroundColor: primaryColor,
color: white, color: white,
@ -80,27 +84,6 @@ const styles = {
border: 'none', border: 'none',
color: white, color: white,
backgroundColor: 'transparent' 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: { forceSize: {
display: 'inline-block', display: 'inline-block',
@ -167,6 +150,35 @@ const styles = {
}, },
logoLink: { logoLink: {
cursor: 'pointer' 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
} }
} }

View file

@ -24,7 +24,7 @@ import {
TransactionsList, TransactionsList,
ComplianceDetails ComplianceDetails
} from './components' } from './components'
import { /* getFormattedPhone, */ getName } from './helper' import { getFormattedPhone, getName } from './helper'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
@ -147,13 +147,12 @@ const CustomerProfile = memo(() => {
Customers Customers
</Label1> </Label1>
<Label2 noMargin className={classes.labelLink}> <Label2 noMargin className={classes.labelLink}>
{name.length ? name : R.path(['phone'])(customerData)} {name.length
{/* {name.length
? name ? name
: getFormattedPhone( : getFormattedPhone(
R.path(['phone'])(customerData), R.path(['phone'])(customerData),
locale.country locale.country
)} */} )}
</Label2> </Label2>
</Breadcrumbs> </Breadcrumbs>
<div> <div>

View file

@ -11,11 +11,7 @@ import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-ou
import { ifNotNull } from 'src/utils/nullCheck' import { ifNotNull } from 'src/utils/nullCheck'
import styles from './CustomersList.styles' import styles from './CustomersList.styles'
import { import { getAuthorizedStatus, getFormattedPhone, getName } from './helper'
getAuthorizedStatus,
getName
/* getFormattedPhone */
} from './helper'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
@ -26,7 +22,7 @@ const CustomersList = ({ data, locale, onClick, loading }) => {
{ {
header: 'Phone', header: 'Phone',
width: 172, width: 172,
view: it => it.phone // getFormattedPhone(it.phone, locale.country) view: it => getFormattedPhone(it.phone, locale.country)
}, },
{ {
header: 'Name', header: 'Name',

View file

@ -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 { ReactComponent as LawIcon } from 'src/styling/icons/circle buttons/law/zodiac.svg'
import mainStyles from '../CustomersList.styles' import mainStyles from '../CustomersList.styles'
import { /* getFormattedPhone, */ getName } from '../helper' import { getFormattedPhone, getName } from '../helper'
import FrontCameraPhoto from './FrontCameraPhoto' import FrontCameraPhoto from './FrontCameraPhoto'
@ -22,7 +22,7 @@ const CustomerDetails = memo(({ customer, locale, setShowCompliance }) => {
{ {
header: 'Phone number', header: 'Phone number',
size: 172, size: 172,
value: customer.phone // getFormattedPhone(customer.phone, locale.country) value: getFormattedPhone(customer.phone, locale.country)
}, },
{ {
header: 'ID number', header: 'ID number',
@ -47,8 +47,9 @@ const CustomerDetails = memo(({ customer, locale, setShowCompliance }) => {
<div className={classes.name}> <div className={classes.name}>
<IdIcon className={classes.idIcon} /> <IdIcon className={classes.idIcon} />
<H2 noMargin> <H2 noMargin>
{name.length ? name : R.path(['phone'])(customer)} {name.length
{/* getFormattedPhone(R.path(['phone'])(customer), locale.country)} */} ? name
: getFormattedPhone(R.path(['phone'])(customer), locale.country)}
</H2> </H2>
<SubpageButton <SubpageButton
className={classes.subpageButton} className={classes.subpageButton}

View file

@ -11,9 +11,10 @@ const getAuthorizedStatus = it =>
: { label: 'Authorized', type: 'success' } : { label: 'Authorized', type: 'success' }
const getFormattedPhone = (phone, country) => { const getFormattedPhone = (phone, country) => {
return phone && country const phoneNumber =
? parsePhoneNumberFromString(phone, country).formatInternational() phone && country ? parsePhoneNumberFromString(phone, country) : null
: ''
return phoneNumber ? phoneNumber.formatInternational() : phone
} }
const getName = it => { const getName = it => {

View file

@ -9,8 +9,8 @@ import { transformNumber } from 'src/utils/number'
import NotificationsCtx from '../NotificationsContext' import NotificationsCtx from '../NotificationsContext'
const HIGH_BALANCE_KEY = 'cryptoHighBalance' const HIGH_BALANCE_KEY = 'highBalance'
const LOW_BALANCE_KEY = 'cryptoLowBalance' const LOW_BALANCE_KEY = 'lowBalance'
const CRYPTOCURRENCY_KEY = 'cryptoCurrency' const CRYPTOCURRENCY_KEY = 'cryptoCurrency'
const NAME = 'cryptoBalanceOverrides' const NAME = 'cryptoBalanceOverrides'

View file

@ -11,6 +11,18 @@ export default {
width: fill, width: fill,
minHeight: 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: { html: {
height: fill height: fill
}, },

View 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

View 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

View file

@ -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

View file

@ -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