commit
cec919297e
232 changed files with 17611 additions and 16861 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -32,6 +32,8 @@ scratch/
|
||||||
seeds/
|
seeds/
|
||||||
mnemonics/
|
mnemonics/
|
||||||
certs/
|
certs/
|
||||||
|
test/stress/machines
|
||||||
|
test/stress/config.json
|
||||||
lamassu.json
|
lamassu.json
|
||||||
|
|
||||||
terraform.*
|
terraform.*
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ const server = require('./server')
|
||||||
const transactions = require('./transactions')
|
const transactions = require('./transactions')
|
||||||
const customers = require('../customers')
|
const customers = require('../customers')
|
||||||
const logs = require('../logs')
|
const logs = require('../logs')
|
||||||
const supportLogs = require('../support_logs')
|
|
||||||
const funding = require('./funding')
|
const funding = require('./funding')
|
||||||
const supportServer = require('./admin-support')
|
const supportServer = require('./admin-support')
|
||||||
|
|
||||||
|
|
@ -208,29 +207,6 @@ app.get('/api/logs', (req, res, next) => {
|
||||||
.catch(next)
|
.catch(next)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/api/support_logs', (req, res, next) => {
|
|
||||||
return supportLogs.batch()
|
|
||||||
.then(supportLogs => res.send({ supportLogs }))
|
|
||||||
.catch(next)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/api/support_logs/logs', (req, res, next) => {
|
|
||||||
return supportLogs.get(req.query.supportLogId)
|
|
||||||
.then(log => (!_.isNil(log) && !_.isEmpty(log)) ? log : supportLogs.batch().then(_.first))
|
|
||||||
.then(result => {
|
|
||||||
const log = result || {}
|
|
||||||
return logs.getMachineLogs(log.deviceId, log.timestamp)
|
|
||||||
})
|
|
||||||
.then(r => res.send(r))
|
|
||||||
.catch(next)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post('/api/support_logs', (req, res, next) => {
|
|
||||||
return supportLogs.insert(req.query.deviceId)
|
|
||||||
.then(r => res.send(r))
|
|
||||||
.catch(next)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.patch('/api/customer/:id', (req, res, next) => {
|
app.patch('/api/customer/:id', (req, res, next) => {
|
||||||
if (!req.params.id) return res.status(400).send({Error: 'Requires id'})
|
if (!req.params.id) return res.status(400).send({Error: 'Requires id'})
|
||||||
const token = req.token || req.cookies.token
|
const token = req.token || req.cookies.token
|
||||||
|
|
@ -349,7 +325,7 @@ wss.on('connection', ws => {
|
||||||
})
|
})
|
||||||
|
|
||||||
function run () {
|
function run () {
|
||||||
const serverPort = devMode ? 8070 : 443
|
const serverPort = devMode ? 8072 : 443
|
||||||
const supportPort = 8071
|
const supportPort = 8071
|
||||||
|
|
||||||
const serverLog = `lamassu-admin-server listening on port ${serverPort}`
|
const serverLog = `lamassu-admin-server listening on port ${serverPort}`
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ const _ = require('lodash/fp')
|
||||||
const serveStatic = require('serve-static')
|
const serveStatic = require('serve-static')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
const logs = require('../logs')
|
|
||||||
const supportLogs = require('../support_logs')
|
|
||||||
const options = require('../options')
|
const options = require('../options')
|
||||||
|
|
||||||
app.use(morgan('dev'))
|
app.use(morgan('dev'))
|
||||||
|
|
@ -30,29 +28,6 @@ const certOptions = {
|
||||||
rejectUnauthorized: true
|
rejectUnauthorized: true
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/api/support_logs', (req, res, next) => {
|
|
||||||
return supportLogs.batch()
|
|
||||||
.then(supportLogs => res.send({ supportLogs }))
|
|
||||||
.catch(next)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/api/support_logs/logs', (req, res, next) => {
|
|
||||||
return supportLogs.get(req.query.supportLogId)
|
|
||||||
.then(log => (!_.isNil(log) && !_.isEmpty(log)) ? log : supportLogs.batch().then(_.first))
|
|
||||||
.then(result => {
|
|
||||||
const log = result || {}
|
|
||||||
return logs.getUnlimitedMachineLogs(log.deviceId, log.timestamp)
|
|
||||||
})
|
|
||||||
.then(r => res.send(r))
|
|
||||||
.catch(next)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post('/api/support_logs', (req, res, next) => {
|
|
||||||
return supportLogs.insert(req.query.deviceId)
|
|
||||||
.then(r => res.send(r))
|
|
||||||
.catch(next)
|
|
||||||
})
|
|
||||||
|
|
||||||
function run (port) {
|
function run (port) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const webServer = https.createServer(certOptions, app)
|
const webServer = https.createServer(certOptions, app)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const db = require('./db')
|
const db = require('./db')
|
||||||
|
const notifierQueries = require('./notifier/queries')
|
||||||
|
|
||||||
// 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,10 +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) => {
|
||||||
return db.none(
|
const sql = `DELETE FROM blacklist WHERE crypto_code = $1 AND address = $2`
|
||||||
`DELETE FROM blacklist WHERE created_by_operator = 't' AND crypto_code = $1 AND address = $2`,
|
notifierQueries.clearBlacklistNotification(cryptoCode, address)
|
||||||
[cryptoCode, address]
|
return db.none(sql, [cryptoCode, address])
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertIntoBlacklist = (cryptoCode, address) => {
|
const insertIntoBlacklist = (cryptoCode, address) => {
|
||||||
|
|
@ -27,12 +27,12 @@ const insertIntoBlacklist = (cryptoCode, address) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function blocked(address, cryptoCode) {
|
function blocked (address, cryptoCode) {
|
||||||
const sql = `select * from blacklist where address = $1 and crypto_code = $2`
|
const sql = `select * from blacklist where address = $1 and crypto_code = $2`
|
||||||
return db.any(sql, [address, cryptoCode])
|
return db.any(sql, [address, cryptoCode])
|
||||||
}
|
}
|
||||||
|
|
||||||
function addToUsedAddresses(address, cryptoCode) {
|
function addToUsedAddresses (address, cryptoCode) {
|
||||||
// ETH reuses addresses
|
// ETH reuses addresses
|
||||||
if (cryptoCode === 'ETH') return Promise.resolve()
|
if (cryptoCode === 'ETH') return Promise.resolve()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ const BINARIES = {
|
||||||
dir: 'geth-linux-amd64-1.9.25-e7872729'
|
dir: 'geth-linux-amd64-1.9.25-e7872729'
|
||||||
},
|
},
|
||||||
ZEC: {
|
ZEC: {
|
||||||
url: 'https://download.z.cash/downloads/zcash-4.2.0-linux64-debian-stretch.tar.gz',
|
url: 'https://z.cash/downloads/zcash-4.3.0-linux64-debian-stretch.tar.gz',
|
||||||
dir: 'zcash-4.2.0/bin'
|
dir: 'zcash-4.3.0/bin'
|
||||||
},
|
},
|
||||||
DASH: {
|
DASH: {
|
||||||
url: 'https://github.com/dashpay/dash/releases/download/v0.16.1.1/dashcore-0.16.1.1-x86_64-linux-gnu.tar.gz',
|
url: 'https://github.com/dashpay/dash/releases/download/v0.16.1.1/dashcore-0.16.1.1-x86_64-linux-gnu.tar.gz',
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,16 @@ function insertNewBills (t, billRows, machineTx) {
|
||||||
if (_.isEmpty(bills)) return Promise.resolve([])
|
if (_.isEmpty(bills)) return Promise.resolve([])
|
||||||
|
|
||||||
const dbBills = _.map(cashInLow.massage, bills)
|
const dbBills = _.map(cashInLow.massage, bills)
|
||||||
const columns = _.keys(dbBills[0])
|
const columns = ['id', 'fiat', 'fiat_code', 'crypto_code', 'cash_in_fee', 'cash_in_txs_id', 'device_time']
|
||||||
const sql = pgp.helpers.insert(dbBills, columns, 'bills')
|
const sql = pgp.helpers.insert(dbBills, columns, 'bills')
|
||||||
|
const deviceID = machineTx.deviceId
|
||||||
|
const sql2 = `update devices set cashbox = cashbox + $2
|
||||||
|
where device_id = $1`
|
||||||
|
|
||||||
return t.none(sql)
|
return t.none(sql2, [deviceID, dbBills.length])
|
||||||
|
.then(() => {
|
||||||
|
return t.none(sql)
|
||||||
|
})
|
||||||
.then(() => bills)
|
.then(() => bills)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,13 @@ const E = require('../error')
|
||||||
|
|
||||||
const PENDING_INTERVAL_MS = 60 * T.minutes
|
const PENDING_INTERVAL_MS = 60 * T.minutes
|
||||||
|
|
||||||
const massage = _.flow(_.omit(['direction', 'cryptoNetwork', 'bills', 'blacklisted', 'addressReuse']),
|
const massageFields = ['direction', 'cryptoNetwork', 'bills', 'blacklisted', 'addressReuse']
|
||||||
|
const massageUpdateFields = _.concat(massageFields, 'cryptoAtoms')
|
||||||
|
|
||||||
|
const massage = _.flow(_.omit(massageFields),
|
||||||
|
convertBigNumFields, _.mapKeys(_.snakeCase))
|
||||||
|
|
||||||
|
const massageUpdates = _.flow(_.omit(massageUpdateFields),
|
||||||
convertBigNumFields, _.mapKeys(_.snakeCase))
|
convertBigNumFields, _.mapKeys(_.snakeCase))
|
||||||
|
|
||||||
module.exports = {toObj, upsert, insert, update, massage, isClearToSend}
|
module.exports = {toObj, upsert, insert, update, massage, isClearToSend}
|
||||||
|
|
@ -62,7 +68,7 @@ function insert (t, tx) {
|
||||||
function update (t, tx, changes) {
|
function update (t, tx, changes) {
|
||||||
if (_.isEmpty(changes)) return Promise.resolve(tx)
|
if (_.isEmpty(changes)) return Promise.resolve(tx)
|
||||||
|
|
||||||
const dbChanges = massage(changes)
|
const dbChanges = isFinalTxStage(changes) ? massage(changes) : massageUpdates(changes)
|
||||||
const sql = pgp.helpers.update(dbChanges, null, 'cash_in_txs') +
|
const sql = pgp.helpers.update(dbChanges, null, 'cash_in_txs') +
|
||||||
pgp.as.format(' where id=$1', [tx.id]) + ' returning *'
|
pgp.as.format(' where id=$1', [tx.id]) + ' returning *'
|
||||||
|
|
||||||
|
|
@ -136,3 +142,7 @@ function isClearToSend (oldTx, newTx) {
|
||||||
(!oldTx || (!oldTx.sendPending && !oldTx.sendConfirmed)) &&
|
(!oldTx || (!oldTx.sendPending && !oldTx.sendConfirmed)) &&
|
||||||
(newTx.created > now - PENDING_INTERVAL_MS)
|
(newTx.created > now - PENDING_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFinalTxStage (txChanges) {
|
||||||
|
return txChanges.send
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +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')
|
||||||
|
|
||||||
const cashInAtomic = require('./cash-in-atomic')
|
const cashInAtomic = require('./cash-in-atomic')
|
||||||
const cashInLow = require('./cash-in-low')
|
const cashInLow = require('./cash-in-low')
|
||||||
|
|
@ -15,7 +16,7 @@ const cashInLow = require('./cash-in-low')
|
||||||
const PENDING_INTERVAL = '60 minutes'
|
const PENDING_INTERVAL = '60 minutes'
|
||||||
const MAX_PENDING = 10
|
const MAX_PENDING = 10
|
||||||
|
|
||||||
module.exports = {post, monitorPending, cancel, PENDING_INTERVAL}
|
module.exports = { post, monitorPending, cancel, PENDING_INTERVAL }
|
||||||
|
|
||||||
function post (machineTx, pi) {
|
function post (machineTx, pi) {
|
||||||
return db.tx(cashInAtomic.atomic(machineTx, pi))
|
return db.tx(cashInAtomic.atomic(machineTx, pi))
|
||||||
|
|
@ -28,12 +29,13 @@ 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
|
||||||
} else if (_.some(it => it.created_by_operator === false)(blacklistItems) && rejectAddressReuseActive) {
|
notifier.notifyIfActive('compliance', 'blacklistNotify', r.tx, false)
|
||||||
|
} else if (_.some(it => !it.created_by_operator)(blacklistItems) && rejectAddressReuseActive) {
|
||||||
|
notifier.notifyIfActive('compliance', 'blacklistNotify', r.tx, true)
|
||||||
addressReuse = true
|
addressReuse = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return postProcess(r, pi, blacklisted, addressReuse)
|
return postProcess(r, pi, blacklisted, addressReuse)
|
||||||
})
|
})
|
||||||
.then(changes => cashInLow.update(db, updatedTx, changes))
|
.then(changes => cashInLow.update(db, updatedTx, changes))
|
||||||
|
|
@ -43,8 +45,8 @@ function post (machineTx, pi) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerTrades (pi, newBills) {
|
function registerTrades (pi, r) {
|
||||||
_.forEach(bill => pi.buy(bill), newBills)
|
_.forEach(bill => pi.buy(bill, r.tx), r.newBills)
|
||||||
}
|
}
|
||||||
|
|
||||||
function logAction (rec, tx) {
|
function logAction (rec, tx) {
|
||||||
|
|
@ -63,7 +65,7 @@ function logAction (rec, tx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function logActionById (action, _rec, txId) {
|
function logActionById (action, _rec, txId) {
|
||||||
const rec = _.assign(_rec, {action, tx_id: txId})
|
const rec = _.assign(_rec, { action, tx_id: txId })
|
||||||
const sql = pgp.helpers.insert(rec, null, 'cash_in_actions')
|
const sql = pgp.helpers.insert(rec, null, 'cash_in_actions')
|
||||||
|
|
||||||
return db.none(sql)
|
return db.none(sql)
|
||||||
|
|
@ -92,7 +94,7 @@ function postProcess (r, pi, isBlacklisted, addressReuse) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
registerTrades(pi, r.newBills)
|
registerTrades(pi, r)
|
||||||
|
|
||||||
if (!cashInLow.isClearToSend(r.dbTx, r.tx)) return Promise.resolve({})
|
if (!cashInLow.isClearToSend(r.dbTx, r.tx)) return Promise.resolve({})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ module.exports = {
|
||||||
|
|
||||||
const STALE_INCOMING_TX_AGE = T.day
|
const STALE_INCOMING_TX_AGE = T.day
|
||||||
const STALE_LIVE_INCOMING_TX_AGE = 10 * T.minutes
|
const STALE_LIVE_INCOMING_TX_AGE = 10 * T.minutes
|
||||||
|
const STALE_LIVE_INCOMING_TX_AGE_FILTER = 5 * T.minutes
|
||||||
const MAX_NOTIFY_AGE = T.day
|
const MAX_NOTIFY_AGE = T.day
|
||||||
const MIN_NOTIFY_AGE = 5 * T.minutes
|
const MIN_NOTIFY_AGE = 5 * T.minutes
|
||||||
const INSUFFICIENT_FUNDS_CODE = 570
|
const INSUFFICIENT_FUNDS_CODE = 570
|
||||||
|
|
@ -95,16 +96,21 @@ function postProcess (txVector, justAuthorized, pi) {
|
||||||
return Promise.resolve({})
|
return Promise.resolve({})
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchOpenTxs (statuses, fromAge, toAge) {
|
function fetchOpenTxs (statuses, fromAge, toAge, applyFilter, coinFilter) {
|
||||||
|
const notClause = applyFilter ? '' : 'not'
|
||||||
const sql = `select *
|
const sql = `select *
|
||||||
from cash_out_txs
|
from cash_out_txs
|
||||||
where ((extract(epoch from (now() - created))) * 1000)>$1
|
where ((extract(epoch from (now() - created))) * 1000)>$1
|
||||||
and ((extract(epoch from (now() - created))) * 1000)<$2
|
and ((extract(epoch from (now() - created))) * 1000)<$2
|
||||||
and status in ($3^)`
|
${_.isEmpty(coinFilter)
|
||||||
|
? ``
|
||||||
|
: `and crypto_code ${notClause} in ($3^)`}
|
||||||
|
and status in ($4^)`
|
||||||
|
|
||||||
|
const coinClause = _.map(pgp.as.text, coinFilter).join(',')
|
||||||
const statusClause = _.map(pgp.as.text, statuses).join(',')
|
const statusClause = _.map(pgp.as.text, statuses).join(',')
|
||||||
|
|
||||||
return db.any(sql, [fromAge, toAge, statusClause])
|
return db.any(sql, [fromAge, toAge, coinClause, statusClause])
|
||||||
.then(rows => rows.map(toObj))
|
.then(rows => rows.map(toObj))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,20 +122,22 @@ function processTxStatus (tx, settings) {
|
||||||
.then(_tx => selfPost(_tx, pi))
|
.then(_tx => selfPost(_tx, pi))
|
||||||
}
|
}
|
||||||
|
|
||||||
function monitorLiveIncoming (settings) {
|
function monitorLiveIncoming (settings, applyFilter, coinFilter) {
|
||||||
const statuses = ['notSeen', 'published', 'insufficientFunds']
|
const statuses = ['notSeen', 'published', 'insufficientFunds']
|
||||||
|
const toAge = applyFilter ? STALE_LIVE_INCOMING_TX_AGE_FILTER : STALE_LIVE_INCOMING_TX_AGE
|
||||||
|
|
||||||
return monitorIncoming(settings, statuses, 0, STALE_LIVE_INCOMING_TX_AGE)
|
return monitorIncoming(settings, statuses, 0, toAge, applyFilter, coinFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
function monitorStaleIncoming (settings) {
|
function monitorStaleIncoming (settings, applyFilter, coinFilter) {
|
||||||
const statuses = ['notSeen', 'published', 'authorized', 'instant', 'rejected', 'insufficientFunds']
|
const statuses = ['notSeen', 'published', 'authorized', 'instant', 'rejected', 'insufficientFunds']
|
||||||
|
const fromAge = applyFilter ? STALE_LIVE_INCOMING_TX_AGE_FILTER : STALE_LIVE_INCOMING_TX_AGE
|
||||||
|
|
||||||
return monitorIncoming(settings, statuses, STALE_LIVE_INCOMING_TX_AGE, STALE_INCOMING_TX_AGE)
|
return monitorIncoming(settings, statuses, fromAge, STALE_INCOMING_TX_AGE, applyFilter, coinFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
function monitorIncoming (settings, statuses, fromAge, toAge) {
|
function monitorIncoming (settings, statuses, fromAge, toAge, applyFilter, coinFilter) {
|
||||||
return fetchOpenTxs(statuses, fromAge, toAge)
|
return fetchOpenTxs(statuses, fromAge, toAge, applyFilter, coinFilter)
|
||||||
.then(txs => pEachSeries(txs, tx => processTxStatus(tx, settings)))
|
.then(txs => pEachSeries(txs, tx => processTxStatus(tx, settings)))
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
if (err.code === dbErrorCodes.SERIALIZATION_FAILURE) {
|
if (err.code === dbErrorCodes.SERIALIZATION_FAILURE) {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ const CRYPTO_CURRENCIES = [
|
||||||
configFile: 'bitcoin.conf',
|
configFile: 'bitcoin.conf',
|
||||||
daemon: 'bitcoind',
|
daemon: 'bitcoind',
|
||||||
defaultPort: 8332,
|
defaultPort: 8332,
|
||||||
unitScale: 8
|
unitScale: 8,
|
||||||
|
displayScale: 5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
cryptoCode: 'ETH',
|
cryptoCode: 'ETH',
|
||||||
|
|
@ -21,7 +22,8 @@ const CRYPTO_CURRENCIES = [
|
||||||
configFile: 'geth.conf',
|
configFile: 'geth.conf',
|
||||||
daemon: 'geth',
|
daemon: 'geth',
|
||||||
defaultPort: 8545,
|
defaultPort: 8545,
|
||||||
unitScale: 18
|
unitScale: 18,
|
||||||
|
displayScale: 15
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
cryptoCode: 'LTC',
|
cryptoCode: 'LTC',
|
||||||
|
|
@ -30,7 +32,8 @@ const CRYPTO_CURRENCIES = [
|
||||||
configFile: 'litecoin.conf',
|
configFile: 'litecoin.conf',
|
||||||
daemon: 'litecoind',
|
daemon: 'litecoind',
|
||||||
defaultPort: 9332,
|
defaultPort: 9332,
|
||||||
unitScale: 8
|
unitScale: 8,
|
||||||
|
displayScale: 5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
cryptoCode: 'DASH',
|
cryptoCode: 'DASH',
|
||||||
|
|
@ -39,7 +42,8 @@ const CRYPTO_CURRENCIES = [
|
||||||
configFile: 'dash.conf',
|
configFile: 'dash.conf',
|
||||||
daemon: 'dashd',
|
daemon: 'dashd',
|
||||||
defaultPort: 9998,
|
defaultPort: 9998,
|
||||||
unitScale: 8
|
unitScale: 8,
|
||||||
|
displayScale: 5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
cryptoCode: 'ZEC',
|
cryptoCode: 'ZEC',
|
||||||
|
|
@ -48,7 +52,8 @@ const CRYPTO_CURRENCIES = [
|
||||||
configFile: 'zcash.conf',
|
configFile: 'zcash.conf',
|
||||||
daemon: 'zcashd',
|
daemon: 'zcashd',
|
||||||
defaultPort: 8232,
|
defaultPort: 8232,
|
||||||
unitScale: 8
|
unitScale: 8,
|
||||||
|
displayScale: 5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
cryptoCode: 'BCH',
|
cryptoCode: 'BCH',
|
||||||
|
|
@ -57,7 +62,8 @@ const CRYPTO_CURRENCIES = [
|
||||||
configFile: 'bitcoincash.conf',
|
configFile: 'bitcoincash.conf',
|
||||||
daemon: 'bitcoincashd',
|
daemon: 'bitcoincashd',
|
||||||
defaultPort: 8335,
|
defaultPort: 8335,
|
||||||
unitScale: 8
|
unitScale: 8,
|
||||||
|
displayScale: 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
41
lib/commission-math.js
Normal file
41
lib/commission-math.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
const BN = require('./bn')
|
||||||
|
const configManager = require('./new-config-manager')
|
||||||
|
const coinUtils = require('./coin-utils')
|
||||||
|
|
||||||
|
function truncateCrypto (cryptoAtoms, cryptoCode) {
|
||||||
|
const DECIMAL_PLACES = 3
|
||||||
|
if (cryptoAtoms.eq(0)) return cryptoAtoms
|
||||||
|
|
||||||
|
const scale = coinUtils.getCryptoCurrency(cryptoCode).displayScale
|
||||||
|
const scaleFactor = BN(10).pow(scale)
|
||||||
|
|
||||||
|
return BN(cryptoAtoms).truncated().div(scaleFactor)
|
||||||
|
.round(DECIMAL_PLACES).times(scaleFactor)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fiatToCrypto (tx, rec, deviceId, config) {
|
||||||
|
const usableFiat = rec.fiat - rec.cashInFee
|
||||||
|
|
||||||
|
const commissions = configManager.getCommissions(tx.cryptoCode, deviceId, config)
|
||||||
|
const tickerRate = BN(tx.rawTickerPrice)
|
||||||
|
const discount = getDiscountRate(tx.discount, commissions[tx.direction])
|
||||||
|
const rate = tickerRate.mul(discount).round(5)
|
||||||
|
const unitScale = coinUtils.getCryptoCurrency(tx.cryptoCode).unitScale
|
||||||
|
const unitScaleFactor = BN(10).pow(unitScale)
|
||||||
|
|
||||||
|
return truncateCrypto(BN(usableFiat).div(rate.div(unitScaleFactor)), tx.cryptoCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiscountRate (discount, commission) {
|
||||||
|
const bnDiscount = discount ? BN(discount) : BN(0)
|
||||||
|
const bnCommission = BN(commission)
|
||||||
|
const percentageDiscount = BN(1).sub(bnDiscount.div(100))
|
||||||
|
const percentageCommission = bnCommission.div(100)
|
||||||
|
return BN(1).add(percentageDiscount.mul(percentageCommission))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
truncateCrypto,
|
||||||
|
fiatToCrypto,
|
||||||
|
getDiscountRate
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ const { scopedValue } = require('./admin/config-manager')
|
||||||
|
|
||||||
const GLOBAL = 'global'
|
const GLOBAL = 'global'
|
||||||
const ALL_CRYPTOS = _.values(COINS).sort()
|
const ALL_CRYPTOS = _.values(COINS).sort()
|
||||||
|
const ALL_CRYPTOS_STRING = 'ALL_COINS'
|
||||||
const ALL_MACHINES = 'ALL_MACHINES'
|
const ALL_MACHINES = 'ALL_MACHINES'
|
||||||
|
|
||||||
const GLOBAL_SCOPE = {
|
const GLOBAL_SCOPE = {
|
||||||
|
|
@ -66,7 +67,7 @@ function getConfigFields (codes, config) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrateCommissions (config) {
|
function migrateCommissions (config) {
|
||||||
const areArraysEquals = (arr1, arr2) => _.isEmpty(_.xor(arr1, arr2))
|
const areArraysEquals = (arr1, arr2) => Array.isArray(arr1) && Array.isArray(arr2) && _.isEmpty(_.xor(arr1, arr2))
|
||||||
const getMachine = _.get('scope.machine')
|
const getMachine = _.get('scope.machine')
|
||||||
const getCrypto = _.get('scope.crypto')
|
const getCrypto = _.get('scope.crypto')
|
||||||
const flattenCoins = _.compose(_.flatten, _.map(getCrypto))
|
const flattenCoins = _.compose(_.flatten, _.map(getCrypto))
|
||||||
|
|
@ -116,7 +117,7 @@ function migrateCommissions (config) {
|
||||||
commissions_overrides: allCommissionsOverrides.map(s => ({
|
commissions_overrides: allCommissionsOverrides.map(s => ({
|
||||||
..._.fromPairs(s.values.map(f => [codes[f.code], f.value])),
|
..._.fromPairs(s.values.map(f => [codes[f.code], f.value])),
|
||||||
machine: s.scope.machine === GLOBAL ? ALL_MACHINES : s.scope.machine,
|
machine: s.scope.machine === GLOBAL ? ALL_MACHINES : s.scope.machine,
|
||||||
cryptoCurrencies: s.scope.crypto,
|
cryptoCurrencies: areArraysEquals(s.scope.crypto, ALL_CRYPTOS) ? [ALL_CRYPTOS_STRING] : s.scope.crypto,
|
||||||
id: uuid.v4()
|
id: uuid.v4()
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
@ -162,17 +163,17 @@ function migrateCashOut (config) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
..._.fromPairs(
|
..._.fromPairs(
|
||||||
global.map(f => [`cashout_${globalCodes[f.code]}`, f.value])
|
global.map(f => [`cashOut_${globalCodes[f.code]}`, f.value])
|
||||||
),
|
),
|
||||||
..._.fromPairs(
|
..._.fromPairs(
|
||||||
_.flatten(
|
_.flatten(
|
||||||
scoped.map(s => {
|
scoped.map(s => {
|
||||||
const fields = s.values.map(f => [
|
const fields = s.values.map(f => [
|
||||||
`cashout_${f.scope.machine}_${scopedCodes[f.code]}`,
|
`cashOut_${f.scope.machine}_${scopedCodes[f.code]}`,
|
||||||
f.value
|
f.value
|
||||||
])
|
])
|
||||||
|
|
||||||
fields.push([`cashout_${s.scope.machine}_id`, s.scope.machine])
|
fields.push([`cashOut_${s.scope.machine}_id`, s.scope.machine])
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
})
|
})
|
||||||
|
|
@ -334,6 +335,9 @@ function migrateTermsAndConditions (config) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrateComplianceTriggers (config) {
|
function migrateComplianceTriggers (config) {
|
||||||
|
|
||||||
|
const suspensionDays = 1
|
||||||
|
|
||||||
const triggerTypes = {
|
const triggerTypes = {
|
||||||
amount: 'txAmount',
|
amount: 'txAmount',
|
||||||
velocity: 'txVelocity',
|
velocity: 'txVelocity',
|
||||||
|
|
@ -343,24 +347,28 @@ function migrateComplianceTriggers (config) {
|
||||||
|
|
||||||
const requirements = {
|
const requirements = {
|
||||||
sms: 'sms',
|
sms: 'sms',
|
||||||
idData: 'idData',
|
idData: 'idCardData',
|
||||||
idPhoto: 'idPhoto',
|
idPhoto: 'idCardPhoto',
|
||||||
facePhoto: 'facePhoto',
|
facePhoto: 'facephoto',
|
||||||
sanctions: 'sanctions'
|
sanctions: 'sanctions',
|
||||||
|
suspend: 'suspend'
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTrigger (
|
function createTrigger (
|
||||||
requirement,
|
requirement,
|
||||||
threshold
|
threshold,
|
||||||
|
suspensionDays
|
||||||
) {
|
) {
|
||||||
return {
|
const triggerConfig = {
|
||||||
id: uuid.v4(),
|
id: uuid.v4(),
|
||||||
cashDirection: 'both',
|
direction: 'both',
|
||||||
threshold,
|
threshold,
|
||||||
thresholdDays: 1,
|
thresholdDays: 1,
|
||||||
triggerType: triggerTypes.volume,
|
triggerType: triggerTypes.volume,
|
||||||
requirement
|
requirement
|
||||||
}
|
}
|
||||||
|
if (!requirement === 'suspend') return triggerConfig
|
||||||
|
return _.assign(triggerConfig, { suspensionDays })
|
||||||
}
|
}
|
||||||
|
|
||||||
const codes = [
|
const codes = [
|
||||||
|
|
@ -373,7 +381,10 @@ function migrateComplianceTriggers (config) {
|
||||||
'frontCameraVerificationActive',
|
'frontCameraVerificationActive',
|
||||||
'frontCameraVerificationThreshold',
|
'frontCameraVerificationThreshold',
|
||||||
'sanctionsVerificationActive',
|
'sanctionsVerificationActive',
|
||||||
'sanctionsVerificationThreshold'
|
'sanctionsVerificationThreshold',
|
||||||
|
'hardLimitVerificationActive',
|
||||||
|
'hardLimitVerificationThreshold',
|
||||||
|
'rejectAddressReuseActive'
|
||||||
]
|
]
|
||||||
|
|
||||||
const global = _.fromPairs(
|
const global = _.fromPairs(
|
||||||
|
|
@ -406,7 +417,11 @@ function migrateComplianceTriggers (config) {
|
||||||
createTrigger(requirements.sanctions, global.sanctionsVerificationThreshold)
|
createTrigger(requirements.sanctions, global.sanctionsVerificationThreshold)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (global.hardLimitVerificationActive && _.isNumber(global.hardLimitVerificationThreshold)) {
|
||||||
|
triggers.push(
|
||||||
|
createTrigger(requirements.suspend, global.hardLimitVerificationThreshold, suspensionDays)
|
||||||
|
)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
triggers,
|
triggers,
|
||||||
['compliance_rejectAddressReuse']: global.rejectAddressReuseActive
|
['compliance_rejectAddressReuse']: global.rejectAddressReuseActive
|
||||||
|
|
@ -440,7 +455,10 @@ function migrateAccounts (accounts) {
|
||||||
'twilio'
|
'twilio'
|
||||||
]
|
]
|
||||||
|
|
||||||
return _.pick(accountArray)(accounts)
|
const services = _.keyBy('code', accounts)
|
||||||
|
const serviceFields = _.mapValues(({ fields }) => _.keyBy('code', fields))(services)
|
||||||
|
const allAccounts = _.mapValues(_.mapValues(_.get('value')))(serviceFields)
|
||||||
|
return _.pick(accountArray)(allAccounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrate (config, accounts) {
|
function migrate (config, accounts) {
|
||||||
|
|
|
||||||
|
|
@ -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,12 +116,20 @@ 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)
|
||||||
|
|
||||||
await db.none(sql, [id])
|
await db.none(sql, [id])
|
||||||
|
|
||||||
return getCustomerById(id)
|
return getCustomerById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invalidateCustomerNotifications = (id, data) => {
|
||||||
|
if (data.authorized_override !== 'verified') return Promise.resolve()
|
||||||
|
|
||||||
|
const detailB = notifierUtils.buildDetail({ code: 'BLOCKED', customerId: id })
|
||||||
|
return notifierQueries.invalidateNotification(detailB, 'compliance')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get customer by id
|
* Get customer by id
|
||||||
*
|
*
|
||||||
|
|
@ -399,7 +408,7 @@ function populateOverrideUsernames (customer) {
|
||||||
return users.getByIds(queryTokens)
|
return users.getByIds(queryTokens)
|
||||||
.then(usersList => {
|
.then(usersList => {
|
||||||
return _.map(userField => {
|
return _.map(userField => {
|
||||||
const user = _.find({token: userField.token}, usersList)
|
const user = _.find({ token: userField.token }, usersList)
|
||||||
return {
|
return {
|
||||||
[userField.field]: user ? user.name : null
|
[userField.field]: user ? user.name : null
|
||||||
}
|
}
|
||||||
|
|
@ -433,7 +442,7 @@ function batch () {
|
||||||
// TODO: getCustomersList and getCustomerById are very similar, so this should be refactored
|
// TODO: getCustomersList and getCustomerById are very similar, so this should be refactored
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query all customers, ordered by last activity
|
* Query all customers, ordered by last activity
|
||||||
* and with aggregate columns based on their
|
* and with aggregate columns based on their
|
||||||
* transactions
|
* transactions
|
||||||
*
|
*
|
||||||
|
|
@ -471,7 +480,7 @@ function getCustomersList () {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query all customers, ordered by last activity
|
* Query all customers, ordered by last activity
|
||||||
* and with aggregate columns based on their
|
* and with aggregate columns based on their
|
||||||
* transactions
|
* transactions
|
||||||
*
|
*
|
||||||
|
|
|
||||||
5
lib/forex.js
Normal file
5
lib/forex.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
const axios = require('axios')
|
||||||
|
|
||||||
|
const getFiatRates = () => axios.get('https://bitpay.com/api/rates').then(response => response.data)
|
||||||
|
|
||||||
|
module.exports = { getFiatRates }
|
||||||
|
|
@ -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 { checkPings, checkStuckScreen } = 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,
|
||||||
|
|
@ -36,20 +35,20 @@ function getConfig (defaultConfig) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMachineNames (config) {
|
function getMachineNames (config) {
|
||||||
const fullyFunctionalStatus = {label: 'Fully functional', type: 'success'}
|
const fullyFunctionalStatus = { label: 'Fully functional', type: 'success' }
|
||||||
const unresponsiveStatus = {label: 'Unresponsive', type: 'error'}
|
const unresponsiveStatus = { label: 'Unresponsive', type: 'error' }
|
||||||
const stuckStatus = {label: 'Stuck', type: 'error'}
|
const stuckStatus = { label: 'Stuck', type: 'error' }
|
||||||
|
|
||||||
return Promise.all([getMachines(), getConfig(config)])
|
return Promise.all([getMachines(), getConfig(config)])
|
||||||
.then(([machines, config]) => Promise.all(
|
.then(([machines, config]) => Promise.all(
|
||||||
[machines, notifier.checkPings(machines), dbm.machineEvents(), config]
|
[machines, checkPings(machines), dbm.machineEvents(), config]
|
||||||
))
|
))
|
||||||
.then(([machines, pings, events, config]) => {
|
.then(([machines, pings, events, config]) => {
|
||||||
const getStatus = (ping, stuck) => {
|
const getStatus = (ping, stuck) => {
|
||||||
if (ping && ping.age) return unresponsiveStatus
|
if (ping && ping.age) return unresponsiveStatus
|
||||||
|
|
||||||
if (stuck && stuck.age) return stuckStatus
|
if (stuck && stuck.age) return stuckStatus
|
||||||
|
|
||||||
return fullyFunctionalStatus
|
return fullyFunctionalStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,15 +56,15 @@ function getMachineNames (config) {
|
||||||
const cashOutConfig = configManager.getCashOut(r.deviceId, config)
|
const cashOutConfig = configManager.getCashOut(r.deviceId, config)
|
||||||
|
|
||||||
const cashOut = !!cashOutConfig.active
|
const cashOut = !!cashOutConfig.active
|
||||||
|
|
||||||
const statuses = [
|
const statuses = [
|
||||||
getStatus(
|
getStatus(
|
||||||
_.first(pings[r.deviceId]),
|
_.first(pings[r.deviceId]),
|
||||||
_.first(notifier.checkStuckScreen(events, r.name))
|
_.first(checkStuckScreen(events, r.name))
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
return _.assign(r, {cashOut, statuses})
|
return _.assign(r, { cashOut, statuses })
|
||||||
}
|
}
|
||||||
|
|
||||||
return _.map(addName, machines)
|
return _.map(addName, machines)
|
||||||
|
|
@ -83,31 +82,37 @@ 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 = 'update devices set cassette1=$1, cassette2=$2 where device_id=$3'
|
const detailB = notifierUtils.buildDetail({ deviceId: rec.deviceId })
|
||||||
return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.deviceId])
|
const sql = `UPDATE devices SET cassette1=$1, cassette2=$2 WHERE device_id=$3;`
|
||||||
|
return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.deviceId]).then(() => notifierQueries.invalidateNotification(detailB, 'fiatBalance'))
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyCashInBills (rec) {
|
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])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCassetteBills (rec) {
|
||||||
|
const sql = 'update devices set cashbox=$1, cassette1=$2, cassette2=$3 where device_id=$4'
|
||||||
|
return db.none(sql, [rec.cashbox, rec.cassettes[0], rec.cassettes[1], rec.deviceId])
|
||||||
|
}
|
||||||
|
|
||||||
function unpair (rec) {
|
function unpair (rec) {
|
||||||
return pairing.unpair(rec.deviceId)
|
return pairing.unpair(rec.deviceId)
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +134,7 @@ function setMachine (rec) {
|
||||||
case 'rename': return renameMachine(rec)
|
case 'rename': return renameMachine(rec)
|
||||||
case 'emptyCashInBills': return emptyCashInBills(rec)
|
case 'emptyCashInBills': return emptyCashInBills(rec)
|
||||||
case 'resetCashOutBills': return resetCashOutBills(rec)
|
case 'resetCashOutBills': return resetCashOutBills(rec)
|
||||||
|
case 'setCassetteBills': return setCassetteBills(rec)
|
||||||
case 'unpair': return unpair(rec)
|
case 'unpair': return unpair(rec)
|
||||||
case 'reboot': return reboot(rec)
|
case 'reboot': return reboot(rec)
|
||||||
case 'shutdown': return shutdown(rec)
|
case 'shutdown': return shutdown(rec)
|
||||||
|
|
@ -136,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 }
|
||||||
|
|
|
||||||
25
lib/new-admin/bills.js
Normal file
25
lib/new-admin/bills.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
// Get all bills with device id
|
||||||
|
const getBills = () => {
|
||||||
|
return Promise.reject(new Error('This functionality hasn\'t been implemented yet'))
|
||||||
|
/* return db.any(`
|
||||||
|
SELECT d.device_id, b.fiat, b.created, d.cashbox
|
||||||
|
FROM cash_in_txs
|
||||||
|
INNER JOIN bills AS b ON b.cash_in_txs_id = cash_in_txs.id
|
||||||
|
INNER JOIN devices as d ON d.device_id = cash_in_txs.device_id
|
||||||
|
ORDER BY device_id, created DESC`
|
||||||
|
)
|
||||||
|
.then(res => {
|
||||||
|
return res.map(item => ({
|
||||||
|
fiat: item.fiat,
|
||||||
|
deviceId: item.device_id,
|
||||||
|
cashbox: item.cashbox,
|
||||||
|
created: item.created
|
||||||
|
}))
|
||||||
|
}) */
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getBills
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ const ALL_ACCOUNTS = [
|
||||||
{ code: 'bitcoind', display: 'bitcoind', class: WALLET, cryptos: [BTC] },
|
{ code: 'bitcoind', display: 'bitcoind', class: WALLET, cryptos: [BTC] },
|
||||||
{ code: 'no-layer2', display: 'No Layer 2', class: LAYER_2, cryptos: ALL_CRYPTOS },
|
{ code: 'no-layer2', display: 'No Layer 2', class: LAYER_2, cryptos: ALL_CRYPTOS },
|
||||||
{ code: 'infura', display: 'Infura', class: WALLET, cryptos: [ETH] },
|
{ code: 'infura', display: 'Infura', class: WALLET, cryptos: [ETH] },
|
||||||
{ code: 'geth', display: 'geth', class: WALLET, cryptos: [ETH] },
|
{ code: 'geth', display: 'geth (DEPRECATED)', class: WALLET, cryptos: [ETH], deprecated: true },
|
||||||
{ code: 'zcashd', display: 'zcashd', class: WALLET, cryptos: [ZEC] },
|
{ code: 'zcashd', display: 'zcashd', class: WALLET, cryptos: [ZEC] },
|
||||||
{ code: 'litecoind', display: 'litecoind', class: WALLET, cryptos: [LTC] },
|
{ code: 'litecoind', display: 'litecoind', class: WALLET, cryptos: [LTC] },
|
||||||
{ code: 'dashd', display: 'dashd', class: WALLET, cryptos: [DASH] },
|
{ code: 'dashd', display: 'dashd', class: WALLET, cryptos: [DASH] },
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,21 @@ 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 notifierQueries = require('../../notifier/queries')
|
||||||
|
const bills = require('../bills')
|
||||||
|
const anonymous = require('../../constants').anonymousCustomer
|
||||||
|
|
||||||
const serverVersion = require('../../../package.json').version
|
const serverVersion = require('../../../package.json').version
|
||||||
|
|
||||||
const transactions = require('../transactions')
|
const transactions = require('../transactions')
|
||||||
const funding = require('../funding')
|
const funding = require('../funding')
|
||||||
|
const forex = require('../../forex')
|
||||||
const supervisor = require('../supervisor')
|
const supervisor = require('../supervisor')
|
||||||
const serverLogs = require('../server-logs')
|
const serverLogs = require('../server-logs')
|
||||||
const pairing = require('../pairing')
|
const pairing = require('../pairing')
|
||||||
|
const plugins = require('../../plugins')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
accounts: accountsConfig,
|
accounts: accountsConfig,
|
||||||
coins,
|
coins,
|
||||||
|
|
@ -81,6 +87,7 @@ const typeDefs = gql`
|
||||||
frontCameraPath: String
|
frontCameraPath: String
|
||||||
frontCameraOverride: String
|
frontCameraOverride: String
|
||||||
phone: String
|
phone: String
|
||||||
|
isAnonymous: Boolean
|
||||||
smsOverride: String
|
smsOverride: String
|
||||||
idCardData: JSONObject
|
idCardData: JSONObject
|
||||||
idCardDataOverride: String
|
idCardDataOverride: String
|
||||||
|
|
@ -130,6 +137,7 @@ const typeDefs = gql`
|
||||||
display: String!
|
display: String!
|
||||||
class: String!
|
class: String!
|
||||||
cryptos: [String]
|
cryptos: [String]
|
||||||
|
deprecated: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type MachineLog {
|
type MachineLog {
|
||||||
|
|
@ -174,6 +182,12 @@ const typeDefs = gql`
|
||||||
ip_address: String
|
ip_address: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PromoCode {
|
||||||
|
id: ID!
|
||||||
|
code: String!
|
||||||
|
discount: Int!
|
||||||
|
}
|
||||||
|
|
||||||
type Transaction {
|
type Transaction {
|
||||||
id: ID!
|
id: ID!
|
||||||
txClass: String!
|
txClass: String!
|
||||||
|
|
@ -200,6 +214,7 @@ const typeDefs = gql`
|
||||||
cashInFeeCrypto: String
|
cashInFeeCrypto: String
|
||||||
minimumTx: Float
|
minimumTx: Float
|
||||||
customerId: ID
|
customerId: ID
|
||||||
|
isAnonymous: Boolean
|
||||||
txVersion: Int!
|
txVersion: Int!
|
||||||
termsAccepted: Boolean
|
termsAccepted: Boolean
|
||||||
commissionPercentage: String
|
commissionPercentage: String
|
||||||
|
|
@ -214,6 +229,7 @@ const typeDefs = gql`
|
||||||
customerIdCardPhotoPath: String
|
customerIdCardPhotoPath: String
|
||||||
expired: Boolean
|
expired: Boolean
|
||||||
machineName: String
|
machineName: String
|
||||||
|
discount: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
type Blacklist {
|
type Blacklist {
|
||||||
|
|
@ -232,6 +248,29 @@ const typeDefs = gql`
|
||||||
deviceTime: Date
|
deviceTime: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Rate {
|
||||||
|
code: String
|
||||||
|
name: String
|
||||||
|
rate: Float
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notification {
|
||||||
|
id: ID!
|
||||||
|
type: String
|
||||||
|
detail: JSON
|
||||||
|
message: String
|
||||||
|
created: Date
|
||||||
|
read: Boolean
|
||||||
|
valid: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bills {
|
||||||
|
fiat: Int
|
||||||
|
deviceId: ID
|
||||||
|
created: Date
|
||||||
|
cashbox: Int
|
||||||
|
}
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
countries: [Country]
|
countries: [Country]
|
||||||
currencies: [Currency]
|
currencies: [Currency]
|
||||||
|
|
@ -249,18 +288,38 @@ const typeDefs = gql`
|
||||||
uptime: [ProcessStatus]
|
uptime: [ProcessStatus]
|
||||||
serverLogs(from: Date, until: Date, limit: Int, offset: Int): [ServerLog]
|
serverLogs(from: Date, until: Date, limit: Int, offset: Int): [ServerLog]
|
||||||
serverLogsCsv(from: Date, until: Date, limit: Int, offset: Int): String
|
serverLogsCsv(from: Date, until: Date, limit: Int, offset: Int): String
|
||||||
transactions(from: Date, until: Date, limit: Int, offset: Int): [Transaction]
|
transactions(
|
||||||
|
from: Date
|
||||||
|
until: Date
|
||||||
|
limit: Int
|
||||||
|
offset: Int
|
||||||
|
deviceId: ID
|
||||||
|
): [Transaction]
|
||||||
transactionsCsv(from: Date, until: Date, limit: Int, offset: Int): String
|
transactionsCsv(from: Date, until: Date, limit: Int, offset: Int): String
|
||||||
accounts: JSONObject
|
accounts: JSONObject
|
||||||
config: JSONObject
|
config: JSONObject
|
||||||
blacklist: [Blacklist]
|
blacklist: [Blacklist]
|
||||||
# userTokens: [UserToken]
|
# userTokens: [UserToken]
|
||||||
|
promoCodes: [PromoCode]
|
||||||
|
cryptoRates: JSONObject
|
||||||
|
fiatRates: [Rate]
|
||||||
|
notifications: [Notification]
|
||||||
|
alerts: [Notification]
|
||||||
|
hasUnreadNotifications: Boolean
|
||||||
|
bills: [Bills]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SupportLogsResponse {
|
||||||
|
id: ID!
|
||||||
|
timestamp: Date!
|
||||||
|
deviceId: ID
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MachineAction {
|
enum MachineAction {
|
||||||
rename
|
rename
|
||||||
emptyCashInBills
|
emptyCashInBills
|
||||||
resetCashOutBills
|
resetCashOutBills
|
||||||
|
setCassetteBills
|
||||||
unpair
|
unpair
|
||||||
reboot
|
reboot
|
||||||
shutdown
|
shutdown
|
||||||
|
|
@ -268,14 +327,21 @@ const typeDefs = gql`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
machineAction(deviceId:ID!, action: MachineAction!, cassette1: Int, cassette2: Int, newName: String): Machine
|
machineAction(deviceId:ID!, action: MachineAction!, cashbox: Int, cassette1: Int, cassette2: Int, newName: String): Machine
|
||||||
setCustomer(customerId: ID!, customerInput: CustomerInput): Customer
|
setCustomer(customerId: ID!, customerInput: CustomerInput): Customer
|
||||||
saveConfig(config: JSONObject): JSONObject
|
saveConfig(config: JSONObject): JSONObject
|
||||||
|
resetConfig(schemaVersion: Int): JSONObject
|
||||||
createPairingTotem(name: String!): String
|
createPairingTotem(name: String!): String
|
||||||
saveAccounts(accounts: JSONObject): JSONObject
|
saveAccounts(accounts: JSONObject): JSONObject
|
||||||
|
resetAccounts(schemaVersion: Int): JSONObject
|
||||||
|
migrateConfigAndAccounts: JSONObject
|
||||||
# revokeToken(token: String!): UserToken
|
# revokeToken(token: String!): UserToken
|
||||||
deleteBlacklistRow(cryptoCode: String!, address: String!): Blacklist
|
deleteBlacklistRow(cryptoCode: String!, address: String!): Blacklist
|
||||||
insertBlacklistRow(cryptoCode: String!, address: String!): Blacklist
|
insertBlacklistRow(cryptoCode: String!, address: String!): Blacklist
|
||||||
|
createPromoCode(code: String!, discount: Int!): PromoCode
|
||||||
|
deletePromoCode(codeId: ID!): PromoCode
|
||||||
|
toggleClearNotification(id: ID!, read: Boolean!): Notification
|
||||||
|
clearAllNotifications: Notification
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -292,7 +358,11 @@ const resolvers = {
|
||||||
JSONObject: GraphQLJSONObject,
|
JSONObject: GraphQLJSONObject,
|
||||||
Date: GraphQLDateTime,
|
Date: GraphQLDateTime,
|
||||||
Customer: {
|
Customer: {
|
||||||
transactions: parent => transactionsLoader.load(parent.id)
|
transactions: parent => transactionsLoader.load(parent.id),
|
||||||
|
isAnonymous: parent => (parent.id === anonymous.uuid)
|
||||||
|
},
|
||||||
|
Transaction: {
|
||||||
|
isAnonymous: parent => (parent.customerId === anonymous.uuid)
|
||||||
},
|
},
|
||||||
Machine: {
|
Machine: {
|
||||||
latestEvent: parent => machineEventsLoader.load(parent.deviceId)
|
latestEvent: parent => machineEventsLoader.load(parent.deviceId)
|
||||||
|
|
@ -308,9 +378,9 @@ const resolvers = {
|
||||||
customers: () => customers.getCustomersList(),
|
customers: () => customers.getCustomersList(),
|
||||||
customer: (...[, { customerId }]) => customers.getCustomerById(customerId),
|
customer: (...[, { customerId }]) => customers.getCustomerById(customerId),
|
||||||
funding: () => funding.getFunding(),
|
funding: () => funding.getFunding(),
|
||||||
machineLogs: (...[, { deviceId, from, until, limit, offset }]) =>
|
machineLogs: (...[, { deviceId, from, until, limit, offset }]) =>
|
||||||
logs.simpleGetMachineLogs(deviceId, from, until, limit, offset),
|
logs.simpleGetMachineLogs(deviceId, from, until, limit, offset),
|
||||||
machineLogsCsv: (...[, { deviceId, from, until, limit, offset }]) =>
|
machineLogsCsv: (...[, { deviceId, from, until, limit, offset }]) =>
|
||||||
logs.simpleGetMachineLogs(deviceId, from, until, limit, offset).then(parseAsync),
|
logs.simpleGetMachineLogs(deviceId, from, until, limit, offset).then(parseAsync),
|
||||||
serverVersion: () => serverVersion,
|
serverVersion: () => serverVersion,
|
||||||
uptime: () => supervisor.getAllProcessInfo(),
|
uptime: () => supervisor.getAllProcessInfo(),
|
||||||
|
|
@ -318,33 +388,57 @@ const resolvers = {
|
||||||
serverLogs.getServerLogs(from, until, limit, offset),
|
serverLogs.getServerLogs(from, until, limit, offset),
|
||||||
serverLogsCsv: (...[, { from, until, limit, offset }]) =>
|
serverLogsCsv: (...[, { from, until, limit, offset }]) =>
|
||||||
serverLogs.getServerLogs(from, until, limit, offset).then(parseAsync),
|
serverLogs.getServerLogs(from, until, limit, offset).then(parseAsync),
|
||||||
transactions: (...[, { from, until, limit, offset }]) =>
|
transactions: (...[, { from, until, limit, offset, deviceId }]) =>
|
||||||
transactions.batch(from, until, limit, offset),
|
transactions.batch(from, until, limit, offset, deviceId),
|
||||||
transactionsCsv: (...[, { from, until, limit, offset }]) =>
|
transactionsCsv: (...[, { from, until, limit, offset }]) =>
|
||||||
transactions.batch(from, until, limit, offset).then(parseAsync),
|
transactions.batch(from, until, limit, offset).then(parseAsync),
|
||||||
config: () => settingsLoader.loadLatestConfigOrNone(),
|
config: () => settingsLoader.loadLatestConfigOrNone(),
|
||||||
accounts: () => settingsLoader.loadAccounts(),
|
accounts: () => settingsLoader.showAccounts(),
|
||||||
blacklist: () => blacklist.getBlacklist(),
|
blacklist: () => blacklist.getBlacklist(),
|
||||||
// userTokens: () => tokenManager.getTokenList()
|
// userTokens: () => tokenManager.getTokenList()
|
||||||
|
promoCodes: () => promoCodeManager.getAvailablePromoCodes(),
|
||||||
|
cryptoRates: () =>
|
||||||
|
settingsLoader.loadLatest().then(settings => {
|
||||||
|
const pi = plugins(settings)
|
||||||
|
return pi.getRawRates().then(r => {
|
||||||
|
return {
|
||||||
|
withCommissions: pi.buildRates(r),
|
||||||
|
withoutCommissions: pi.buildRatesNoCommission(r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
fiatRates: () => forex.getFiatRates(),
|
||||||
|
notifications: () => notifierQueries.getNotifications(),
|
||||||
|
hasUnreadNotifications: () => notifierQueries.hasUnreadNotifications(),
|
||||||
|
alerts: () => notifierQueries.getAlerts(),
|
||||||
|
bills: () => bills.getBills()
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
machineAction: (...[, { deviceId, action, cassette1, cassette2, newName }]) => machineAction({ deviceId, action, cassette1, cassette2, newName }),
|
machineAction: (...[, { deviceId, action, cashbox, cassette1, cassette2, newName }]) => machineAction({ deviceId, action, cashbox, cassette1, cassette2, newName }),
|
||||||
createPairingTotem: (...[, { name }]) => pairing.totem(name),
|
createPairingTotem: (...[, { name }]) => pairing.totem(name),
|
||||||
saveAccounts: (...[, { accounts }]) => settingsLoader.saveAccounts(accounts),
|
saveAccounts: (...[, { accounts }]) => settingsLoader.saveAccounts(accounts),
|
||||||
setCustomer: (root, args, context, info) => {
|
resetAccounts: (...[, { schemaVersion }]) => settingsLoader.resetAccounts(schemaVersion),
|
||||||
|
setCustomer: (root, { customerId, customerInput }, context, info) => {
|
||||||
const token = context.req.cookies && context.req.cookies.token
|
const token = context.req.cookies && context.req.cookies.token
|
||||||
return customers.updateCustomer(args.customerId, args.customerInput, token)
|
if (customerId === anonymous.uuid) return customers.getCustomerById(customerId)
|
||||||
|
return customers.updateCustomer(customerId, customerInput, token)
|
||||||
},
|
},
|
||||||
saveConfig: (...[, { config }]) => settingsLoader.saveConfig(config)
|
saveConfig: (...[, { config }]) => settingsLoader.saveConfig(config)
|
||||||
.then(it => {
|
.then(it => {
|
||||||
notify()
|
notify()
|
||||||
return it
|
return it
|
||||||
}),
|
}),
|
||||||
|
resetConfig: (...[, { schemaVersion }]) => settingsLoader.resetConfig(schemaVersion),
|
||||||
|
migrateConfigAndAccounts: () => settingsLoader.migrate(),
|
||||||
deleteBlacklistRow: (...[, { cryptoCode, address }]) =>
|
deleteBlacklistRow: (...[, { cryptoCode, address }]) =>
|
||||||
blacklist.deleteFromBlacklist(cryptoCode, address),
|
blacklist.deleteFromBlacklist(cryptoCode, address),
|
||||||
insertBlacklistRow: (...[, { cryptoCode, address }]) =>
|
insertBlacklistRow: (...[, { cryptoCode, address }]) =>
|
||||||
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),
|
||||||
|
deletePromoCode: (...[, { codeId }]) => promoCodeManager.deletePromoCode(codeId),
|
||||||
|
toggleClearNotification: (...[, { id, read }]) => notifierQueries.setRead(id, read),
|
||||||
|
clearAllNotifications: () => notifierQueries.markAllAsRead()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@ function getMachine (machineId) {
|
||||||
.then(machines => machines.find(({ deviceId }) => deviceId === machineId))
|
.then(machines => machines.find(({ deviceId }) => deviceId === machineId))
|
||||||
}
|
}
|
||||||
|
|
||||||
function machineAction ({ deviceId, action, cassette1, cassette2, newName }) {
|
function machineAction ({ deviceId, action, cashbox, cassette1, cassette2, newName }) {
|
||||||
return getMachine(deviceId)
|
return getMachine(deviceId)
|
||||||
.then(machine => {
|
.then(machine => {
|
||||||
if (!machine) throw new UserInputError(`machine:${deviceId} not found`, { deviceId })
|
if (!machine) throw new UserInputError(`machine:${deviceId} not found`, { deviceId })
|
||||||
return machine
|
return machine
|
||||||
})
|
})
|
||||||
.then(machineLoader.setMachine({ deviceId, action, cassettes: [cassette1, cassette2], newName }))
|
.then(machineLoader.setMachine({ deviceId, action, cashbox, cassettes: [cassette1, cassette2], newName }))
|
||||||
.then(getMachine(deviceId))
|
.then(getMachine(deviceId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ function addNames (txs) {
|
||||||
|
|
||||||
const camelize = _.mapKeys(_.camelCase)
|
const camelize = _.mapKeys(_.camelCase)
|
||||||
|
|
||||||
function batch (from = new Date(0).toISOString(), until = new Date().toISOString(), limit = null, offset = 0) {
|
function batch (from = new Date(0).toISOString(), until = new Date().toISOString(), limit = null, offset = 0, id = null) {
|
||||||
const packager = _.flow(_.flatten, _.orderBy(_.property('created'), ['desc']), _.map(camelize), addNames)
|
const packager = _.flow(_.flatten, _.orderBy(_.property('created'), ['desc']), _.map(camelize), addNames)
|
||||||
|
|
||||||
const cashInSql = `select 'cashIn' as tx_class, txs.*,
|
const cashInSql = `select 'cashIn' as tx_class, txs.*,
|
||||||
|
|
@ -38,7 +38,9 @@ function batch (from = new Date(0).toISOString(), until = new Date().toISOString
|
||||||
((not txs.send_confirmed) and (txs.created <= now() - interval $1)) as expired
|
((not txs.send_confirmed) and (txs.created <= now() - interval $1)) as expired
|
||||||
from cash_in_txs as txs
|
from cash_in_txs as txs
|
||||||
left outer join customers c on txs.customer_id = c.id
|
left outer join customers c on txs.customer_id = c.id
|
||||||
where txs.created >= $2 and txs.created <= $3
|
where txs.created >= $2 and txs.created <= $3 ${
|
||||||
|
id !== null ? `and txs.device_id = $6` : ``
|
||||||
|
}
|
||||||
order by created desc limit $4 offset $5`
|
order by created desc limit $4 offset $5`
|
||||||
|
|
||||||
const cashOutSql = `select 'cashOut' as tx_class,
|
const cashOutSql = `select 'cashOut' as tx_class,
|
||||||
|
|
@ -56,14 +58,22 @@ function batch (from = new Date(0).toISOString(), until = new Date().toISOString
|
||||||
inner join cash_out_actions actions on txs.id = actions.tx_id
|
inner join cash_out_actions actions on txs.id = actions.tx_id
|
||||||
and actions.action = 'provisionAddress'
|
and actions.action = 'provisionAddress'
|
||||||
left outer join customers c on txs.customer_id = c.id
|
left outer join customers c on txs.customer_id = c.id
|
||||||
where txs.created >= $2 and txs.created <= $3
|
where txs.created >= $2 and txs.created <= $3 ${
|
||||||
|
id !== null ? `and txs.device_id = $6` : ``
|
||||||
|
}
|
||||||
order by created desc limit $4 offset $5`
|
order by created desc limit $4 offset $5`
|
||||||
|
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
db.any(cashInSql, [cashInTx.PENDING_INTERVAL, from, until, limit, offset]),
|
db.any(cashInSql, [
|
||||||
db.any(cashOutSql, [REDEEMABLE_AGE, from, until, limit, offset])
|
cashInTx.PENDING_INTERVAL,
|
||||||
])
|
from,
|
||||||
.then(packager)
|
until,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
id
|
||||||
|
]),
|
||||||
|
db.any(cashOutSql, [REDEEMABLE_AGE, from, until, limit, offset, id])
|
||||||
|
]).then(packager)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCustomerTransactionsBatch (ids) {
|
function getCustomerTransactionsBatch (ids) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
const _ = require('lodash/fp')
|
const _ = require('lodash/fp')
|
||||||
const logger = require('./logger')
|
|
||||||
|
|
||||||
const namespaces = {
|
const namespaces = {
|
||||||
WALLETS: 'wallets',
|
WALLETS: 'wallets',
|
||||||
|
|
@ -19,13 +18,6 @@ const filter = namespace => _.pickBy((value, key) => _.startsWith(`${namespace}_
|
||||||
const strip = key => _.mapKeys(stripl(`${key}_`))
|
const strip = key => _.mapKeys(stripl(`${key}_`))
|
||||||
|
|
||||||
const fromNamespace = _.curry((key, config) => _.compose(strip(key), filter(key))(config))
|
const fromNamespace = _.curry((key, config) => _.compose(strip(key), filter(key))(config))
|
||||||
const toNamespace = (key, config) => _.mapKeys(it => `${key}_${it}`)(config)
|
|
||||||
|
|
||||||
const resolveOverrides = (original, filter, overrides, overridesPath = 'overrides') => {
|
|
||||||
if (_.isEmpty(overrides)) return _.omit(overridesPath, original)
|
|
||||||
|
|
||||||
return _.omit(overridesPath, _.assignAll([original, ..._.filter(filter)(overrides)]))
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCommissions = (cryptoCode, deviceId, config) => {
|
const getCommissions = (cryptoCode, deviceId, config) => {
|
||||||
const commissions = fromNamespace(namespaces.COMMISSIONS)(config)
|
const commissions = fromNamespace(namespaces.COMMISSIONS)(config)
|
||||||
|
|
@ -55,7 +47,7 @@ const getLocale = (deviceId, it) => {
|
||||||
const locale = fromNamespace(namespaces.LOCALE)(it)
|
const locale = fromNamespace(namespaces.LOCALE)(it)
|
||||||
|
|
||||||
const filter = _.matches({ machine: deviceId })
|
const filter = _.matches({ machine: deviceId })
|
||||||
return resolveOverrides(locale, filter, locale.overrides)
|
return _.omit('overrides', _.assignAll([locale, ..._.filter(filter)(locale.overrides)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
const getGlobalLocale = it => getLocale(null, it)
|
const getGlobalLocale = it => getLocale(null, it)
|
||||||
|
|
@ -78,17 +70,34 @@ const getAllCryptoCurrencies = (config) => {
|
||||||
const getNotifications = (cryptoCurrency, machine, config) => {
|
const getNotifications = (cryptoCurrency, machine, config) => {
|
||||||
const notifications = fromNamespace(namespaces.NOTIFICATIONS)(config)
|
const notifications = fromNamespace(namespaces.NOTIFICATIONS)(config)
|
||||||
|
|
||||||
const cryptoFilter = _.matches({ cryptoCurrency })
|
const smsSettings = fromNamespace('sms', notifications)
|
||||||
const withCryptoBalance = resolveOverrides(notifications, cryptoFilter, notifications.cryptoBalanceOverrides, 'cryptoBalanceOverrides')
|
const emailSettings = fromNamespace('email', notifications)
|
||||||
|
const notificationCenterSettings = fromNamespace('notificationCenter', notifications)
|
||||||
|
|
||||||
const fiatFilter = _.matches({ machine })
|
const notifNoOverrides = _.omit(['cryptoBalanceOverrides', 'fiatBalanceOverrides'], notifications)
|
||||||
const withFiatBalance = resolveOverrides(withCryptoBalance, fiatFilter, withCryptoBalance.fiatBalanceOverrides, 'fiatBalanceOverrides')
|
|
||||||
|
|
||||||
const withSms = fromNamespace('sms', withFiatBalance)
|
const findByCryptoCurrency = _.find(_.matches({ cryptoCurrency }))
|
||||||
const withEmail = fromNamespace('email', withFiatBalance)
|
const findByMachine = _.find(_.matches({ machine }))
|
||||||
|
|
||||||
const final = { ...withFiatBalance, sms: withSms, email: withEmail }
|
const cryptoFields = ['cryptoHighBalance', 'cryptoLowBalance', 'highBalance', 'lowBalance']
|
||||||
return final
|
const fiatFields = ['fiatBalanceCassette1', 'fiatBalanceCassette2']
|
||||||
|
|
||||||
|
const getCryptoSettings = _.compose(_.pick(cryptoFields), _.defaultTo(notifications), findByCryptoCurrency)
|
||||||
|
const cryptoSettings = getCryptoSettings(notifications.cryptoBalanceOverrides)
|
||||||
|
|
||||||
|
if (cryptoSettings.highBalance) {
|
||||||
|
cryptoSettings['cryptoHighBalance'] = cryptoSettings.highBalance
|
||||||
|
delete cryptoSettings.highBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cryptoSettings.lowBalance) {
|
||||||
|
cryptoSettings['cryptoLowBalance'] = cryptoSettings.lowBalance
|
||||||
|
delete cryptoSettings.lowBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFiatSettings = _.compose(_.pick(fiatFields), _.defaultTo(notifications), findByMachine)
|
||||||
|
const fiatSettings = getFiatSettings(notifications.fiatBalanceOverrides)
|
||||||
|
return { ...notifNoOverrides, sms: smsSettings, email: emailSettings, ...cryptoSettings, ...fiatSettings, notificationCenter: notificationCenterSettings }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getGlobalNotifications = config => getNotifications(null, null, config)
|
const getGlobalNotifications = config => getNotifications(null, null, config)
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,46 @@
|
||||||
const _ = require('lodash/fp')
|
const _ = require('lodash/fp')
|
||||||
const db = require('./db')
|
const db = require('./db')
|
||||||
|
const migration = require('./config-migration')
|
||||||
|
|
||||||
|
const OLD_SETTINGS_LOADER_SCHEMA_VERSION = 1
|
||||||
const NEW_SETTINGS_LOADER_SCHEMA_VERSION = 2
|
const NEW_SETTINGS_LOADER_SCHEMA_VERSION = 2
|
||||||
|
const PASSWORD_FILLED = 'PASSWORD_FILLED'
|
||||||
|
const SECRET_FIELDS = [
|
||||||
|
'bitgo.BTCWalletPassphrase',
|
||||||
|
'bitgo.LTCWalletPassphrase',
|
||||||
|
'bitgo.ZECWalletPassphrase',
|
||||||
|
'bitgo.BCHWalletPassphrase',
|
||||||
|
'bitgo.DASHWalletPassphrase',
|
||||||
|
'bitstamp.secret',
|
||||||
|
'infura.apiSecret',
|
||||||
|
'itbit.clientSecret',
|
||||||
|
'kraken.privateKey',
|
||||||
|
'twilio.authToken'
|
||||||
|
]
|
||||||
|
|
||||||
function saveAccounts (accountsToSave) {
|
const accountsSql = `update user_config set data = $2, valid = $3, schema_version = $4 where type = $1;
|
||||||
const sql = `update user_config set data = $2, valid = $3, schema_version = $4 where type = $1;
|
insert into user_config (type, data, valid, schema_version)
|
||||||
insert into user_config (type, data, valid, schema_version)
|
select $1, $2, $3, $4 where $1 not in (select type from user_config)`
|
||||||
select $1, $2, $3, $4 where $1 not in (select type from user_config)`
|
function saveAccounts (accounts) {
|
||||||
|
|
||||||
return loadAccounts()
|
return loadAccounts()
|
||||||
.then(currentAccounts => {
|
.then(currentAccounts => {
|
||||||
const newAccounts = _.assign(currentAccounts, accountsToSave)
|
const newAccounts = _.merge(currentAccounts, accounts)
|
||||||
return db.none(sql, ['accounts', { accounts: newAccounts }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
return db.none(accountsSql, ['accounts', { accounts: newAccounts }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
function resetAccounts (schemaVersion) {
|
||||||
|
return db.none(
|
||||||
|
accountsSql,
|
||||||
|
[
|
||||||
|
'accounts',
|
||||||
|
{ accounts: NEW_SETTINGS_LOADER_SCHEMA_VERSION ? {} : [] },
|
||||||
|
true,
|
||||||
|
schemaVersion
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function loadAccounts () {
|
function loadAccounts (schemaVersion) {
|
||||||
const sql = `select data
|
const sql = `select data
|
||||||
from user_config
|
from user_config
|
||||||
where type=$1
|
where type=$1
|
||||||
|
|
@ -24,22 +49,45 @@ function loadAccounts () {
|
||||||
order by id desc
|
order by id desc
|
||||||
limit 1`
|
limit 1`
|
||||||
|
|
||||||
return db.oneOrNone(sql, ['accounts', NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
return db.oneOrNone(sql, ['accounts', schemaVersion || NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
||||||
.then(_.compose(_.defaultTo({}), _.get('data.accounts')))
|
.then(_.compose(_.defaultTo({}), _.get('data.accounts')))
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveConfig (config) {
|
function showAccounts (schemaVersion) {
|
||||||
const sql = 'insert into user_config (type, data, valid, schema_version) values ($1, $2, $3, $4)'
|
return loadAccounts(schemaVersion)
|
||||||
|
.then(accounts => {
|
||||||
return loadLatestConfigOrNone()
|
const filledSecretPaths = _.compact(_.map(path => {
|
||||||
.then(currentConfig => {
|
if (!_.isEmpty(_.get(path, accounts))) {
|
||||||
const newConfig = _.assign(currentConfig, config)
|
return path
|
||||||
return db.none(sql, ['config', { config: newConfig }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
}
|
||||||
|
}, SECRET_FIELDS))
|
||||||
|
return _.compose(_.map(path => _.assoc(path, PASSWORD_FILLED), filledSecretPaths))(accounts)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLatest () {
|
const configSql = 'insert into user_config (type, data, valid, schema_version) values ($1, $2, $3, $4)'
|
||||||
return Promise.all([loadLatestConfigOrNone(), loadAccounts()])
|
function saveConfig (config) {
|
||||||
|
return loadLatestConfigOrNone()
|
||||||
|
.then(currentConfig => {
|
||||||
|
const newConfig = _.assign(currentConfig, config)
|
||||||
|
return db.none(configSql, ['config', { config: newConfig }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetConfig (schemaVersion) {
|
||||||
|
return db.none(
|
||||||
|
configSql,
|
||||||
|
[
|
||||||
|
'config',
|
||||||
|
{ config: schemaVersion === NEW_SETTINGS_LOADER_SCHEMA_VERSION ? {} : [] },
|
||||||
|
true,
|
||||||
|
schemaVersion
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLatest (schemaVersion) {
|
||||||
|
return Promise.all([loadLatestConfigOrNone(schemaVersion), loadAccounts(schemaVersion)])
|
||||||
.then(([config, accounts]) => ({
|
.then(([config, accounts]) => ({
|
||||||
config,
|
config,
|
||||||
accounts
|
accounts
|
||||||
|
|
@ -62,7 +110,7 @@ function loadLatestConfig () {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLatestConfigOrNone () {
|
function loadLatestConfigOrNone (schemaVersion) {
|
||||||
const sql = `select data
|
const sql = `select data
|
||||||
from user_config
|
from user_config
|
||||||
where type=$1
|
where type=$1
|
||||||
|
|
@ -70,7 +118,7 @@ function loadLatestConfigOrNone () {
|
||||||
order by id desc
|
order by id desc
|
||||||
limit 1`
|
limit 1`
|
||||||
|
|
||||||
return db.oneOrNone(sql, ['config', NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
return db.oneOrNone(sql, ['config', schemaVersion || NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
||||||
.then(row => row ? row.data.config : {})
|
.then(row => row ? row.data.config : {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,12 +151,27 @@ function load (versionId) {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrate () {
|
||||||
|
return loadLatest(OLD_SETTINGS_LOADER_SCHEMA_VERSION)
|
||||||
|
.then(res => {
|
||||||
|
const migrated = migration.migrate(res.config, res.accounts)
|
||||||
|
saveConfig(migrated.config)
|
||||||
|
saveAccounts(migrated.accounts)
|
||||||
|
|
||||||
|
return migrated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
saveConfig,
|
saveConfig,
|
||||||
|
resetConfig,
|
||||||
saveAccounts,
|
saveAccounts,
|
||||||
|
resetAccounts,
|
||||||
loadAccounts,
|
loadAccounts,
|
||||||
|
showAccounts,
|
||||||
loadLatest,
|
loadLatest,
|
||||||
loadLatestConfig,
|
loadLatestConfig,
|
||||||
loadLatestConfigOrNone,
|
loadLatestConfigOrNone,
|
||||||
load
|
load,
|
||||||
|
migrate
|
||||||
}
|
}
|
||||||
|
|
|
||||||
351
lib/notifier.js
351
lib/notifier.js
|
|
@ -1,351 +0,0 @@
|
||||||
const crypto = require('crypto')
|
|
||||||
const _ = require('lodash/fp')
|
|
||||||
const prettyMs = require('pretty-ms')
|
|
||||||
const numeral = require('numeral')
|
|
||||||
|
|
||||||
const dbm = require('./postgresql_interface')
|
|
||||||
const db = require('./db')
|
|
||||||
const T = require('./time')
|
|
||||||
const logger = require('./logger')
|
|
||||||
|
|
||||||
const STALE_STATE = 7 * T.minute
|
|
||||||
const NETWORK_DOWN_TIME = 1 * T.minute
|
|
||||||
const ALERT_SEND_INTERVAL = T.hour
|
|
||||||
|
|
||||||
const PING = 'PING'
|
|
||||||
const STALE = 'STALE'
|
|
||||||
const LOW_CRYPTO_BALANCE = 'LOW_CRYPTO_BALANCE'
|
|
||||||
const HIGH_CRYPTO_BALANCE = 'HIGH_CRYPTO_BALANCE'
|
|
||||||
const CASH_BOX_FULL = 'CASH_BOX_FULL'
|
|
||||||
const LOW_CASH_OUT = 'LOW_CASH_OUT'
|
|
||||||
|
|
||||||
const CODES_DISPLAY = {
|
|
||||||
PING: 'Machine Down',
|
|
||||||
STALE: 'Machine Stuck',
|
|
||||||
LOW_CRYPTO_BALANCE: 'Low Crypto Balance',
|
|
||||||
HIGH_CRYPTO_BALANCE: 'High Crypto Balance',
|
|
||||||
CASH_BOX_FULL: 'Cash box full',
|
|
||||||
LOW_CASH_OUT: 'Low Cash-out'
|
|
||||||
}
|
|
||||||
|
|
||||||
let alertFingerprint
|
|
||||||
let lastAlertTime
|
|
||||||
|
|
||||||
function codeDisplay (code) {
|
|
||||||
return CODES_DISPLAY[code]
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonParse (event) {
|
|
||||||
return _.set('note', JSON.parse(event.note), event)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameState (a, b) {
|
|
||||||
return a.note.txId === b.note.txId && a.note.state === b.note.state
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendNoAlerts (plugins, smsEnabled, emailEnabled) {
|
|
||||||
const subject = '[Lamassu] All clear'
|
|
||||||
|
|
||||||
let rec = {}
|
|
||||||
if (smsEnabled) {
|
|
||||||
rec = _.set(['sms', 'body'])(subject)(rec)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emailEnabled) {
|
|
||||||
rec = _.set(['email', 'subject'])(subject)(rec)
|
|
||||||
rec = _.set(['email', 'body'])('No errors are reported for your machines.')(rec)
|
|
||||||
}
|
|
||||||
|
|
||||||
return plugins.sendMessage(rec)
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkNotification (plugins) {
|
|
||||||
const notifications = plugins.getNotificationConfig()
|
|
||||||
const isActive = it => it.active && (it.balance || it.errors)
|
|
||||||
const smsEnabled = isActive(notifications.sms)
|
|
||||||
const emailEnabled = isActive(notifications.email)
|
|
||||||
|
|
||||||
if (!smsEnabled && !emailEnabled) return Promise.resolve()
|
|
||||||
|
|
||||||
return checkStatus(plugins)
|
|
||||||
.then(alertRec => {
|
|
||||||
const currentAlertFingerprint = buildAlertFingerprint(alertRec, notifications)
|
|
||||||
if (!currentAlertFingerprint) {
|
|
||||||
const inAlert = !!alertFingerprint
|
|
||||||
alertFingerprint = null
|
|
||||||
lastAlertTime = null
|
|
||||||
if (inAlert) return sendNoAlerts(plugins, smsEnabled, emailEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
const alertChanged = currentAlertFingerprint === alertFingerprint &&
|
|
||||||
lastAlertTime - Date.now() < ALERT_SEND_INTERVAL
|
|
||||||
if (alertChanged) return
|
|
||||||
|
|
||||||
let rec = {}
|
|
||||||
if (smsEnabled) {
|
|
||||||
rec = _.set(['sms', 'body'])(printSmsAlerts(alertRec, notifications.sms))(rec)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emailEnabled) {
|
|
||||||
rec = _.set(['email', 'subject'])(alertSubject(alertRec, notifications.email))(rec)
|
|
||||||
rec = _.set(['email', 'body'])(printEmailAlerts(alertRec, notifications.email))(rec)
|
|
||||||
}
|
|
||||||
|
|
||||||
alertFingerprint = currentAlertFingerprint
|
|
||||||
lastAlertTime = Date.now()
|
|
||||||
|
|
||||||
return plugins.sendMessage(rec)
|
|
||||||
})
|
|
||||||
.then(results => {
|
|
||||||
if (results && results.length > 0) logger.debug('Successfully sent alerts')
|
|
||||||
})
|
|
||||||
.catch(logger.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDeviceTime = _.flow(_.get('device_time'), Date.parse)
|
|
||||||
|
|
||||||
function dropRepeatsWith (comparator, arr) {
|
|
||||||
const iteratee = (acc, val) => val === acc.last
|
|
||||||
? acc
|
|
||||||
: { arr: _.concat(acc.arr, val), last: val }
|
|
||||||
|
|
||||||
return _.reduce(iteratee, { arr: [] }, arr).arr
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkStuckScreen (deviceEvents, machineName) {
|
|
||||||
const sortedEvents = _.sortBy(getDeviceTime, _.map(jsonParse, deviceEvents))
|
|
||||||
const noRepeatEvents = dropRepeatsWith(sameState, sortedEvents)
|
|
||||||
const lastEvent = _.last(noRepeatEvents)
|
|
||||||
|
|
||||||
if (!lastEvent) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = lastEvent.note.state
|
|
||||||
const isIdle = lastEvent.note.isIdle
|
|
||||||
|
|
||||||
if (isIdle) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const age = Math.floor(lastEvent.age)
|
|
||||||
if (age > STALE_STATE) {
|
|
||||||
return [{ code: STALE, state, age, machineName }]
|
|
||||||
}
|
|
||||||
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkPing (device) {
|
|
||||||
const sql = `select (EXTRACT(EPOCH FROM (now() - updated))) * 1000 AS age from machine_pings
|
|
||||||
where device_id=$1`
|
|
||||||
const deviceId = device.deviceId
|
|
||||||
return db.oneOrNone(sql, [deviceId])
|
|
||||||
.then(row => {
|
|
||||||
if (!row) return [{ code: PING }]
|
|
||||||
if (row.age > NETWORK_DOWN_TIME) return [{ code: PING, age: row.age, machineName: device.name }]
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkPings (devices) {
|
|
||||||
const deviceIds = _.map('deviceId', devices)
|
|
||||||
const promises = _.map(checkPing, devices)
|
|
||||||
|
|
||||||
return Promise.all(promises)
|
|
||||||
.then(_.zipObject(deviceIds))
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkStatus (plugins) {
|
|
||||||
const alerts = { devices: {}, deviceNames: {} }
|
|
||||||
|
|
||||||
return Promise.all([plugins.checkBalances(), dbm.machineEvents(), plugins.getMachineNames()])
|
|
||||||
.then(([balances, events, devices]) => {
|
|
||||||
return checkPings(devices)
|
|
||||||
.then(pings => {
|
|
||||||
alerts.general = _.filter(r => !r.deviceId, balances)
|
|
||||||
devices.forEach(function (device) {
|
|
||||||
const deviceId = device.deviceId
|
|
||||||
const deviceName = device.name
|
|
||||||
const deviceEvents = events.filter(function (eventRow) {
|
|
||||||
return eventRow.device_id === deviceId
|
|
||||||
})
|
|
||||||
|
|
||||||
const ping = pings[deviceId] || []
|
|
||||||
const stuckScreen = checkStuckScreen(deviceEvents, deviceName)
|
|
||||||
|
|
||||||
if (!alerts.devices[deviceId]) alerts.devices[deviceId] = {}
|
|
||||||
alerts.devices[deviceId].balanceAlerts = _.filter(['deviceId', deviceId], balances)
|
|
||||||
alerts.devices[deviceId].deviceAlerts = _.isEmpty(ping) ? stuckScreen : ping
|
|
||||||
|
|
||||||
alerts.deviceNames[deviceId] = deviceName
|
|
||||||
})
|
|
||||||
|
|
||||||
return alerts
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCurrency (num, code) {
|
|
||||||
return numeral(num).format('0,0.00') + ' ' + code
|
|
||||||
}
|
|
||||||
|
|
||||||
function emailAlert (alert) {
|
|
||||||
switch (alert.code) {
|
|
||||||
case PING:
|
|
||||||
if (alert.age) {
|
|
||||||
const pingAge = prettyMs(alert.age, { compact: true, verbose: true })
|
|
||||||
return `Machine down for ${pingAge}`
|
|
||||||
}
|
|
||||||
return 'Machine down for a while.'
|
|
||||||
case STALE:
|
|
||||||
const stuckAge = prettyMs(alert.age, { compact: true, verbose: true })
|
|
||||||
return `Machine is stuck on ${alert.state} screen for ${stuckAge}`
|
|
||||||
case LOW_CRYPTO_BALANCE:
|
|
||||||
const balance = formatCurrency(alert.fiatBalance.balance, alert.fiatCode)
|
|
||||||
return `Low balance in ${alert.cryptoCode} [${balance}]`
|
|
||||||
case HIGH_CRYPTO_BALANCE:
|
|
||||||
const highBalance = formatCurrency(alert.fiatBalance.balance, alert.fiatCode)
|
|
||||||
return `High balance in ${alert.cryptoCode} [${highBalance}]`
|
|
||||||
case CASH_BOX_FULL:
|
|
||||||
return `Cash box full on ${alert.machineName} [${alert.notes} banknotes]`
|
|
||||||
case LOW_CASH_OUT:
|
|
||||||
return `Cassette for ${alert.denomination} ${alert.fiatCode} low [${alert.notes} banknotes]`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function emailAlerts (alerts) {
|
|
||||||
return alerts.map(emailAlert).join('\n') + '\n'
|
|
||||||
}
|
|
||||||
|
|
||||||
function printEmailAlerts (alertRec, config) {
|
|
||||||
let body = 'Errors were reported by your Lamassu Machines.\n'
|
|
||||||
|
|
||||||
if (config.balance && alertRec.general.length !== 0) {
|
|
||||||
body = body + '\nGeneral errors:\n'
|
|
||||||
body = body + emailAlerts(alertRec.general) + '\n'
|
|
||||||
}
|
|
||||||
|
|
||||||
_.keys(alertRec.devices).forEach(function (device) {
|
|
||||||
const deviceName = alertRec.deviceNames[device]
|
|
||||||
body = body + '\nErrors for ' + deviceName + ':\n'
|
|
||||||
|
|
||||||
let alerts = []
|
|
||||||
if (config.balance) {
|
|
||||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.errors) {
|
|
||||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
|
||||||
}
|
|
||||||
|
|
||||||
body = body + emailAlerts(alerts)
|
|
||||||
})
|
|
||||||
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
function alertSubject (alertRec, config) {
|
|
||||||
let alerts = []
|
|
||||||
|
|
||||||
if (config.balance) {
|
|
||||||
alerts = _.concat(alerts, alertRec.general)
|
|
||||||
}
|
|
||||||
|
|
||||||
_.keys(alertRec.devices).forEach(function (device) {
|
|
||||||
if (config.balance) {
|
|
||||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.errors) {
|
|
||||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (alerts.length === 0) return null
|
|
||||||
|
|
||||||
const alertTypes = _.map(codeDisplay, _.uniq(_.map('code', alerts))).sort()
|
|
||||||
return '[Lamassu] Errors reported: ' + alertTypes.join(', ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function printSmsAlerts (alertRec, config) {
|
|
||||||
let alerts = []
|
|
||||||
|
|
||||||
if (config.balance) {
|
|
||||||
alerts = _.concat(alerts, alertRec.general)
|
|
||||||
}
|
|
||||||
|
|
||||||
_.keys(alertRec.devices).forEach(function (device) {
|
|
||||||
if (config.balance) {
|
|
||||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.errors) {
|
|
||||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (alerts.length === 0) return null
|
|
||||||
|
|
||||||
const alertsMap = _.groupBy('code', alerts)
|
|
||||||
|
|
||||||
const alertTypes = _.map(entry => {
|
|
||||||
const code = entry[0]
|
|
||||||
const machineNames = _.filter(_.negate(_.isEmpty), _.map('machineName', entry[1]))
|
|
||||||
|
|
||||||
return {
|
|
||||||
codeDisplay: codeDisplay(code),
|
|
||||||
machineNames
|
|
||||||
}
|
|
||||||
}, _.toPairs(alertsMap))
|
|
||||||
|
|
||||||
const mapByCodeDisplay = _.map(it => _.isEmpty(it.machineNames)
|
|
||||||
? it.codeDisplay : `${it.codeDisplay} (${it.machineNames.join(', ')})`
|
|
||||||
)
|
|
||||||
|
|
||||||
const displayAlertTypes = _.compose(_.uniq, mapByCodeDisplay, _.sortBy('codeDisplay'))(alertTypes)
|
|
||||||
|
|
||||||
return '[Lamassu] Errors reported: ' + displayAlertTypes.join(', ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAlertTypes (alertRec, config) {
|
|
||||||
let alerts = []
|
|
||||||
|
|
||||||
if (!config.active || (!config.balance && !config.errors)) return alerts
|
|
||||||
|
|
||||||
if (config.balance) {
|
|
||||||
alerts = _.concat(alerts, alertRec.general)
|
|
||||||
}
|
|
||||||
|
|
||||||
_.keys(alertRec.devices).forEach(function (device) {
|
|
||||||
if (config.balance) {
|
|
||||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.errors) {
|
|
||||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAlertFingerprint (alertRec, notifications) {
|
|
||||||
const sms = getAlertTypes(alertRec, notifications.sms)
|
|
||||||
const email = getAlertTypes(alertRec, notifications.email)
|
|
||||||
|
|
||||||
if (sms.length === 0 && email.length === 0) return null
|
|
||||||
|
|
||||||
const smsTypes = _.map(codeDisplay, _.uniq(_.map('code', sms))).sort()
|
|
||||||
const emailTypes = _.map(codeDisplay, _.uniq(_.map('code', email))).sort()
|
|
||||||
|
|
||||||
const subject = _.concat(smsTypes, emailTypes).join(', ')
|
|
||||||
|
|
||||||
return crypto.createHash('sha256').update(subject).digest('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
checkNotification,
|
|
||||||
checkPings,
|
|
||||||
checkStuckScreen
|
|
||||||
}
|
|
||||||
44
lib/notifier/codes.js
Normal file
44
lib/notifier/codes.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
const T = require('../time')
|
||||||
|
|
||||||
|
const PING = 'PING'
|
||||||
|
const STALE = 'STALE'
|
||||||
|
const LOW_CRYPTO_BALANCE = 'LOW_CRYPTO_BALANCE'
|
||||||
|
const HIGH_CRYPTO_BALANCE = 'HIGH_CRYPTO_BALANCE'
|
||||||
|
const CASH_BOX_FULL = 'CASH_BOX_FULL'
|
||||||
|
const LOW_CASH_OUT = 'LOW_CASH_OUT'
|
||||||
|
|
||||||
|
const 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'
|
||||||
|
}
|
||||||
|
|
||||||
|
const NETWORK_DOWN_TIME = 1 * T.minute
|
||||||
|
const STALE_STATE = 7 * T.minute
|
||||||
|
const ALERT_SEND_INTERVAL = T.hour
|
||||||
|
|
||||||
|
const NOTIFICATION_TYPES = {
|
||||||
|
HIGH_VALUE_TX: 'highValueTransaction',
|
||||||
|
NORMAL_VALUE_TX: 'transaction',
|
||||||
|
FIAT_BALANCE: 'fiatBalance',
|
||||||
|
CRYPTO_BALANCE: 'cryptoBalance',
|
||||||
|
COMPLIANCE: 'compliance',
|
||||||
|
ERROR: 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PING,
|
||||||
|
STALE,
|
||||||
|
LOW_CRYPTO_BALANCE,
|
||||||
|
HIGH_CRYPTO_BALANCE,
|
||||||
|
CASH_BOX_FULL,
|
||||||
|
LOW_CASH_OUT,
|
||||||
|
CODES_DISPLAY,
|
||||||
|
NETWORK_DOWN_TIME,
|
||||||
|
STALE_STATE,
|
||||||
|
ALERT_SEND_INTERVAL,
|
||||||
|
NOTIFICATION_TYPES
|
||||||
|
}
|
||||||
87
lib/notifier/email.js
Normal file
87
lib/notifier/email.js
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
const _ = require('lodash/fp')
|
||||||
|
const utils = require('./utils')
|
||||||
|
|
||||||
|
const email = require('../email')
|
||||||
|
|
||||||
|
const {
|
||||||
|
PING,
|
||||||
|
STALE,
|
||||||
|
LOW_CRYPTO_BALANCE,
|
||||||
|
HIGH_CRYPTO_BALANCE,
|
||||||
|
CASH_BOX_FULL,
|
||||||
|
LOW_CASH_OUT
|
||||||
|
} = require('./codes')
|
||||||
|
|
||||||
|
function alertSubject (alertRec, config) {
|
||||||
|
let alerts = []
|
||||||
|
|
||||||
|
if (config.balance) {
|
||||||
|
alerts = _.concat(alerts, alertRec.general)
|
||||||
|
}
|
||||||
|
|
||||||
|
_.forEach(device => {
|
||||||
|
alerts = _.concat(alerts, utils.deviceAlerts(config, alertRec, device))
|
||||||
|
}, _.keys(alertRec.devices))
|
||||||
|
|
||||||
|
if (alerts.length === 0) return null
|
||||||
|
|
||||||
|
const alertTypes = _.flow(_.map('code'), _.uniq, _.map(utils.codeDisplay), _.sortBy(o => o))(alerts)
|
||||||
|
return '[Lamassu] Errors reported: ' + alertTypes.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function printEmailAlerts (alertRec, config) {
|
||||||
|
let body = 'Errors were reported by your Lamassu Machines.\n'
|
||||||
|
|
||||||
|
if (config.balance && alertRec.general.length !== 0) {
|
||||||
|
body += '\nGeneral errors:\n'
|
||||||
|
body += emailAlerts(alertRec.general) + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
_.forEach(device => {
|
||||||
|
const deviceName = alertRec.deviceNames[device]
|
||||||
|
body += '\nErrors for ' + deviceName + ':\n'
|
||||||
|
|
||||||
|
const alerts = utils.deviceAlerts(config, alertRec, device)
|
||||||
|
|
||||||
|
body += emailAlerts(alerts)
|
||||||
|
}, _.keys(alertRec.devices))
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailAlerts (alerts) {
|
||||||
|
return _.join('\n', _.map(emailAlert, alerts)) + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailAlert (alert) {
|
||||||
|
switch (alert.code) {
|
||||||
|
case PING:
|
||||||
|
if (alert.age) {
|
||||||
|
const pingAge = utils.formatAge(alert.age, { compact: true, verbose: true })
|
||||||
|
return `Machine down for ${pingAge}`
|
||||||
|
}
|
||||||
|
return 'Machine down for a while.'
|
||||||
|
case STALE: {
|
||||||
|
const stuckAge = utils.formatAge(alert.age, { compact: true, verbose: true })
|
||||||
|
return `Machine is stuck on ${alert.state} screen for ${stuckAge}`
|
||||||
|
}
|
||||||
|
case LOW_CRYPTO_BALANCE: {
|
||||||
|
const balance = utils.formatCurrency(alert.fiatBalance.balance, alert.fiatCode)
|
||||||
|
return `Low balance in ${alert.cryptoCode} [${balance}]`
|
||||||
|
}
|
||||||
|
case HIGH_CRYPTO_BALANCE: {
|
||||||
|
const highBalance = utils.formatCurrency(
|
||||||
|
alert.fiatBalance.balance,
|
||||||
|
alert.fiatCode
|
||||||
|
)
|
||||||
|
return `High balance in ${alert.cryptoCode} [${highBalance}]`
|
||||||
|
}
|
||||||
|
case CASH_BOX_FULL:
|
||||||
|
return `Cash box full on ${alert.machineName} [${alert.notes} banknotes]`
|
||||||
|
case LOW_CASH_OUT:
|
||||||
|
return `Cassette for ${alert.denomination} ${alert.fiatCode} low [${alert.notes} banknotes]`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage = email.sendMessage
|
||||||
|
|
||||||
|
module.exports = { alertSubject, printEmailAlerts, sendMessage }
|
||||||
224
lib/notifier/index.js
Normal file
224
lib/notifier/index.js
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
const _ = require('lodash/fp')
|
||||||
|
|
||||||
|
const configManager = require('../new-config-manager')
|
||||||
|
const logger = require('../logger')
|
||||||
|
const queries = require('./queries')
|
||||||
|
const settingsLoader = require('../new-settings-loader')
|
||||||
|
const customers = require('../customers')
|
||||||
|
|
||||||
|
const notificationCenter = require('./notificationCenter')
|
||||||
|
const utils = require('./utils')
|
||||||
|
const emailFuncs = require('./email')
|
||||||
|
const smsFuncs = require('./sms')
|
||||||
|
const { STALE, STALE_STATE } = require('./codes')
|
||||||
|
|
||||||
|
function buildMessage (alerts, notifications) {
|
||||||
|
const smsEnabled = utils.isActive(notifications.sms)
|
||||||
|
const emailEnabled = utils.isActive(notifications.email)
|
||||||
|
|
||||||
|
let rec = {}
|
||||||
|
if (smsEnabled) {
|
||||||
|
rec = _.set(['sms', 'body'])(
|
||||||
|
smsFuncs.printSmsAlerts(alerts, notifications.sms)
|
||||||
|
)(rec)
|
||||||
|
}
|
||||||
|
if (emailEnabled) {
|
||||||
|
rec = _.set(['email', 'subject'])(
|
||||||
|
emailFuncs.alertSubject(alerts, notifications.email)
|
||||||
|
)(rec)
|
||||||
|
rec = _.set(['email', 'body'])(
|
||||||
|
emailFuncs.printEmailAlerts(alerts, notifications.email)
|
||||||
|
)(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkNotification (plugins) {
|
||||||
|
const notifications = plugins.getNotificationConfig()
|
||||||
|
const smsEnabled = utils.isActive(notifications.sms)
|
||||||
|
const emailEnabled = utils.isActive(notifications.email)
|
||||||
|
|
||||||
|
if (!smsEnabled && !emailEnabled) return Promise.resolve()
|
||||||
|
|
||||||
|
return getAlerts(plugins)
|
||||||
|
.then(alerts => {
|
||||||
|
notifyIfActive('errors', 'errorAlertsNotify', alerts)
|
||||||
|
const currentAlertFingerprint = utils.buildAlertFingerprint(
|
||||||
|
alerts,
|
||||||
|
notifications
|
||||||
|
)
|
||||||
|
if (!currentAlertFingerprint) {
|
||||||
|
const inAlert = !!utils.getAlertFingerprint()
|
||||||
|
// variables for setAlertFingerprint: (fingerprint = null, lastAlertTime = null)
|
||||||
|
utils.setAlertFingerprint(null, null)
|
||||||
|
if (inAlert) return utils.sendNoAlerts(plugins, smsEnabled, emailEnabled)
|
||||||
|
}
|
||||||
|
if (utils.shouldNotAlert(currentAlertFingerprint)) return
|
||||||
|
|
||||||
|
const message = buildMessage(alerts, notifications)
|
||||||
|
utils.setAlertFingerprint(currentAlertFingerprint, Date.now())
|
||||||
|
return plugins.sendMessage(message)
|
||||||
|
})
|
||||||
|
.then(results => {
|
||||||
|
if (results && results.length > 0) {
|
||||||
|
logger.debug('Successfully sent alerts')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(logger.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlerts (plugins) {
|
||||||
|
return Promise.all([
|
||||||
|
plugins.checkBalances(),
|
||||||
|
queries.machineEvents(),
|
||||||
|
plugins.getMachineNames()
|
||||||
|
]).then(([balances, events, devices]) => {
|
||||||
|
notifyIfActive('balance', 'balancesNotify', balances)
|
||||||
|
return buildAlerts(checkPings(devices), balances, events, devices)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAlerts (pings, balances, events, devices) {
|
||||||
|
const alerts = { devices: {}, deviceNames: {} }
|
||||||
|
alerts.general = _.filter(r => !r.deviceId, balances)
|
||||||
|
_.forEach(device => {
|
||||||
|
const deviceId = device.deviceId
|
||||||
|
const deviceName = device.name
|
||||||
|
const deviceEvents = events.filter(function (eventRow) {
|
||||||
|
return eventRow.device_id === deviceId
|
||||||
|
})
|
||||||
|
const ping = pings[deviceId] || []
|
||||||
|
const stuckScreen = checkStuckScreen(deviceEvents, deviceName)
|
||||||
|
|
||||||
|
alerts.devices = _.set([deviceId, 'balanceAlerts'], _.filter(
|
||||||
|
['deviceId', deviceId],
|
||||||
|
balances
|
||||||
|
), alerts.devices)
|
||||||
|
alerts.devices[deviceId].deviceAlerts = _.isEmpty(ping) ? stuckScreen : ping
|
||||||
|
|
||||||
|
alerts.deviceNames[deviceId] = deviceName
|
||||||
|
}, devices)
|
||||||
|
|
||||||
|
return alerts
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPings (devices) {
|
||||||
|
const deviceIds = _.map('deviceId', devices)
|
||||||
|
const pings = _.map(utils.checkPing, devices)
|
||||||
|
return _.zipObject(deviceIds)(pings)
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStuckScreen (deviceEvents, machineName) {
|
||||||
|
const sortedEvents = _.sortBy(
|
||||||
|
utils.getDeviceTime,
|
||||||
|
_.map(utils.parseEventNote, deviceEvents)
|
||||||
|
)
|
||||||
|
const lastEvent = _.last(sortedEvents)
|
||||||
|
|
||||||
|
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 transactionNotify (tx, rec) {
|
||||||
|
return settingsLoader.loadLatest().then(settings => {
|
||||||
|
const notifSettings = configManager.getGlobalNotifications(settings.config)
|
||||||
|
const highValueTx = tx.fiat.gt(notifSettings.highValueTransaction || Infinity)
|
||||||
|
const isCashOut = tx.direction === 'cashOut'
|
||||||
|
|
||||||
|
// for notification center
|
||||||
|
const directionDisplay = isCashOut ? 'cash-out' : 'cash-in'
|
||||||
|
const readyToNotify = !isCashOut || (tx.direction === 'cashOut' && rec.isRedemption)
|
||||||
|
// awaiting for redesign. notification should not be sent if toggle in the settings table is disabled,
|
||||||
|
// but currently we're sending notifications of high value tx even with the toggle disabled
|
||||||
|
if (readyToNotify && !highValueTx) {
|
||||||
|
notifyIfActive('transactions', 'notifCenterTransactionNotify', highValueTx, directionDisplay, tx.fiat, tx.fiatCode, tx.deviceId, tx.toAddress)
|
||||||
|
} else if (readyToNotify && highValueTx) {
|
||||||
|
notificationCenter.notifCenterTransactionNotify(highValueTx, directionDisplay, tx.fiat, tx.fiatCode, tx.deviceId, tx.toAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// alert through sms or email any transaction or high value transaction, if SMS || email alerts are enabled
|
||||||
|
const cashOutConfig = configManager.getCashOut(tx.deviceId, settings.config)
|
||||||
|
const zeroConfLimit = cashOutConfig.zeroConfLimit
|
||||||
|
const zeroConf = isCashOut && tx.fiat.lte(zeroConfLimit)
|
||||||
|
const notificationsEnabled = notifSettings.sms.transactions || notifSettings.email.transactions
|
||||||
|
const customerPromise = tx.customerId ? customers.getById(tx.customerId) : Promise.resolve({})
|
||||||
|
|
||||||
|
if (!notificationsEnabled && !highValueTx) return Promise.resolve()
|
||||||
|
if (zeroConf && isCashOut && !rec.isRedemption && !rec.error) return Promise.resolve()
|
||||||
|
if (!zeroConf && rec.isRedemption) return sendRedemptionMessage(tx.id, rec.error)
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
queries.getMachineName(tx.deviceId),
|
||||||
|
customerPromise
|
||||||
|
]).then(([machineName, customer]) => {
|
||||||
|
return utils.buildTransactionMessage(tx, rec, highValueTx, machineName, customer)
|
||||||
|
}).then(([msg, highValueTx]) => sendTransactionMessage(msg, highValueTx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendRedemptionMessage (txId, error) {
|
||||||
|
const subject = `Here's an update on transaction ${txId}`
|
||||||
|
const body = error
|
||||||
|
? `Error: ${error}`
|
||||||
|
: 'It was just dispensed successfully'
|
||||||
|
|
||||||
|
const rec = {
|
||||||
|
sms: {
|
||||||
|
body: `${subject} - ${body}`
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
subject,
|
||||||
|
body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sendTransactionMessage(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendTransactionMessage (rec, isHighValueTx) {
|
||||||
|
return settingsLoader.loadLatest().then(settings => {
|
||||||
|
const notifications = configManager.getGlobalNotifications(settings.config)
|
||||||
|
|
||||||
|
const promises = []
|
||||||
|
|
||||||
|
const emailActive =
|
||||||
|
notifications.email.active &&
|
||||||
|
(notifications.email.transactions || isHighValueTx)
|
||||||
|
if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec))
|
||||||
|
|
||||||
|
const smsActive =
|
||||||
|
notifications.sms.active &&
|
||||||
|
(notifications.sms.transactions || isHighValueTx)
|
||||||
|
if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec))
|
||||||
|
|
||||||
|
return Promise.all(promises)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// for notification center, check if type of notification is active before calling the respective notify function
|
||||||
|
const notifyIfActive = (type, fnName, ...args) => {
|
||||||
|
return settingsLoader.loadLatest().then(settings => {
|
||||||
|
const notificationSettings = configManager.getGlobalNotifications(settings.config).notificationCenter
|
||||||
|
if (!notificationCenter[fnName]) return Promise.reject(new Error(`Notification function ${fnName} for type ${type} does not exist`))
|
||||||
|
if (!(notificationSettings.active && notificationSettings[type])) return Promise.resolve()
|
||||||
|
return notificationCenter[fnName](...args)
|
||||||
|
}).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
transactionNotify,
|
||||||
|
checkNotification,
|
||||||
|
checkPings,
|
||||||
|
checkStuckScreen,
|
||||||
|
sendRedemptionMessage,
|
||||||
|
notifyIfActive
|
||||||
|
}
|
||||||
187
lib/notifier/notificationCenter.js
Normal file
187
lib/notifier/notificationCenter.js
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
const _ = require('lodash/fp')
|
||||||
|
|
||||||
|
const queries = require('./queries')
|
||||||
|
const utils = require('./utils')
|
||||||
|
const codes = require('./codes')
|
||||||
|
const customers = require('../customers')
|
||||||
|
|
||||||
|
const { NOTIFICATION_TYPES: {
|
||||||
|
COMPLIANCE,
|
||||||
|
CRYPTO_BALANCE,
|
||||||
|
FIAT_BALANCE,
|
||||||
|
ERROR,
|
||||||
|
HIGH_VALUE_TX,
|
||||||
|
NORMAL_VALUE_TX }
|
||||||
|
} = codes
|
||||||
|
|
||||||
|
const { STALE, PING } = codes
|
||||||
|
|
||||||
|
const sanctionsNotify = (customer, phone) => {
|
||||||
|
const code = 'SANCTIONS'
|
||||||
|
const detailB = utils.buildDetail({ customerId: customer.id, code })
|
||||||
|
|
||||||
|
// if it's a new customer then phone comes as undefined
|
||||||
|
if (phone) {
|
||||||
|
return queries.addNotification(COMPLIANCE, `Blocked customer with phone ${phone} for being on the OFAC sanctions list`, detailB)
|
||||||
|
}
|
||||||
|
return customers.getById(customer.id).then(c => queries.addNotification(COMPLIANCE, `Blocked customer with phone ${c.phone} for being on the OFAC sanctions list`, detailB))
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearOldCustomerSuspendedNotifications = (customerId, deviceId) => {
|
||||||
|
const detailB = utils.buildDetail({ code: 'SUSPENDED', customerId, deviceId })
|
||||||
|
return queries.invalidateNotification(detailB, 'compliance')
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerComplianceNotify = (customer, deviceId, code, days = null) => {
|
||||||
|
// code for now can be "BLOCKED", "SUSPENDED"
|
||||||
|
const detailB = utils.buildDetail({ customerId: customer.id, code, deviceId })
|
||||||
|
const date = new Date()
|
||||||
|
if (days) {
|
||||||
|
date.setDate(date.getDate() + days)
|
||||||
|
}
|
||||||
|
const message = code === 'SUSPENDED' ? `Customer suspended until ${date.toLocaleString()}` : `Customer blocked`
|
||||||
|
|
||||||
|
return clearOldCustomerSuspendedNotifications(customer.id, deviceId)
|
||||||
|
.then(() => queries.getValidNotifications(COMPLIANCE, detailB))
|
||||||
|
.then(res => {
|
||||||
|
if (res.length > 0) return Promise.resolve()
|
||||||
|
return queries.addNotification(COMPLIANCE, message, detailB)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearOldFiatNotifications = (balances) => {
|
||||||
|
return queries.getAllValidNotifications(FIAT_BALANCE).then(notifications => {
|
||||||
|
const filterByBalance = _.filter(notification => {
|
||||||
|
const { cassette, deviceId } = notification.detail
|
||||||
|
return !_.find(balance => balance.cassette === cassette && balance.deviceId === deviceId)(balances)
|
||||||
|
})
|
||||||
|
const indexesToInvalidate = _.compose(_.map('id'), filterByBalance)(notifications)
|
||||||
|
const notInvalidated = _.filter(notification => {
|
||||||
|
return !_.find(id => notification.id === id)(indexesToInvalidate)
|
||||||
|
}, notifications)
|
||||||
|
return (indexesToInvalidate.length ? queries.batchInvalidate(indexesToInvalidate) : Promise.resolve()).then(() => notInvalidated)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fiatBalancesNotify = (fiatWarnings) => {
|
||||||
|
return clearOldFiatNotifications(fiatWarnings).then(notInvalidated => {
|
||||||
|
return fiatWarnings.forEach(balance => {
|
||||||
|
if (_.find(o => {
|
||||||
|
const { cassette, deviceId } = o.detail
|
||||||
|
return cassette === balance.cassette && deviceId === balance.deviceId
|
||||||
|
}, notInvalidated)) return
|
||||||
|
const message = `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 clearOldCryptoNotifications = balances => {
|
||||||
|
return queries.getAllValidNotifications(CRYPTO_BALANCE).then(res => {
|
||||||
|
const filterByBalance = _.filter(notification => {
|
||||||
|
const { cryptoCode, code } = notification.detail
|
||||||
|
return !_.find(balance => balance.cryptoCode === cryptoCode && balance.code === code)(balances)
|
||||||
|
})
|
||||||
|
const indexesToInvalidate = _.compose(_.map('id'), filterByBalance)(res)
|
||||||
|
|
||||||
|
const notInvalidated = _.filter(notification => {
|
||||||
|
return !_.find(id => notification.id === id)(indexesToInvalidate)
|
||||||
|
}, res)
|
||||||
|
return (indexesToInvalidate.length ? queries.batchInvalidate(indexesToInvalidate) : Promise.resolve()).then(() => notInvalidated)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cryptoBalancesNotify = (cryptoWarnings) => {
|
||||||
|
return clearOldCryptoNotifications(cryptoWarnings).then(notInvalidated => {
|
||||||
|
return cryptoWarnings.forEach(balance => {
|
||||||
|
// if notification exists in DB and wasnt invalidated then don't add a duplicate
|
||||||
|
if (_.find(o => {
|
||||||
|
const { code, cryptoCode } = o.detail
|
||||||
|
return code === balance.code && cryptoCode === balance.cryptoCode
|
||||||
|
}, notInvalidated)) return
|
||||||
|
|
||||||
|
const fiat = utils.formatCurrency(balance.fiatBalance.balance, balance.fiatCode)
|
||||||
|
const message = `${balance.code === 'HIGH_CRYPTO_BALANCE' ? 'High' : 'Low'} balance in ${balance.cryptoCode} [${fiat}]`
|
||||||
|
const detailB = utils.buildDetail({ cryptoCode: balance.cryptoCode, code: balance.code })
|
||||||
|
return queries.addNotification(CRYPTO_BALANCE, message, detailB)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const balancesNotify = (balances) => {
|
||||||
|
const cryptoFilter = o => o.code === 'HIGH_CRYPTO_BALANCE' || o.code === 'LOW_CRYPTO_BALANCE'
|
||||||
|
const fiatFilter = o => o.code === 'LOW_CASH_OUT'
|
||||||
|
const cryptoWarnings = _.filter(cryptoFilter, balances)
|
||||||
|
const fiatWarnings = _.filter(fiatFilter, balances)
|
||||||
|
return Promise.all([cryptoBalancesNotify(cryptoWarnings), fiatBalancesNotify(fiatWarnings)])
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearOldErrorNotifications = alerts => {
|
||||||
|
return queries.getAllValidNotifications(ERROR)
|
||||||
|
.then(res => {
|
||||||
|
// for each valid notification in DB see if it exists in alerts
|
||||||
|
// if the notification doesn't exist in alerts, it is not valid anymore
|
||||||
|
const filterByAlert = _.filter(notification => {
|
||||||
|
const { code, deviceId } = notification.detail
|
||||||
|
return !_.find(alert => alert.code === code && alert.deviceId === deviceId)(alerts)
|
||||||
|
})
|
||||||
|
const indexesToInvalidate = _.compose(_.map('id'), filterByAlert)(res)
|
||||||
|
if (!indexesToInvalidate.length) return Promise.resolve()
|
||||||
|
return queries.batchInvalidate(indexesToInvalidate)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorAlertsNotify = (alertRec) => {
|
||||||
|
const embedDeviceId = deviceId => _.assign({ deviceId })
|
||||||
|
const mapToAlerts = _.map(it => _.map(embedDeviceId(it), alertRec.devices[it].deviceAlerts))
|
||||||
|
const alerts = _.compose(_.flatten, mapToAlerts, _.keys)(alertRec.devices)
|
||||||
|
|
||||||
|
return clearOldErrorNotifications(alerts).then(() => {
|
||||||
|
_.forEach(alert => {
|
||||||
|
switch (alert.code) {
|
||||||
|
case PING: {
|
||||||
|
const detailB = utils.buildDetail({ code: PING, age: alert.age ? alert.age : -1, deviceId: alert.deviceId })
|
||||||
|
return queries.getValidNotifications(ERROR, _.omit(['age'], detailB)).then(res => {
|
||||||
|
if (res.length > 0) return Promise.resolve()
|
||||||
|
const message = `Machine down`
|
||||||
|
return queries.addNotification(ERROR, message, detailB)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case STALE: {
|
||||||
|
const detailB = utils.buildDetail({ code: STALE, deviceId: alert.deviceId })
|
||||||
|
return queries.getValidNotifications(ERROR, detailB).then(res => {
|
||||||
|
if (res.length > 0) return Promise.resolve()
|
||||||
|
const message = `Machine is stuck on ${alert.state} screen`
|
||||||
|
return queries.addNotification(ERROR, message, detailB)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, alerts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifCenterTransactionNotify (isHighValue, direction, fiat, fiatCode, deviceId, cryptoAddress) {
|
||||||
|
const messageSuffix = isHighValue ? 'High value' : ''
|
||||||
|
const message = `${messageSuffix} ${fiat} ${fiatCode} ${direction} transaction`
|
||||||
|
const detailB = utils.buildDetail({ deviceId: deviceId, direction, fiat, fiatCode, cryptoAddress })
|
||||||
|
return queries.addNotification(isHighValue ? HIGH_VALUE_TX : NORMAL_VALUE_TX, message, detailB)
|
||||||
|
}
|
||||||
|
|
||||||
|
const blacklistNotify = (tx, isAddressReuse) => {
|
||||||
|
const code = isAddressReuse ? 'REUSED' : 'BLOCKED'
|
||||||
|
const name = isAddressReuse ? 'reused' : 'blacklisted'
|
||||||
|
|
||||||
|
const detailB = utils.buildDetail({ cryptoCode: tx.cryptoCode, code, cryptoAddress: tx.toAddress })
|
||||||
|
const message = `Blocked ${name} address: ${tx.cryptoCode} ${tx.toAddress.substr(0, 10)}...`
|
||||||
|
return queries.addNotification(COMPLIANCE, message, detailB)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sanctionsNotify,
|
||||||
|
customerComplianceNotify,
|
||||||
|
balancesNotify,
|
||||||
|
errorAlertsNotify,
|
||||||
|
notifCenterTransactionNotify,
|
||||||
|
blacklistNotify
|
||||||
|
}
|
||||||
94
lib/notifier/queries.js
Normal file
94
lib/notifier/queries.js
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
const pgp = require('pg-promise')()
|
||||||
|
const _ = require('lodash/fp')
|
||||||
|
|
||||||
|
const dbm = require('../postgresql_interface')
|
||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
// types of notifications able to be inserted into db:
|
||||||
|
/*
|
||||||
|
highValueTransaction - for transactions of value higher than threshold
|
||||||
|
fiatBalance - when the number of notes in cash cassettes falls below threshold
|
||||||
|
cryptoBalance - when ammount of crypto balance in fiat falls below or above low/high threshold
|
||||||
|
compliance - notifications related to warnings triggered by compliance settings
|
||||||
|
error - notifications related to errors
|
||||||
|
*/
|
||||||
|
|
||||||
|
function getMachineName (machineId) {
|
||||||
|
const sql = 'SELECT * FROM devices WHERE device_id=$1'
|
||||||
|
return db.oneOrNone(sql, [machineId])
|
||||||
|
.then(it => it.name).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addNotification = (type, message, detail) => {
|
||||||
|
const sql = `INSERT INTO notifications (id, type, message, detail) VALUES ($1, $2, $3, $4)`
|
||||||
|
return db.oneOrNone(sql, [uuidv4(), type, message, detail]).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllValidNotifications = (type) => {
|
||||||
|
const sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't'`
|
||||||
|
return db.any(sql, [type]).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidateNotification = (detail, type) => {
|
||||||
|
detail = _.omitBy(_.isEmpty, detail)
|
||||||
|
const sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE valid = 't' AND type = $1 AND detail::jsonb @> $2::jsonb`
|
||||||
|
return db.none(sql, [type, detail]).catch(console.error).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchInvalidate = (ids) => {
|
||||||
|
const formattedIds = _.map(pgp.as.text, ids).join(',')
|
||||||
|
const sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE id IN ($1^)`
|
||||||
|
return db.none(sql, [formattedIds]).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearBlacklistNotification = (cryptoCode, cryptoAddress) => {
|
||||||
|
const sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE type = 'compliance' AND detail->>'cryptoCode' = $1 AND detail->>'cryptoAddress' = $2 AND (detail->>'code' = 'BLOCKED' OR detail->>'code' = 'REUSED')`
|
||||||
|
return db.none(sql, [cryptoCode, cryptoAddress]).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getValidNotifications = (type, detail) => {
|
||||||
|
const sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't' AND detail @> $2`
|
||||||
|
return db.any(sql, [type, detail]).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNotifications = () => {
|
||||||
|
const sql = `SELECT * FROM notifications ORDER BY created DESC`
|
||||||
|
return db.any(sql).catch(console.error)
|
||||||
|
}
|
||||||
|
const setRead = (id, read) => {
|
||||||
|
const sql = `UPDATE notifications SET read = $1 WHERE id = $2`
|
||||||
|
return db.none(sql, [read, id]).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const markAllAsRead = () => {
|
||||||
|
const sql = `UPDATE notifications SET read = 't'`
|
||||||
|
return db.none(sql).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUnreadNotifications = () => {
|
||||||
|
const sql = `SELECT EXISTS (SELECT 1 FROM notifications WHERE read = 'f' LIMIT 1)`
|
||||||
|
return db.oneOrNone(sql).then(res => res.exists).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAlerts = () => {
|
||||||
|
const types = ['fiatBalance', 'cryptoBalance', 'error']
|
||||||
|
const sql = `SELECT * FROM notifications WHERE valid = 't' AND type IN ($1:list) ORDER BY created DESC`
|
||||||
|
return db.any(sql, [types]).catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
machineEvents: dbm.machineEvents,
|
||||||
|
addNotification,
|
||||||
|
getAllValidNotifications,
|
||||||
|
invalidateNotification,
|
||||||
|
batchInvalidate,
|
||||||
|
clearBlacklistNotification,
|
||||||
|
getValidNotifications,
|
||||||
|
getNotifications,
|
||||||
|
setRead,
|
||||||
|
markAllAsRead,
|
||||||
|
hasUnreadNotifications,
|
||||||
|
getAlerts,
|
||||||
|
getMachineName
|
||||||
|
}
|
||||||
56
lib/notifier/sms.js
Normal file
56
lib/notifier/sms.js
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
const _ = require('lodash/fp')
|
||||||
|
const utils = require('./utils')
|
||||||
|
const sms = require('../sms')
|
||||||
|
|
||||||
|
function printSmsAlerts (alertRec, config) {
|
||||||
|
let alerts = []
|
||||||
|
|
||||||
|
if (config.balance) {
|
||||||
|
alerts = _.concat(alerts, alertRec.general)
|
||||||
|
}
|
||||||
|
|
||||||
|
_.forEach(device => {
|
||||||
|
alerts = _.concat(alerts, utils.deviceAlerts(config, alertRec, device))
|
||||||
|
}, _.keys(alertRec.devices))
|
||||||
|
|
||||||
|
if (alerts.length === 0) return null
|
||||||
|
|
||||||
|
const alertsMap = _.groupBy('code', alerts)
|
||||||
|
|
||||||
|
const alertTypes = _.map(entry => {
|
||||||
|
const code = entry[0]
|
||||||
|
const machineNames = _.filter(
|
||||||
|
_.negate(_.isEmpty),
|
||||||
|
_.map('machineName', entry[1])
|
||||||
|
)
|
||||||
|
|
||||||
|
const cryptoCodes = _.filter(
|
||||||
|
_.negate(_.isEmpty),
|
||||||
|
_.map('cryptoCode', entry[1])
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
codeDisplay: utils.codeDisplay(code),
|
||||||
|
machineNames,
|
||||||
|
cryptoCodes
|
||||||
|
}
|
||||||
|
}, _.toPairs(alertsMap))
|
||||||
|
|
||||||
|
const mapByCodeDisplay = _.map(it => {
|
||||||
|
if (_.isEmpty(it.machineNames) && _.isEmpty(it.cryptoCodes)) return it.codeDisplay
|
||||||
|
if (_.isEmpty(it.machineNames)) return `${it.codeDisplay} (${it.cryptoCodes.join(', ')})`
|
||||||
|
return `${it.codeDisplay} (${it.machineNames.join(', ')})`
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayAlertTypes = _.compose(
|
||||||
|
_.uniq,
|
||||||
|
mapByCodeDisplay,
|
||||||
|
_.sortBy('codeDisplay')
|
||||||
|
)(alertTypes)
|
||||||
|
|
||||||
|
return '[Lamassu] Errors reported: ' + displayAlertTypes.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage = sms.sendMessage
|
||||||
|
|
||||||
|
module.exports = { printSmsAlerts, sendMessage }
|
||||||
28
lib/notifier/test/email.test.js
Normal file
28
lib/notifier/test/email.test.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
const email = require('../email')
|
||||||
|
|
||||||
|
const alertRec = {
|
||||||
|
devices: {
|
||||||
|
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: {
|
||||||
|
balanceAlerts: [],
|
||||||
|
deviceAlerts: [
|
||||||
|
{ code: 'PING', age: 602784301.446, machineName: 'Abc123' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deviceNames: {
|
||||||
|
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: 'Abc123'
|
||||||
|
},
|
||||||
|
general: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const printEmailMsg = `Errors were reported by your Lamassu Machines.
|
||||||
|
|
||||||
|
Errors for Abc123:
|
||||||
|
Machine down for ~6 days
|
||||||
|
`
|
||||||
|
|
||||||
|
test('Print Email Alers', () => {
|
||||||
|
expect(email.printEmailAlerts(alertRec, { active: true, errors: true })).toBe(
|
||||||
|
printEmailMsg
|
||||||
|
)
|
||||||
|
})
|
||||||
334
lib/notifier/test/notifier.test.js
Normal file
334
lib/notifier/test/notifier.test.js
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
const BigNumber = require('../../../lib/bn')
|
||||||
|
|
||||||
|
const notifier = require('..')
|
||||||
|
const utils = require('../utils')
|
||||||
|
const smsFuncs = require('../sms')
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// https://stackoverflow.com/questions/58151010/difference-between-resetallmocks-resetmodules-resetmoduleregistry-restoreallm
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// mock plugins object with mock data to test functions
|
||||||
|
const plugins = {
|
||||||
|
sendMessage: rec => {
|
||||||
|
return rec
|
||||||
|
},
|
||||||
|
getNotificationConfig: () => ({
|
||||||
|
email_active: false,
|
||||||
|
sms_active: true,
|
||||||
|
email_errors: false,
|
||||||
|
sms_errors: true,
|
||||||
|
sms: { active: true, errors: true },
|
||||||
|
email: { active: false, errors: false }
|
||||||
|
}),
|
||||||
|
getMachineNames: () => [
|
||||||
|
{
|
||||||
|
deviceId:
|
||||||
|
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||||
|
cashbox: 0,
|
||||||
|
cassette1: 444,
|
||||||
|
cassette2: 222,
|
||||||
|
version: '7.5.0-beta.0',
|
||||||
|
model: 'unknown',
|
||||||
|
pairedAt: '2020-11-13T16:20:31.624Z',
|
||||||
|
lastPing: '2020-11-16T13:11:03.169Z',
|
||||||
|
name: 'Abc123',
|
||||||
|
paired: true,
|
||||||
|
cashOut: true,
|
||||||
|
statuses: [{ label: 'Unresponsive', type: 'error' }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
checkBalances: () => []
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = {
|
||||||
|
id: 'bec8d452-9ea2-4846-841b-55a9df8bbd00',
|
||||||
|
deviceId:
|
||||||
|
'490ab16ee0c124512dc769be1f3e7ee3894ce1e5b4b8b975e134fb326e551e88',
|
||||||
|
toAddress: 'bc1q7s4yy5n9vp6zhlf6mrw3cttdgx5l3ysr2mhc4v',
|
||||||
|
cryptoAtoms: BigNumber(252100),
|
||||||
|
cryptoCode: 'BTC',
|
||||||
|
fiat: BigNumber(55),
|
||||||
|
fiatCode: 'USD',
|
||||||
|
fee: null,
|
||||||
|
txHash: null,
|
||||||
|
phone: null,
|
||||||
|
error: null,
|
||||||
|
created: '2020-12-04T16:28:11.016Z',
|
||||||
|
send: true,
|
||||||
|
sendConfirmed: false,
|
||||||
|
timedout: false,
|
||||||
|
sendTime: null,
|
||||||
|
errorCode: null,
|
||||||
|
operatorCompleted: false,
|
||||||
|
sendPending: true,
|
||||||
|
cashInFee: BigNumber(2),
|
||||||
|
cashInFeeCrypto: BigNumber(9500),
|
||||||
|
minimumTx: 5,
|
||||||
|
customerId: '47ac1184-8102-11e7-9079-8f13a7117867',
|
||||||
|
txVersion: 6,
|
||||||
|
termsAccepted: false,
|
||||||
|
commissionPercentage: BigNumber(0.11),
|
||||||
|
rawTickerPrice: BigNumber(18937.4),
|
||||||
|
isPaperWallet: false,
|
||||||
|
direction: 'cashIn'
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifSettings = {
|
||||||
|
email_active: false,
|
||||||
|
sms_active: true,
|
||||||
|
email_errors: false,
|
||||||
|
sms_errors: true,
|
||||||
|
sms_transactions: true,
|
||||||
|
highValueTransaction: Infinity, // this will make highValueTx always false
|
||||||
|
sms: {
|
||||||
|
active: true,
|
||||||
|
errors: true,
|
||||||
|
transactions: false // force early return
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
active: false,
|
||||||
|
errors: false,
|
||||||
|
transactions: false // force early return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Exits checkNotifications with Promise.resolve() if SMS and Email are disabled', async () => {
|
||||||
|
expect.assertions(1)
|
||||||
|
await expect(
|
||||||
|
notifier.checkNotification({
|
||||||
|
getNotificationConfig: () => ({
|
||||||
|
sms: { active: false, errors: false },
|
||||||
|
email: { active: false, errors: false }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
).resolves.toBe(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Exits checkNotifications with Promise.resolve() if SMS and Email are disabled even if errors or balance are defined to something', async () => {
|
||||||
|
expect.assertions(1)
|
||||||
|
await expect(
|
||||||
|
notifier.checkNotification({
|
||||||
|
getNotificationConfig: () => ({
|
||||||
|
sms: { active: false, errors: true, balance: true },
|
||||||
|
email: { active: false, errors: true, balance: true }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
).resolves.toBe(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Check Pings should return code PING for devices that haven't been pinged recently", () => {
|
||||||
|
expect(
|
||||||
|
notifier.checkPings([
|
||||||
|
{
|
||||||
|
deviceId:
|
||||||
|
'7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4',
|
||||||
|
lastPing: '2020-11-16T13:11:03.169Z',
|
||||||
|
name: 'Abc123'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
).toMatchObject({
|
||||||
|
'7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4': [
|
||||||
|
{ code: 'PING', machineName: 'Abc123' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Checkpings returns empty array as the value for the id prop, if the lastPing is more recent than 60 seconds', () => {
|
||||||
|
expect(
|
||||||
|
notifier.checkPings([
|
||||||
|
{
|
||||||
|
deviceId:
|
||||||
|
'7a531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4',
|
||||||
|
lastPing: new Date(),
|
||||||
|
name: 'Abc123'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
).toMatchObject({
|
||||||
|
'7a531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4': []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Check notification resolves to undefined if shouldNotAlert is called and is true', async () => {
|
||||||
|
const mockShouldNotAlert = jest.spyOn(utils, 'shouldNotAlert')
|
||||||
|
mockShouldNotAlert.mockReturnValue(true)
|
||||||
|
|
||||||
|
const result = await notifier.checkNotification(plugins)
|
||||||
|
expect(mockShouldNotAlert).toHaveBeenCalledTimes(1)
|
||||||
|
expect(result).toBe(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Sendmessage is called if shouldNotAlert is called and is false', async () => {
|
||||||
|
const mockShouldNotAlert = jest.spyOn(utils, 'shouldNotAlert')
|
||||||
|
const mockSendMessage = jest.spyOn(plugins, 'sendMessage')
|
||||||
|
|
||||||
|
mockShouldNotAlert.mockReturnValue(false)
|
||||||
|
const result = await notifier.checkNotification(plugins)
|
||||||
|
|
||||||
|
expect(mockShouldNotAlert).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSendMessage).toHaveBeenCalledTimes(1)
|
||||||
|
expect(result).toBe(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('If no alert fingerprint and inAlert is true, exits on call to sendNoAlerts', async () => {
|
||||||
|
// mock utils.buildAlertFingerprint to return null
|
||||||
|
// mock utils.getAlertFingerprint to be true which will make inAlert true
|
||||||
|
|
||||||
|
const buildFp = jest.spyOn(utils, 'buildAlertFingerprint')
|
||||||
|
const mockGetFp = jest.spyOn(utils, 'getAlertFingerprint')
|
||||||
|
const mockSendNoAlerts = jest.spyOn(utils, 'sendNoAlerts')
|
||||||
|
|
||||||
|
buildFp.mockReturnValue(null)
|
||||||
|
mockGetFp.mockReturnValue(true)
|
||||||
|
await notifier.checkNotification(plugins)
|
||||||
|
|
||||||
|
expect(mockGetFp).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSendNoAlerts).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// vvv tests for checkstuckscreen...
|
||||||
|
test('checkStuckScreen returns [] when no events are found', () => {
|
||||||
|
expect(notifier.checkStuckScreen([], 'Abc123')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('checkStuckScreen returns [] if most recent event is idle', () => {
|
||||||
|
// device_time is what matters for the sorting of the events by recency
|
||||||
|
expect(
|
||||||
|
notifier.checkStuckScreen([
|
||||||
|
{
|
||||||
|
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||||
|
device_id:
|
||||||
|
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||||
|
event_type: 'stateChange',
|
||||||
|
note: '{"state":"chooseCoin","isIdle":false}',
|
||||||
|
created: '2020-11-23T19:30:29.209Z',
|
||||||
|
device_time: '1999-11-23T19:30:29.177Z',
|
||||||
|
age: 157352628.123
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||||
|
device_id:
|
||||||
|
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||||
|
event_type: 'stateChange',
|
||||||
|
note: '{"state":"chooseCoin","isIdle":true}',
|
||||||
|
created: '2020-11-23T19:30:29.209Z',
|
||||||
|
device_time: '2020-11-23T19:30:29.177Z',
|
||||||
|
age: 157352628.123
|
||||||
|
}
|
||||||
|
])
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('checkStuckScreen returns object array of length 1 with prop code: "STALE" if age > STALE_STATE', () => {
|
||||||
|
// there is an age 0 and an isIdle true in the first object but it will be below the second one in the sorting order and thus ignored
|
||||||
|
const result = notifier.checkStuckScreen([
|
||||||
|
{
|
||||||
|
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||||
|
device_id:
|
||||||
|
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||||
|
event_type: 'stateChange',
|
||||||
|
note: '{"state":"chooseCoin","isIdle":true}',
|
||||||
|
created: '2020-11-23T19:30:29.209Z',
|
||||||
|
device_time: '1999-11-23T19:30:29.177Z',
|
||||||
|
age: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||||
|
device_id:
|
||||||
|
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||||
|
event_type: 'stateChange',
|
||||||
|
note: '{"state":"chooseCoin","isIdle":false}',
|
||||||
|
created: '2020-11-23T19:30:29.209Z',
|
||||||
|
device_time: '2020-11-23T19:30:29.177Z',
|
||||||
|
age: 157352628.123
|
||||||
|
}
|
||||||
|
])
|
||||||
|
expect(result[0]).toMatchObject({ code: 'STALE' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('checkStuckScreen returns empty array if age < STALE_STATE', () => {
|
||||||
|
const STALE_STATE = require('../codes').STALE_STATE
|
||||||
|
const result1 = notifier.checkStuckScreen([
|
||||||
|
{
|
||||||
|
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||||
|
device_id:
|
||||||
|
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||||
|
event_type: 'stateChange',
|
||||||
|
note: '{"state":"chooseCoin","isIdle":false}',
|
||||||
|
created: '2020-11-23T19:30:29.209Z',
|
||||||
|
device_time: '2020-11-23T19:30:29.177Z',
|
||||||
|
age: 0
|
||||||
|
}
|
||||||
|
])
|
||||||
|
const result2 = notifier.checkStuckScreen([
|
||||||
|
{
|
||||||
|
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||||
|
device_id:
|
||||||
|
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||||
|
event_type: 'stateChange',
|
||||||
|
note: '{"state":"chooseCoin","isIdle":false}',
|
||||||
|
created: '2020-11-23T19:30:29.209Z',
|
||||||
|
device_time: '2020-11-23T19:30:29.177Z',
|
||||||
|
age: STALE_STATE
|
||||||
|
}
|
||||||
|
])
|
||||||
|
expect(result1).toEqual([])
|
||||||
|
expect(result2).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calls sendRedemptionMessage if !zeroConf and rec.isRedemption', async () => {
|
||||||
|
const configManager = require('../../new-config-manager')
|
||||||
|
const settingsLoader = require('../../new-settings-loader')
|
||||||
|
|
||||||
|
const loadLatest = jest.spyOn(settingsLoader, 'loadLatest')
|
||||||
|
const getGlobalNotifications = jest.spyOn(configManager, 'getGlobalNotifications')
|
||||||
|
const getCashOut = jest.spyOn(configManager, 'getCashOut')
|
||||||
|
|
||||||
|
// sendRedemptionMessage will cause this func to be called
|
||||||
|
jest.spyOn(smsFuncs, 'sendMessage').mockImplementation((_, rec) => rec)
|
||||||
|
|
||||||
|
getCashOut.mockReturnValue({ zeroConfLimit: -Infinity })
|
||||||
|
loadLatest.mockReturnValue(Promise.resolve({}))
|
||||||
|
getGlobalNotifications.mockReturnValue({ ...notifSettings, sms: { active: true, errors: true, transactions: true }, notificationCenter: { active: true } })
|
||||||
|
|
||||||
|
const response = await notifier.transactionNotify(tx, { isRedemption: true })
|
||||||
|
|
||||||
|
// this type of response implies sendRedemptionMessage was called
|
||||||
|
expect(response[0]).toMatchObject({
|
||||||
|
sms: {
|
||||||
|
body: "Here's an update on transaction bec8d452-9ea2-4846-841b-55a9df8bbd00 - It was just dispensed successfully"
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
subject: "Here's an update on transaction bec8d452-9ea2-4846-841b-55a9df8bbd00",
|
||||||
|
body: 'It was just dispensed successfully'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('calls sendTransactionMessage if !zeroConf and !rec.isRedemption', async () => {
|
||||||
|
const configManager = require('../../new-config-manager')
|
||||||
|
const settingsLoader = require('../../new-settings-loader')
|
||||||
|
const machineLoader = require('../../machine-loader')
|
||||||
|
|
||||||
|
const loadLatest = jest.spyOn(settingsLoader, 'loadLatest')
|
||||||
|
const getGlobalNotifications = jest.spyOn(configManager, 'getGlobalNotifications')
|
||||||
|
const getCashOut = jest.spyOn(configManager, 'getCashOut')
|
||||||
|
const getMachineName = jest.spyOn(machineLoader, 'getMachineName')
|
||||||
|
const buildTransactionMessage = jest.spyOn(utils, 'buildTransactionMessage')
|
||||||
|
|
||||||
|
// sendMessage on emailFuncs isn't called because it is disabled in getGlobalNotifications.mockReturnValue
|
||||||
|
jest.spyOn(smsFuncs, 'sendMessage').mockImplementation((_, rec) => ({ prop: rec }))
|
||||||
|
buildTransactionMessage.mockImplementation(() => ['mock message', false])
|
||||||
|
|
||||||
|
getMachineName.mockReturnValue('mockMachineName')
|
||||||
|
getCashOut.mockReturnValue({ zeroConfLimit: -Infinity })
|
||||||
|
loadLatest.mockReturnValue(Promise.resolve({}))
|
||||||
|
getGlobalNotifications.mockReturnValue({ ...notifSettings, sms: { active: true, errors: true, transactions: true }, notificationCenter: { active: true } })
|
||||||
|
|
||||||
|
const response = await notifier.transactionNotify(tx, { isRedemption: false })
|
||||||
|
|
||||||
|
// If the return object is this, it means the code went through all the functions expected to go through if
|
||||||
|
// getMachineName, buildTransactionMessage and sendTransactionMessage were called, in this order
|
||||||
|
expect(response).toEqual([{prop: 'mock message'}])
|
||||||
|
})
|
||||||
22
lib/notifier/test/sms.test.js
Normal file
22
lib/notifier/test/sms.test.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
const sms = require('../sms')
|
||||||
|
|
||||||
|
const alertRec = {
|
||||||
|
devices: {
|
||||||
|
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: {
|
||||||
|
balanceAlerts: [],
|
||||||
|
deviceAlerts: [
|
||||||
|
{ code: 'PING', age: 602784301.446, machineName: 'Abc123' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deviceNames: {
|
||||||
|
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: 'Abc123'
|
||||||
|
},
|
||||||
|
general: []
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Print SMS alerts', () => {
|
||||||
|
expect(sms.printSmsAlerts(alertRec, { active: true, errors: true })).toBe(
|
||||||
|
'[Lamassu] Errors reported: Machine Down (Abc123)'
|
||||||
|
)
|
||||||
|
})
|
||||||
104
lib/notifier/test/utils.test.js
Normal file
104
lib/notifier/test/utils.test.js
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
const utils = require('../utils')
|
||||||
|
|
||||||
|
const plugins = {
|
||||||
|
sendMessage: rec => {
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertRec = {
|
||||||
|
devices: {
|
||||||
|
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: {
|
||||||
|
balanceAlerts: [],
|
||||||
|
deviceAlerts: [
|
||||||
|
{ code: 'PING', age: 1605532263169, machineName: 'Abc123' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deviceNames: {
|
||||||
|
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: 'Abc123'
|
||||||
|
},
|
||||||
|
general: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications = {
|
||||||
|
sms: { active: true, errors: true },
|
||||||
|
email: { active: false, errors: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildAlertFingerprint', () => {
|
||||||
|
test('Build alert fingerprint returns null if no sms or email alerts', () => {
|
||||||
|
expect(
|
||||||
|
utils.buildAlertFingerprint(
|
||||||
|
{
|
||||||
|
devices: {},
|
||||||
|
deviceNames: {},
|
||||||
|
general: []
|
||||||
|
},
|
||||||
|
notifications
|
||||||
|
)
|
||||||
|
).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Build alert fingerprint returns null if sms and email are disabled', () => {
|
||||||
|
expect(
|
||||||
|
utils.buildAlertFingerprint(alertRec, {
|
||||||
|
sms: { active: false, errors: true },
|
||||||
|
email: { active: false, errors: false }
|
||||||
|
})
|
||||||
|
).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Build alert fingerprint returns hash if email or [sms] are enabled and there are alerts in alertrec', () => {
|
||||||
|
expect(
|
||||||
|
typeof utils.buildAlertFingerprint(alertRec, {
|
||||||
|
sms: { active: true, errors: true },
|
||||||
|
email: { active: false, errors: false }
|
||||||
|
})
|
||||||
|
).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Build alert fingerprint returns hash if [email] or sms are enabled and there are alerts in alertrec', () => {
|
||||||
|
expect(
|
||||||
|
typeof utils.buildAlertFingerprint(alertRec, {
|
||||||
|
sms: { active: false, errors: false },
|
||||||
|
email: { active: true, errors: true }
|
||||||
|
})
|
||||||
|
).toBe('string')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendNoAlerts', () => {
|
||||||
|
test('Send no alerts returns empty object with sms and email disabled', () => {
|
||||||
|
expect(utils.sendNoAlerts(plugins, false, false)).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Send no alerts returns object with sms prop with sms only enabled', () => {
|
||||||
|
expect(utils.sendNoAlerts(plugins, true, false)).toEqual({
|
||||||
|
sms: {
|
||||||
|
body: '[Lamassu] All clear'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Send no alerts returns object with sms and email prop with both enabled', () => {
|
||||||
|
expect(utils.sendNoAlerts(plugins, true, true)).toEqual({
|
||||||
|
email: {
|
||||||
|
body: 'No errors are reported for your machines.',
|
||||||
|
subject: '[Lamassu] All clear'
|
||||||
|
},
|
||||||
|
sms: {
|
||||||
|
body: '[Lamassu] All clear'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Send no alerts returns object with email prop if only email is enabled', () => {
|
||||||
|
expect(utils.sendNoAlerts(plugins, false, true)).toEqual({
|
||||||
|
email: {
|
||||||
|
body: 'No errors are reported for your machines.',
|
||||||
|
subject: '[Lamassu] All clear'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
200
lib/notifier/utils.js
Normal file
200
lib/notifier/utils.js
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
const _ = require('lodash/fp')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const numeral = require('numeral')
|
||||||
|
const prettyMs = require('pretty-ms')
|
||||||
|
|
||||||
|
const coinUtils = require('../coin-utils')
|
||||||
|
const {
|
||||||
|
CODES_DISPLAY,
|
||||||
|
NETWORK_DOWN_TIME,
|
||||||
|
PING,
|
||||||
|
ALERT_SEND_INTERVAL
|
||||||
|
} = require('./codes')
|
||||||
|
|
||||||
|
const DETAIL_TEMPLATE = {
|
||||||
|
deviceId: '',
|
||||||
|
cryptoCode: '',
|
||||||
|
code: '',
|
||||||
|
cassette: '',
|
||||||
|
age: '',
|
||||||
|
customerId: '',
|
||||||
|
cryptoAddress: '',
|
||||||
|
direction: '',
|
||||||
|
fiat: '',
|
||||||
|
fiatCode: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEventNote (event) {
|
||||||
|
return _.set('note', JSON.parse(event.note), event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPing (device) {
|
||||||
|
const age = Date.now() - (new Date(device.lastPing).getTime())
|
||||||
|
if (age > NETWORK_DOWN_TIME) return [{ code: PING, age, machineName: device.name }]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDeviceTime = _.flow(_.get('device_time'), Date.parse)
|
||||||
|
|
||||||
|
const isActive = it => it.active && (it.balance || it.errors)
|
||||||
|
|
||||||
|
const codeDisplay = code => CODES_DISPLAY[code]
|
||||||
|
|
||||||
|
const alertFingerprint = {
|
||||||
|
fingerprint: null,
|
||||||
|
lastAlertTime: null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAlertFingerprint = () => alertFingerprint.fingerprint
|
||||||
|
|
||||||
|
const getLastAlertTime = () => alertFingerprint.lastAlertTime
|
||||||
|
|
||||||
|
const setAlertFingerprint = (fp, time = Date.now()) => {
|
||||||
|
alertFingerprint.fingerprint = fp
|
||||||
|
alertFingerprint.lastAlertTime = time
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldNotAlert = currentAlertFingerprint => {
|
||||||
|
return (
|
||||||
|
currentAlertFingerprint === getAlertFingerprint() &&
|
||||||
|
getLastAlertTime() - Date.now() < ALERT_SEND_INTERVAL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAlertFingerprint (alertRec, notifications) {
|
||||||
|
const sms = getAlertTypes(alertRec, notifications.sms)
|
||||||
|
const email = getAlertTypes(alertRec, notifications.email)
|
||||||
|
if (sms.length === 0 && email.length === 0) return null
|
||||||
|
|
||||||
|
const smsTypes = _.map(codeDisplay, _.uniq(_.map('code', sms))).sort()
|
||||||
|
const emailTypes = _.map(codeDisplay, _.uniq(_.map('code', email))).sort()
|
||||||
|
|
||||||
|
const subject = _.concat(smsTypes, emailTypes).join(', ')
|
||||||
|
return crypto.createHash('sha256').update(subject).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendNoAlerts (plugins, smsEnabled, emailEnabled) {
|
||||||
|
const subject = '[Lamassu] All clear'
|
||||||
|
|
||||||
|
let rec = {}
|
||||||
|
if (smsEnabled) {
|
||||||
|
rec = _.set(['sms', 'body'])(subject)(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailEnabled) {
|
||||||
|
rec = _.set(['email', 'subject'])(subject)(rec)
|
||||||
|
rec = _.set(['email', 'body'])('No errors are reported for your machines.')(
|
||||||
|
rec
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugins.sendMessage(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildTransactionMessage = (tx, rec, highValueTx, machineName, customer) => {
|
||||||
|
const isCashOut = tx.direction === 'cashOut'
|
||||||
|
const direction = isCashOut ? 'Cash Out' : 'Cash In'
|
||||||
|
const crypto = `${coinUtils.toUnit(tx.cryptoAtoms, tx.cryptoCode)} ${
|
||||||
|
tx.cryptoCode
|
||||||
|
}`
|
||||||
|
const fiat = `${tx.fiat} ${tx.fiatCode}`
|
||||||
|
const customerName = customer.name || customer.id
|
||||||
|
const phone = customer.phone ? `- Phone: ${customer.phone}` : ''
|
||||||
|
|
||||||
|
let status = null
|
||||||
|
if (rec.error) {
|
||||||
|
status = `Error - ${rec.error}`
|
||||||
|
} else {
|
||||||
|
status = !isCashOut
|
||||||
|
? 'Successful'
|
||||||
|
: !rec.isRedemption
|
||||||
|
? 'Successful & awaiting redemption'
|
||||||
|
: 'Successful & dispensed'
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
- Transaction ID: ${tx.id}
|
||||||
|
- Status: ${status}
|
||||||
|
- Machine name: ${machineName}
|
||||||
|
- ${direction}
|
||||||
|
- ${fiat}
|
||||||
|
- ${crypto}
|
||||||
|
- Customer: ${customerName}
|
||||||
|
${phone}
|
||||||
|
`
|
||||||
|
const smsSubject = `A ${highValueTx ? 'high value ' : ''}${direction.toLowerCase()} transaction just happened at ${machineName} for ${fiat}`
|
||||||
|
const emailSubject = `A ${highValueTx ? 'high value ' : ''}transaction just happened`
|
||||||
|
|
||||||
|
return [{
|
||||||
|
sms: {
|
||||||
|
body: `${smsSubject} – ${status}`
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
emailSubject,
|
||||||
|
body
|
||||||
|
}
|
||||||
|
}, highValueTx]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency (num, code) {
|
||||||
|
return numeral(num).format('0,0.00') + ' ' + code
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAge (age, settings) {
|
||||||
|
return prettyMs(age, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDetail (obj) {
|
||||||
|
// obj validation
|
||||||
|
const objKeys = _.keys(obj)
|
||||||
|
const detailKeys = _.keys(DETAIL_TEMPLATE)
|
||||||
|
if ((_.difference(objKeys, detailKeys)).length > 0) {
|
||||||
|
return Promise.reject(new Error('Error when building detail object: invalid properties'))
|
||||||
|
}
|
||||||
|
return { ...DETAIL_TEMPLATE, ...obj }
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceAlerts (config, alertRec, device) {
|
||||||
|
let alerts = []
|
||||||
|
if (config.balance) {
|
||||||
|
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.errors) {
|
||||||
|
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||||
|
}
|
||||||
|
return alerts
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlertTypes (alertRec, config) {
|
||||||
|
let alerts = []
|
||||||
|
if (!isActive(config)) return alerts
|
||||||
|
|
||||||
|
if (config.balance) {
|
||||||
|
alerts = _.concat(alerts, alertRec.general)
|
||||||
|
}
|
||||||
|
|
||||||
|
_.forEach(device => {
|
||||||
|
alerts = _.concat(alerts, deviceAlerts(config, alertRec, device))
|
||||||
|
}, _.keys(alertRec.devices))
|
||||||
|
return alerts
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
codeDisplay,
|
||||||
|
parseEventNote,
|
||||||
|
getDeviceTime,
|
||||||
|
checkPing,
|
||||||
|
isActive,
|
||||||
|
getAlertFingerprint,
|
||||||
|
getLastAlertTime,
|
||||||
|
setAlertFingerprint,
|
||||||
|
shouldNotAlert,
|
||||||
|
buildAlertFingerprint,
|
||||||
|
sendNoAlerts,
|
||||||
|
buildTransactionMessage,
|
||||||
|
formatCurrency,
|
||||||
|
formatAge,
|
||||||
|
buildDetail,
|
||||||
|
deviceAlerts
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@ const path = require('path')
|
||||||
const os = require('os')
|
const os = require('os')
|
||||||
const argv = require('minimist')(process.argv.slice(2))
|
const argv = require('minimist')(process.argv.slice(2))
|
||||||
|
|
||||||
|
const STRESS_TEST_DB = 'psql://postgres:postgres123@localhost/lamassu_stress'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return {{path: string, opts: any}}
|
* @return {{path: string, opts: any}}
|
||||||
*/
|
*/
|
||||||
|
|
@ -25,17 +27,29 @@ function load () {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const globalConfigPath = path.resolve('/etc', 'lamassu', 'lamassu.json')
|
const globalConfigPath = path.resolve('/etc', 'lamassu', 'lamassu.json')
|
||||||
return {
|
const config = {
|
||||||
path: globalConfigPath,
|
path: globalConfigPath,
|
||||||
opts: JSON.parse(fs.readFileSync(globalConfigPath))
|
opts: JSON.parse(fs.readFileSync(globalConfigPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (argv.testDB) {
|
||||||
|
config.opts.postgresql = STRESS_TEST_DB
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
try {
|
try {
|
||||||
const homeConfigPath = path.resolve(os.homedir(), '.lamassu', 'lamassu.json')
|
const homeConfigPath = path.resolve(os.homedir(), '.lamassu', 'lamassu.json')
|
||||||
return {
|
const config = {
|
||||||
path: homeConfigPath,
|
path: homeConfigPath,
|
||||||
opts: JSON.parse(fs.readFileSync(homeConfigPath))
|
opts: JSON.parse(fs.readFileSync(homeConfigPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (argv.testDB) {
|
||||||
|
config.opts.postgresql = STRESS_TEST_DB
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
console.error("Couldn't open lamassu.json config file.")
|
console.error("Couldn't open lamassu.json config file.")
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|
|
||||||
154
lib/plugins.js
154
lib/plugins.js
|
|
@ -1,4 +1,3 @@
|
||||||
const uuid = require('uuid')
|
|
||||||
const _ = require('lodash/fp')
|
const _ = require('lodash/fp')
|
||||||
const argv = require('minimist')(process.argv.slice(2))
|
const argv = require('minimist')(process.argv.slice(2))
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
|
|
@ -21,6 +20,10 @@ const cashOutHelper = require('./cash-out/cash-out-helper')
|
||||||
const machineLoader = require('./machine-loader')
|
const machineLoader = require('./machine-loader')
|
||||||
const customers = require('./customers')
|
const customers = require('./customers')
|
||||||
const coinUtils = require('./coin-utils')
|
const coinUtils = require('./coin-utils')
|
||||||
|
const commissionMath = require('./commission-math')
|
||||||
|
const promoCodes = require('./promo-codes')
|
||||||
|
|
||||||
|
const notifier = require('./notifier')
|
||||||
|
|
||||||
const mapValuesWithKey = _.mapValues.convert({
|
const mapValuesWithKey = _.mapValues.convert({
|
||||||
cap: false
|
cap: false
|
||||||
|
|
@ -33,15 +36,16 @@ const PONG_TTL = '1 week'
|
||||||
const tradesQueues = {}
|
const tradesQueues = {}
|
||||||
|
|
||||||
function plugins (settings, deviceId) {
|
function plugins (settings, deviceId) {
|
||||||
function buildRates (tickers) {
|
|
||||||
|
function internalBuildRates (tickers, withCommission = true) {
|
||||||
const localeConfig = configManager.getLocale(deviceId, settings.config)
|
const localeConfig = configManager.getLocale(deviceId, settings.config)
|
||||||
const cryptoCodes = localeConfig.cryptoCurrencies
|
const cryptoCodes = localeConfig.cryptoCurrencies
|
||||||
|
|
||||||
const rates = {}
|
const rates = {}
|
||||||
|
|
||||||
cryptoCodes.forEach((cryptoCode, i) => {
|
cryptoCodes.forEach((cryptoCode, i) => {
|
||||||
const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config)
|
|
||||||
const rateRec = tickers[i]
|
const rateRec = tickers[i]
|
||||||
|
const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config)
|
||||||
|
|
||||||
if (!rateRec) return
|
if (!rateRec) return
|
||||||
|
|
||||||
|
|
@ -53,15 +57,26 @@ function plugins (settings, deviceId) {
|
||||||
|
|
||||||
if (Date.now() - rateRec.timestamp > STALE_TICKER) return logger.warn('Stale rate for ' + cryptoCode)
|
if (Date.now() - rateRec.timestamp > STALE_TICKER) return logger.warn('Stale rate for ' + cryptoCode)
|
||||||
const rate = rateRec.rates
|
const rate = rateRec.rates
|
||||||
rates[cryptoCode] = {
|
|
||||||
|
withCommission ? rates[cryptoCode] = {
|
||||||
cashIn: rate.ask.mul(cashInCommission).round(5),
|
cashIn: rate.ask.mul(cashInCommission).round(5),
|
||||||
cashOut: cashOutCommission && rate.bid.div(cashOutCommission).round(5)
|
cashOut: cashOutCommission && rate.bid.div(cashOutCommission).round(5)
|
||||||
|
} : rates[cryptoCode] = {
|
||||||
|
cashIn: rate.ask.round(5),
|
||||||
|
cashOut: rate.bid.round(5)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return rates
|
return rates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRatesNoCommission (tickers) {
|
||||||
|
return internalBuildRates(tickers, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRates (tickers) {
|
||||||
|
return internalBuildRates(tickers, true)
|
||||||
|
}
|
||||||
|
|
||||||
function getNotificationConfig () {
|
function getNotificationConfig () {
|
||||||
return configManager.getGlobalNotifications(settings.config)
|
return configManager.getGlobalNotifications(settings.config)
|
||||||
}
|
}
|
||||||
|
|
@ -124,18 +139,6 @@ function plugins (settings, deviceId) {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLcmOrBigx2 (n1, n2) {
|
|
||||||
let big = Math.max(n1, n2);
|
|
||||||
let small = Math.min(n1, n2);
|
|
||||||
|
|
||||||
let i = big * 2;
|
|
||||||
while(i % small !== 0){
|
|
||||||
i += lar;
|
|
||||||
}
|
|
||||||
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAvailableCassettes (excludeTxId) {
|
function buildAvailableCassettes (excludeTxId) {
|
||||||
const cashOutConfig = configManager.getCashOut(deviceId, settings.config)
|
const cashOutConfig = configManager.getCashOut(deviceId, settings.config)
|
||||||
|
|
||||||
|
|
@ -143,7 +146,7 @@ function plugins (settings, deviceId) {
|
||||||
|
|
||||||
const denominations = [cashOutConfig.top, cashOutConfig.bottom]
|
const denominations = [cashOutConfig.top, cashOutConfig.bottom]
|
||||||
|
|
||||||
const virtualCassettes = [getLcmOrBigx2(cashOutConfig.top, cashOutConfig.bottom)]
|
const virtualCassettes = [Math.max(cashOutConfig.top, cashOutConfig.bottom) * 2]
|
||||||
|
|
||||||
return Promise.all([dbm.cassetteCounts(deviceId), cashOutHelper.redeemableTxs(deviceId, excludeTxId)])
|
return Promise.all([dbm.cassetteCounts(deviceId), cashOutHelper.redeemableTxs(deviceId, excludeTxId)])
|
||||||
.then(([rec, _redeemableTxs]) => {
|
.then(([rec, _redeemableTxs]) => {
|
||||||
|
|
@ -222,12 +225,13 @@ function plugins (settings, deviceId) {
|
||||||
const testnetPromises = cryptoCodes.map(c => wallet.cryptoNetwork(settings, c))
|
const testnetPromises = cryptoCodes.map(c => wallet.cryptoNetwork(settings, c))
|
||||||
const pingPromise = recordPing(deviceTime, machineVersion, machineModel)
|
const pingPromise = recordPing(deviceTime, machineVersion, machineModel)
|
||||||
const currentConfigVersionPromise = fetchCurrentConfigVersion()
|
const currentConfigVersionPromise = fetchCurrentConfigVersion()
|
||||||
|
const currentAvailablePromoCodes = promoCodes.getNumberOfAvailablePromoCodes()
|
||||||
|
|
||||||
const promises = [
|
const promises = [
|
||||||
buildAvailableCassettes(),
|
buildAvailableCassettes(),
|
||||||
pingPromise,
|
pingPromise,
|
||||||
currentConfigVersionPromise
|
currentConfigVersionPromise
|
||||||
].concat(tickerPromises, balancePromises, testnetPromises)
|
].concat(tickerPromises, balancePromises, testnetPromises, currentAvailablePromoCodes)
|
||||||
|
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
.then(arr => {
|
.then(arr => {
|
||||||
|
|
@ -236,16 +240,18 @@ function plugins (settings, deviceId) {
|
||||||
const cryptoCodesCount = cryptoCodes.length
|
const cryptoCodesCount = cryptoCodes.length
|
||||||
const tickers = arr.slice(3, cryptoCodesCount + 3)
|
const tickers = arr.slice(3, cryptoCodesCount + 3)
|
||||||
const balances = arr.slice(cryptoCodesCount + 3, 2 * cryptoCodesCount + 3)
|
const balances = arr.slice(cryptoCodesCount + 3, 2 * cryptoCodesCount + 3)
|
||||||
const testNets = arr.slice(2 * cryptoCodesCount + 3)
|
const testNets = arr.slice(2 * cryptoCodesCount + 3, arr.length - 1)
|
||||||
const coinParams = _.zip(cryptoCodes, testNets)
|
const coinParams = _.zip(cryptoCodes, testNets)
|
||||||
const coinsWithoutRate = _.map(mapCoinSettings, coinParams)
|
const coinsWithoutRate = _.map(mapCoinSettings, coinParams)
|
||||||
|
const areThereAvailablePromoCodes = arr[arr.length - 1] > 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cassettes,
|
cassettes,
|
||||||
rates: buildRates(tickers),
|
rates: buildRates(tickers),
|
||||||
balances: buildBalances(balances),
|
balances: buildBalances(balances),
|
||||||
coins: _.zipWith(_.assign, coinsWithoutRate, tickers),
|
coins: _.zipWith(_.assign, coinsWithoutRate, tickers),
|
||||||
configVersion
|
configVersion,
|
||||||
|
areThereAvailablePromoCodes
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -350,78 +356,8 @@ function plugins (settings, deviceId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifyOperator (tx, rec) {
|
function notifyOperator (tx, rec) {
|
||||||
const notifications = configManager.getGlobalNotifications(settings.config)
|
// notify operator about new transaction and add high volume txs to database
|
||||||
|
return notifier.transactionNotify(tx, rec)
|
||||||
const notificationsEnabled = notifications.sms.transactions || notifications.email.transactions
|
|
||||||
const highValueTx = tx.fiat.gt(notifications.highValueTransaction || Infinity)
|
|
||||||
|
|
||||||
if (!notificationsEnabled && !highValueTx) return Promise.resolve()
|
|
||||||
|
|
||||||
const isCashOut = tx.direction === 'cashOut'
|
|
||||||
const zeroConf = isCashOut && isZeroConf(tx)
|
|
||||||
|
|
||||||
// 0-conf cash-out should only send notification on redemption
|
|
||||||
if (zeroConf && isCashOut && !rec.isRedemption && !rec.error) return Promise.resolve()
|
|
||||||
|
|
||||||
if (!zeroConf && rec.isRedemption) return sendRedemptionMessage(tx.id, rec.error)
|
|
||||||
|
|
||||||
const customerPromise = tx.customerId ? customers.getById(tx.customerId) : Promise.resolve({})
|
|
||||||
|
|
||||||
return Promise.all([machineLoader.getMachineName(tx.deviceId), customerPromise])
|
|
||||||
.then(([machineName, customer]) => {
|
|
||||||
const direction = isCashOut ? 'Cash Out' : 'Cash In'
|
|
||||||
const crypto = `${coinUtils.toUnit(tx.cryptoAtoms, tx.cryptoCode)} ${tx.cryptoCode}`
|
|
||||||
const fiat = `${tx.fiat} ${tx.fiatCode}`
|
|
||||||
const customerName = customer.name || customer.id
|
|
||||||
const phone = customer.phone ? `- Phone: ${customer.phone}` : ''
|
|
||||||
|
|
||||||
let status
|
|
||||||
if (rec.error) {
|
|
||||||
status = `Error - ${rec.error}`
|
|
||||||
} else {
|
|
||||||
status = !isCashOut ? 'Successful' : !rec.isRedemption
|
|
||||||
? 'Successful & awaiting redemption' : 'Successful & dispensed'
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = `
|
|
||||||
- Transaction ID: ${tx.id}
|
|
||||||
- Status: ${status}
|
|
||||||
- Machine name: ${machineName}
|
|
||||||
- ${direction}
|
|
||||||
- ${fiat}
|
|
||||||
- ${crypto}
|
|
||||||
- Customer: ${customerName}
|
|
||||||
${phone}
|
|
||||||
`
|
|
||||||
const subject = `A ${highValueTx ? 'high value ' : ''}transaction just happened`
|
|
||||||
|
|
||||||
return [{
|
|
||||||
sms: {
|
|
||||||
body: `${subject} - ${status}`
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
subject,
|
|
||||||
body
|
|
||||||
}
|
|
||||||
}, highValueTx]
|
|
||||||
})
|
|
||||||
.then(([rec, highValueTx]) => sendTransactionMessage(rec, highValueTx))
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendRedemptionMessage (txId, error) {
|
|
||||||
const subject = `Here's an update on transaction ${txId}`
|
|
||||||
const body = error ? `Error: ${error}` : 'It was just dispensed successfully'
|
|
||||||
|
|
||||||
const rec = {
|
|
||||||
sms: {
|
|
||||||
body: `${subject} - ${body}`
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
subject,
|
|
||||||
body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sendTransactionMessage(rec)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearOldLogs () {
|
function clearOldLogs () {
|
||||||
|
|
@ -440,18 +376,18 @@ function plugins (settings, deviceId) {
|
||||||
* Trader functions
|
* Trader functions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function buy (rec) {
|
function buy (rec, tx) {
|
||||||
return buyAndSell(rec, true)
|
return buyAndSell(rec, true, tx)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sell (rec) {
|
function sell (rec) {
|
||||||
return buyAndSell(rec, false)
|
return buyAndSell(rec, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buyAndSell (rec, doBuy) {
|
function buyAndSell (rec, doBuy, tx) {
|
||||||
const cryptoCode = rec.cryptoCode
|
const cryptoCode = rec.cryptoCode
|
||||||
const fiatCode = rec.fiatCode
|
const fiatCode = rec.fiatCode
|
||||||
const cryptoAtoms = doBuy ? rec.cryptoAtoms : rec.cryptoAtoms.neg()
|
const cryptoAtoms = doBuy ? commissionMath.fiatToCrypto(tx, rec, deviceId, settings.config) : rec.cryptoAtoms.neg()
|
||||||
|
|
||||||
const market = [fiatCode, cryptoCode].join('')
|
const market = [fiatCode, cryptoCode].join('')
|
||||||
|
|
||||||
|
|
@ -611,20 +547,6 @@ function plugins (settings, deviceId) {
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendTransactionMessage (rec, isHighValueTx) {
|
|
||||||
const notifications = configManager.getGlobalNotifications(settings.config)
|
|
||||||
|
|
||||||
let promises = []
|
|
||||||
|
|
||||||
const emailActive = notifications.email.active && (notifications.email.transactions || isHighValueTx)
|
|
||||||
if (emailActive) promises.push(email.sendMessage(settings, rec))
|
|
||||||
|
|
||||||
const smsActive = notifications.sms.active && (notifications.sms.transactions || isHighValueTx)
|
|
||||||
if (smsActive) promises.push(sms.sendMessage(settings, rec))
|
|
||||||
|
|
||||||
return Promise.all(promises)
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkDevicesCashBalances (fiatCode, devices) {
|
function checkDevicesCashBalances (fiatCode, devices) {
|
||||||
return _.map(device => checkDeviceCashBalances(fiatCode, device), devices)
|
return _.map(device => checkDeviceCashBalances(fiatCode, device), devices)
|
||||||
}
|
}
|
||||||
|
|
@ -693,7 +615,6 @@ function plugins (settings, deviceId) {
|
||||||
|
|
||||||
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(cryptoCode, null, settings.config)
|
const notifications = configManager.getNotifications(cryptoCode, null, settings.config)
|
||||||
|
|
@ -703,14 +624,16 @@ function plugins (settings, deviceId) {
|
||||||
const req = {
|
const req = {
|
||||||
cryptoCode,
|
cryptoCode,
|
||||||
fiatBalance,
|
fiatBalance,
|
||||||
fiatCode,
|
fiatCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_.isFinite(lowAlertThreshold) && BN(fiatBalance.balance).lt(lowAlertThreshold))
|
if (_.isFinite(lowAlertThreshold) && BN(fiatBalance.balance).lt(lowAlertThreshold)) {
|
||||||
return _.set('code')('LOW_CRYPTO_BALANCE')(req)
|
return _.set('code')('LOW_CRYPTO_BALANCE')(req)
|
||||||
|
}
|
||||||
|
|
||||||
if (_.isFinite(highAlertThreshold) && BN(fiatBalance.balance).gt(highAlertThreshold))
|
if (_.isFinite(highAlertThreshold) && BN(fiatBalance.balance).gt(highAlertThreshold)) {
|
||||||
return _.set('code')('HIGH_CRYPTO_BALANCE')(req)
|
return _.set('code')('HIGH_CRYPTO_BALANCE')(req)
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -798,6 +721,7 @@ function plugins (settings, deviceId) {
|
||||||
getRates,
|
getRates,
|
||||||
buildRates,
|
buildRates,
|
||||||
getRawRates,
|
getRawRates,
|
||||||
|
buildRatesNoCommission,
|
||||||
pollQueries,
|
pollQueries,
|
||||||
sendCoins,
|
sendCoins,
|
||||||
newAddress,
|
newAddress,
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,22 @@ const coinUtils = require('../../../coin-utils')
|
||||||
const cryptoRec = coinUtils.getCryptoCurrency('BCH')
|
const cryptoRec = coinUtils.getCryptoCurrency('BCH')
|
||||||
const configPath = coinUtils.configPath(cryptoRec)
|
const configPath = coinUtils.configPath(cryptoRec)
|
||||||
const unitScale = cryptoRec.unitScale
|
const unitScale = cryptoRec.unitScale
|
||||||
const config = jsonRpc.parseConf(configPath)
|
|
||||||
|
|
||||||
const rpcConfig = {
|
function rpcConfig () {
|
||||||
username: config.rpcuser,
|
try {
|
||||||
password: config.rpcpassword,
|
const config = jsonRpc.parseConf(configPath)
|
||||||
port: config.rpcport || cryptoRec.defaultPort
|
return {
|
||||||
|
username: config.rpcuser,
|
||||||
|
password: config.rpcpassword,
|
||||||
|
port: config.rpcport || cryptoRec.defaultPort
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('wallet is currently not installed')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetch (method, params) {
|
function fetch (method, params) {
|
||||||
return jsonRpc.fetch(rpcConfig, method, params)
|
return jsonRpc.fetch(rpcConfig(), method, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkCryptoCode (cryptoCode) {
|
function checkCryptoCode (cryptoCode) {
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,22 @@ const coinUtils = require('../../../coin-utils')
|
||||||
const cryptoRec = coinUtils.getCryptoCurrency('BTC')
|
const cryptoRec = coinUtils.getCryptoCurrency('BTC')
|
||||||
const configPath = coinUtils.configPath(cryptoRec)
|
const configPath = coinUtils.configPath(cryptoRec)
|
||||||
const unitScale = cryptoRec.unitScale
|
const unitScale = cryptoRec.unitScale
|
||||||
const config = jsonRpc.parseConf(configPath)
|
|
||||||
|
|
||||||
const rpcConfig = {
|
function rpcConfig () {
|
||||||
username: config.rpcuser,
|
try {
|
||||||
password: config.rpcpassword,
|
const config = jsonRpc.parseConf(configPath)
|
||||||
port: config.rpcport || cryptoRec.defaultPort
|
return {
|
||||||
|
username: config.rpcuser,
|
||||||
|
password: config.rpcpassword,
|
||||||
|
port: config.rpcport || cryptoRec.defaultPort
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('wallet is currently not installed')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetch (method, params) {
|
function fetch (method, params) {
|
||||||
return jsonRpc.fetch(rpcConfig, method, params)
|
return jsonRpc.fetch(rpcConfig(), method, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkCryptoCode (cryptoCode) {
|
function checkCryptoCode (cryptoCode) {
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,22 @@ const E = require('../../../error')
|
||||||
const cryptoRec = coinUtils.getCryptoCurrency('DASH')
|
const cryptoRec = coinUtils.getCryptoCurrency('DASH')
|
||||||
const configPath = coinUtils.configPath(cryptoRec)
|
const configPath = coinUtils.configPath(cryptoRec)
|
||||||
const unitScale = cryptoRec.unitScale
|
const unitScale = cryptoRec.unitScale
|
||||||
const config = jsonRpc.parseConf(configPath)
|
|
||||||
|
|
||||||
const rpcConfig = {
|
function rpcConfig () {
|
||||||
username: config.rpcuser,
|
try {
|
||||||
password: config.rpcpassword,
|
const config = jsonRpc.parseConf(configPath)
|
||||||
port: config.rpcport || cryptoRec.defaultPort
|
return {
|
||||||
|
username: config.rpcuser,
|
||||||
|
password: config.rpcpassword,
|
||||||
|
port: config.rpcport || cryptoRec.defaultPort
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('wallet is currently not installed')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetch (method, params) {
|
function fetch (method, params) {
|
||||||
return jsonRpc.fetch(rpcConfig, method, params)
|
return jsonRpc.fetch(rpcConfig(), method, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkCryptoCode (cryptoCode) {
|
function checkCryptoCode (cryptoCode) {
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,13 @@ function checkCryptoCode (cryptoCode) {
|
||||||
|
|
||||||
function balance (account, cryptoCode) {
|
function balance (account, cryptoCode) {
|
||||||
return checkCryptoCode(cryptoCode)
|
return checkCryptoCode(cryptoCode)
|
||||||
.then(() => pendingBalance(defaultAddress(account)))
|
.then(() => confirmedBalance(defaultAddress(account)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingBalance = address => _balance(true, address)
|
const pendingBalance = address => {
|
||||||
|
const promises = [_balance(true, address), _balance(false, address)]
|
||||||
|
return Promise.all(promises).then(([pending, confirmed]) => pending - confirmed)
|
||||||
|
}
|
||||||
const confirmedBalance = address => _balance(false, address)
|
const confirmedBalance = address => _balance(false, address)
|
||||||
|
|
||||||
function _balance (includePending, address) {
|
function _balance (includePending, address) {
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,21 @@ const E = require('../../../error')
|
||||||
const cryptoRec = coinUtils.getCryptoCurrency('LTC')
|
const cryptoRec = coinUtils.getCryptoCurrency('LTC')
|
||||||
const configPath = coinUtils.configPath(cryptoRec)
|
const configPath = coinUtils.configPath(cryptoRec)
|
||||||
const unitScale = cryptoRec.unitScale
|
const unitScale = cryptoRec.unitScale
|
||||||
const config = jsonRpc.parseConf(configPath)
|
|
||||||
|
|
||||||
const rpcConfig = {
|
function rpcConfig () {
|
||||||
username: config.rpcuser,
|
try {
|
||||||
password: config.rpcpassword,
|
const config = jsonRpc.parseConf(configPath)
|
||||||
port: config.rpcport || cryptoRec.defaultPort
|
return {
|
||||||
|
username: config.rpcuser,
|
||||||
|
password: config.rpcpassword,
|
||||||
|
port: config.rpcport || cryptoRec.defaultPort
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('wallet is currently not installed')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetch (method, params) {
|
function fetch (method, params) {
|
||||||
return jsonRpc.fetch(rpcConfig, method, params)
|
return jsonRpc.fetch(rpcConfig(), method, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkCryptoCode (cryptoCode) {
|
function checkCryptoCode (cryptoCode) {
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,22 @@ const E = require('../../../error')
|
||||||
const cryptoRec = coinUtils.getCryptoCurrency('ZEC')
|
const cryptoRec = coinUtils.getCryptoCurrency('ZEC')
|
||||||
const configPath = coinUtils.configPath(cryptoRec)
|
const configPath = coinUtils.configPath(cryptoRec)
|
||||||
const unitScale = cryptoRec.unitScale
|
const unitScale = cryptoRec.unitScale
|
||||||
const config = jsonRpc.parseConf(configPath)
|
|
||||||
|
|
||||||
const rpcConfig = {
|
function rpcConfig () {
|
||||||
username: config.rpcuser,
|
try {
|
||||||
password: config.rpcpassword,
|
const config = jsonRpc.parseConf(configPath)
|
||||||
port: config.rpcport || cryptoRec.defaultPort
|
return {
|
||||||
|
username: config.rpcuser,
|
||||||
|
password: config.rpcpassword,
|
||||||
|
port: config.rpcport || cryptoRec.defaultPort
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('wallet is currently not installed')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetch (method, params) {
|
function fetch (method, params) {
|
||||||
return jsonRpc.fetch(rpcConfig, method, params)
|
return jsonRpc.fetch(rpcConfig(), method, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkCryptoCode (cryptoCode) {
|
function checkCryptoCode (cryptoCode) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ const complianceTriggers = require('./compliance-triggers')
|
||||||
|
|
||||||
const INCOMING_TX_INTERVAL = 30 * T.seconds
|
const INCOMING_TX_INTERVAL = 30 * T.seconds
|
||||||
const LIVE_INCOMING_TX_INTERVAL = 5 * T.seconds
|
const LIVE_INCOMING_TX_INTERVAL = 5 * T.seconds
|
||||||
|
const INCOMING_TX_INTERVAL_FILTER = 1 * T.minute
|
||||||
|
const LIVE_INCOMING_TX_INTERVAL_FILTER = 10 * T.seconds
|
||||||
const UNNOTIFIED_INTERVAL = 10 * T.seconds
|
const UNNOTIFIED_INTERVAL = 10 * T.seconds
|
||||||
const SWEEP_HD_INTERVAL = T.minute
|
const SWEEP_HD_INTERVAL = T.minute
|
||||||
const TRADE_INTERVAL = 60 * T.seconds
|
const TRADE_INTERVAL = 60 * T.seconds
|
||||||
|
|
@ -27,6 +29,8 @@ const CHECK_NOTIFICATION_INTERVAL = 20 * T.seconds
|
||||||
|
|
||||||
const PENDING_INTERVAL = 10 * T.seconds
|
const PENDING_INTERVAL = 10 * T.seconds
|
||||||
|
|
||||||
|
const coinFilter = ['ETH']
|
||||||
|
|
||||||
let _pi, _settings
|
let _pi, _settings
|
||||||
|
|
||||||
function reload (__settings) {
|
function reload (__settings) {
|
||||||
|
|
@ -71,16 +75,24 @@ function start (__settings) {
|
||||||
pi().executeTrades()
|
pi().executeTrades()
|
||||||
pi().pong()
|
pi().pong()
|
||||||
pi().clearOldLogs()
|
pi().clearOldLogs()
|
||||||
cashOutTx.monitorLiveIncoming(settings())
|
cashOutTx.monitorLiveIncoming(settings(), false, coinFilter)
|
||||||
cashOutTx.monitorStaleIncoming(settings())
|
cashOutTx.monitorStaleIncoming(settings(), false, coinFilter)
|
||||||
|
if (!_.isEmpty(coinFilter)) {
|
||||||
|
cashOutTx.monitorLiveIncoming(settings(), true, coinFilter)
|
||||||
|
cashOutTx.monitorStaleIncoming(settings(), true, coinFilter)
|
||||||
|
}
|
||||||
cashOutTx.monitorUnnotified(settings())
|
cashOutTx.monitorUnnotified(settings())
|
||||||
pi().sweepHd()
|
pi().sweepHd()
|
||||||
notifier.checkNotification(pi())
|
notifier.checkNotification(pi())
|
||||||
updateCoinAtmRadar()
|
updateCoinAtmRadar()
|
||||||
|
|
||||||
setInterval(() => pi().executeTrades(), TRADE_INTERVAL)
|
setInterval(() => pi().executeTrades(), TRADE_INTERVAL)
|
||||||
setInterval(() => cashOutTx.monitorLiveIncoming(settings()), LIVE_INCOMING_TX_INTERVAL)
|
setInterval(() => cashOutTx.monitorLiveIncoming(settings(), false, coinFilter), LIVE_INCOMING_TX_INTERVAL)
|
||||||
setInterval(() => cashOutTx.monitorStaleIncoming(settings()), INCOMING_TX_INTERVAL)
|
setInterval(() => cashOutTx.monitorStaleIncoming(settings(), false, coinFilter), INCOMING_TX_INTERVAL)
|
||||||
|
if (!_.isEmpty(coinFilter)) {
|
||||||
|
setInterval(() => cashOutTx.monitorLiveIncoming(settings(), true, coinFilter), LIVE_INCOMING_TX_INTERVAL_FILTER)
|
||||||
|
setInterval(() => cashOutTx.monitorStaleIncoming(settings(), true, coinFilter), INCOMING_TX_INTERVAL_FILTER)
|
||||||
|
}
|
||||||
setInterval(() => cashOutTx.monitorUnnotified(settings()), UNNOTIFIED_INTERVAL)
|
setInterval(() => cashOutTx.monitorUnnotified(settings()), UNNOTIFIED_INTERVAL)
|
||||||
setInterval(() => cashInTx.monitorPending(settings()), PENDING_INTERVAL)
|
setInterval(() => cashInTx.monitorPending(settings()), PENDING_INTERVAL)
|
||||||
setInterval(() => pi().sweepHd(), SWEEP_HD_INTERVAL)
|
setInterval(() => pi().sweepHd(), SWEEP_HD_INTERVAL)
|
||||||
|
|
|
||||||
29
lib/promo-codes.js
Normal file
29
lib/promo-codes.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
const db = require('./db')
|
||||||
|
const uuid = require('uuid')
|
||||||
|
|
||||||
|
function getAvailablePromoCodes () {
|
||||||
|
const sql = `SELECT * FROM coupons WHERE soft_deleted=false`
|
||||||
|
return db.any(sql)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPromoCode (code) {
|
||||||
|
const sql = `SELECT * FROM coupons WHERE code=$1 AND soft_deleted=false`
|
||||||
|
return db.oneOrNone(sql, [code])
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPromoCode (code, discount) {
|
||||||
|
const sql = `INSERT INTO coupons (id, code, discount) VALUES ($1, $2, $3) RETURNING *`
|
||||||
|
return db.one(sql, [uuid.v4(), code, discount])
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePromoCode (id) {
|
||||||
|
const sql = `UPDATE coupons SET soft_deleted=true WHERE id=$1`
|
||||||
|
return db.none(sql, [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNumberOfAvailablePromoCodes () {
|
||||||
|
const sql = `SELECT COUNT(id) FROM coupons WHERE soft_deleted=false`
|
||||||
|
return db.one(sql).then(res => res.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getAvailablePromoCodes, getPromoCode, createPromoCode, deletePromoCode, getNumberOfAvailablePromoCodes }
|
||||||
|
|
@ -25,6 +25,10 @@ const E = require('./error')
|
||||||
const customers = require('./customers')
|
const customers = require('./customers')
|
||||||
const logs = require('./logs')
|
const logs = require('./logs')
|
||||||
const compliance = require('./compliance')
|
const compliance = require('./compliance')
|
||||||
|
const promoCodes = require('./promo-codes')
|
||||||
|
const BN = require('./bn')
|
||||||
|
const commissionMath = require('./commission-math')
|
||||||
|
const notifier = require('./notifier')
|
||||||
|
|
||||||
const version = require('../package.json').version
|
const version = require('../package.json').version
|
||||||
|
|
||||||
|
|
@ -65,8 +69,10 @@ function poll (req, res, next) {
|
||||||
const triggers = configManager.getTriggers(settings.config)
|
const triggers = configManager.getTriggers(settings.config)
|
||||||
|
|
||||||
const operatorInfo = configManager.getOperatorInfo(settings.config)
|
const operatorInfo = configManager.getOperatorInfo(settings.config)
|
||||||
|
const machineInfo = { deviceId: req.deviceId, deviceName: req.deviceName }
|
||||||
const cashOutConfig = configManager.getCashOut(deviceId, settings.config)
|
const cashOutConfig = configManager.getCashOut(deviceId, settings.config)
|
||||||
const receipt = configManager.getReceipt(settings.config)
|
const receipt = configManager.getReceipt(settings.config)
|
||||||
|
const terms = configManager.getTermsConditions(settings.config)
|
||||||
|
|
||||||
pids[deviceId] = { pid, ts: Date.now() }
|
pids[deviceId] = { pid, ts: Date.now() }
|
||||||
|
|
||||||
|
|
@ -102,9 +108,9 @@ function poll (req, res, next) {
|
||||||
hasLightning,
|
hasLightning,
|
||||||
receipt,
|
receipt,
|
||||||
operatorInfo,
|
operatorInfo,
|
||||||
|
machineInfo,
|
||||||
triggers
|
triggers
|
||||||
}
|
}
|
||||||
|
|
||||||
// BACKWARDS_COMPATIBILITY 7.5
|
// BACKWARDS_COMPATIBILITY 7.5
|
||||||
// machines before 7.5 expect old compliance
|
// machines before 7.5 expect old compliance
|
||||||
if (!machineVersion || semver.lt(machineVersion, '7.5.0-beta.0')) {
|
if (!machineVersion || semver.lt(machineVersion, '7.5.0-beta.0')) {
|
||||||
|
|
@ -124,9 +130,8 @@ function poll (req, res, next) {
|
||||||
// BACKWARDS_COMPATIBILITY 7.4.9
|
// BACKWARDS_COMPATIBILITY 7.4.9
|
||||||
// machines before 7.4.9 expect t&c on poll
|
// machines before 7.4.9 expect t&c on poll
|
||||||
if (!machineVersion || semver.lt(machineVersion, '7.4.9')) {
|
if (!machineVersion || semver.lt(machineVersion, '7.4.9')) {
|
||||||
response.terms = config.termsScreenActive && config.termsScreenText ? createTerms(config) : null
|
response.terms = createTerms(terms)
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json(_.assign(response, results))
|
return res.json(_.assign(response, results))
|
||||||
})
|
})
|
||||||
.catch(next)
|
.catch(next)
|
||||||
|
|
@ -213,6 +218,31 @@ function verifyTx (req, res, next) {
|
||||||
.catch(next)
|
.catch(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verifyPromoCode (req, res, next) {
|
||||||
|
promoCodes.getPromoCode(req.body.codeInput)
|
||||||
|
.then(promoCode => {
|
||||||
|
if (!promoCode) return next()
|
||||||
|
|
||||||
|
const transaction = req.body.tx
|
||||||
|
const commissions = configManager.getCommissions(transaction.cryptoCode, req.deviceId, req.settings.config)
|
||||||
|
const tickerRate = BN(transaction.rawTickerPrice)
|
||||||
|
const discount = commissionMath.getDiscountRate(promoCode.discount, commissions[transaction.direction])
|
||||||
|
const rates = {
|
||||||
|
[transaction.cryptoCode]: {
|
||||||
|
[transaction.direction]: (transaction.direction === 'cashIn')
|
||||||
|
? tickerRate.mul(discount).round(5)
|
||||||
|
: tickerRate.div(discount).round(5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(req, res, {
|
||||||
|
promoCode: promoCode,
|
||||||
|
newRates: rates
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(next)
|
||||||
|
}
|
||||||
|
|
||||||
function addOrUpdateCustomer (req) {
|
function addOrUpdateCustomer (req) {
|
||||||
const customerData = req.body
|
const customerData = req.body
|
||||||
const machineVersion = req.query.version
|
const machineVersion = req.query.version
|
||||||
|
|
@ -314,7 +344,10 @@ function triggerBlock (req, res, next) {
|
||||||
const id = req.params.id
|
const id = req.params.id
|
||||||
|
|
||||||
customers.update(id, { authorizedOverride: 'blocked' })
|
customers.update(id, { authorizedOverride: 'blocked' })
|
||||||
.then(customer => respond(req, res, { customer }))
|
.then(customer => {
|
||||||
|
notifier.notifyIfActive('compliance', 'customerComplianceNotify', customer, req.deviceId, 'BLOCKED')
|
||||||
|
return respond(req, res, { customer })
|
||||||
|
})
|
||||||
.catch(next)
|
.catch(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,7 +363,10 @@ function triggerSuspend (req, res, next) {
|
||||||
const date = new Date()
|
const date = new Date()
|
||||||
date.setDate(date.getDate() + days);
|
date.setDate(date.getDate() + days);
|
||||||
customers.update(id, { suspendedUntil: date })
|
customers.update(id, { suspendedUntil: date })
|
||||||
.then(customer => respond(req, res, { customer }))
|
.then(customer => {
|
||||||
|
notifier.notifyIfActive('compliance', 'customerComplianceNotify', customer, req.deviceId, 'SUSPENDED', days)
|
||||||
|
return respond(req, res, { customer })
|
||||||
|
})
|
||||||
.catch(next)
|
.catch(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -395,7 +431,11 @@ function errorHandler (err, req, res, next) {
|
||||||
function respond (req, res, _body, _status) {
|
function respond (req, res, _body, _status) {
|
||||||
const status = _status || 200
|
const status = _status || 200
|
||||||
const body = _body || {}
|
const body = _body || {}
|
||||||
|
const customer = _.getOr({ sanctions: true }, ['customer'], body)
|
||||||
|
// sanctions can be null for new customers so we can't use falsy checks
|
||||||
|
if (customer.sanctions === false) {
|
||||||
|
notifier.notifyIfActive('compliance', 'sanctionsNotify', customer, req.body.phone)
|
||||||
|
}
|
||||||
return res.status(status).json(body)
|
return res.status(status).json(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -450,7 +490,8 @@ const configRequiredRoutes = [
|
||||||
'/event',
|
'/event',
|
||||||
'/phone_code',
|
'/phone_code',
|
||||||
'/customer',
|
'/customer',
|
||||||
'/tx'
|
'/tx',
|
||||||
|
'/verify_promo_code'
|
||||||
]
|
]
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
@ -477,6 +518,7 @@ app.post('/state', stateChange)
|
||||||
|
|
||||||
app.post('/verify_user', verifyUser)
|
app.post('/verify_user', verifyUser)
|
||||||
app.post('/verify_transaction', verifyTx)
|
app.post('/verify_transaction', verifyTx)
|
||||||
|
app.post('/verify_promo_code', verifyPromoCode)
|
||||||
|
|
||||||
app.post('/phone_code', getCustomerWithPhoneCode)
|
app.post('/phone_code', getCustomerWithPhoneCode)
|
||||||
app.patch('/customer/:id', updateCustomer)
|
app.patch('/customer/:id', updateCustomer)
|
||||||
|
|
|
||||||
|
|
@ -209,11 +209,23 @@ function isStrictAddress (settings, cryptoCode, toAddress) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const balance = mem(_balance, {
|
const coinFilter = ['ETH']
|
||||||
|
|
||||||
|
const balance = (settings, cryptoCode) => {
|
||||||
|
if (_.includes(coinFilter, cryptoCode)) return balanceFiltered(settings, cryptoCode)
|
||||||
|
return balanceUnfiltered(settings, cryptoCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceUnfiltered = mem(_balance, {
|
||||||
maxAge: FETCH_INTERVAL,
|
maxAge: FETCH_INTERVAL,
|
||||||
cacheKey: (settings, cryptoCode) => cryptoCode
|
cacheKey: (settings, cryptoCode) => cryptoCode
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const balanceFiltered = mem(_balance, {
|
||||||
|
maxAge: 3 * FETCH_INTERVAL,
|
||||||
|
cacheKey: (settings, cryptoCode) => cryptoCode
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
balance,
|
balance,
|
||||||
sendCoins,
|
sendCoins,
|
||||||
|
|
|
||||||
19
migrations/1603886141913-coupon-codes.js
Normal file
19
migrations/1603886141913-coupon-codes.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
var db = require('./db')
|
||||||
|
|
||||||
|
exports.up = function (next) {
|
||||||
|
const sql =
|
||||||
|
[
|
||||||
|
`CREATE TABLE coupons (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
discount SMALLINT NOT NULL,
|
||||||
|
soft_deleted BOOLEAN DEFAULT false )`,
|
||||||
|
`CREATE UNIQUE INDEX uq_code ON coupons (code) WHERE NOT soft_deleted`
|
||||||
|
]
|
||||||
|
|
||||||
|
db.multi(sql, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.down = function (next) {
|
||||||
|
next()
|
||||||
|
}
|
||||||
14
migrations/1604419505567-add-discount-to-txs.js
Normal file
14
migrations/1604419505567-add-discount-to-txs.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
const db = require('./db')
|
||||||
|
|
||||||
|
exports.up = function (next) {
|
||||||
|
var sql = [
|
||||||
|
'ALTER TABLE cash_in_txs ADD COLUMN discount SMALLINT',
|
||||||
|
'ALTER TABLE cash_out_txs ADD COLUMN discount SMALLINT'
|
||||||
|
]
|
||||||
|
|
||||||
|
db.multi(sql, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.down = function (next) {
|
||||||
|
next()
|
||||||
|
}
|
||||||
15
migrations/1604934042127-clean-bills.js
Normal file
15
migrations/1604934042127-clean-bills.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
const db = require('./db')
|
||||||
|
|
||||||
|
exports.up = function (next) {
|
||||||
|
var sql = [
|
||||||
|
'ALTER TABLE bills DROP COLUMN crypto_atoms',
|
||||||
|
'ALTER TABLE bills DROP COLUMN cash_in_fee_crypto',
|
||||||
|
'ALTER TABLE bills DROP COLUMN crypto_atoms_after_fee'
|
||||||
|
]
|
||||||
|
|
||||||
|
db.multi(sql, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.down = function (next) {
|
||||||
|
next()
|
||||||
|
}
|
||||||
38
migrations/1607009558538-create-notifications-table.js
Normal file
38
migrations/1607009558538-create-notifications-table.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
var db = require('./db')
|
||||||
|
|
||||||
|
const singleQuotify = (item) => `'${item}'`
|
||||||
|
|
||||||
|
var types = [
|
||||||
|
'highValueTransaction',
|
||||||
|
'transaction',
|
||||||
|
'fiatBalance',
|
||||||
|
'cryptoBalance',
|
||||||
|
'compliance',
|
||||||
|
'error'
|
||||||
|
]
|
||||||
|
.map(singleQuotify)
|
||||||
|
.join(',')
|
||||||
|
|
||||||
|
exports.up = function (next) {
|
||||||
|
const sql = [
|
||||||
|
`
|
||||||
|
CREATE TYPE notification_type AS ENUM ${'(' + types + ')'};
|
||||||
|
CREATE TABLE "notifications" (
|
||||||
|
"id" uuid NOT NULL PRIMARY KEY,
|
||||||
|
"type" notification_type NOT NULL,
|
||||||
|
"detail" JSONB,
|
||||||
|
"message" TEXT NOT NULL,
|
||||||
|
"created" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"read" BOOLEAN NOT NULL DEFAULT 'false',
|
||||||
|
"valid" BOOLEAN NOT NULL DEFAULT 'true'
|
||||||
|
);
|
||||||
|
CREATE INDEX ON notifications (valid);
|
||||||
|
CREATE INDEX ON notifications (read);`
|
||||||
|
]
|
||||||
|
|
||||||
|
db.multi(sql, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.down = function (next) {
|
||||||
|
next()
|
||||||
|
}
|
||||||
13
migrations/1610716756175-id-card-raw.js
Normal file
13
migrations/1610716756175-id-card-raw.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
var db = require('./db')
|
||||||
|
|
||||||
|
exports.up = function (next) {
|
||||||
|
var sql = [
|
||||||
|
'ALTER TABLE customers ADD COLUMN id_card_data_raw text'
|
||||||
|
]
|
||||||
|
|
||||||
|
db.multi(sql, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.down = function (next) {
|
||||||
|
next()
|
||||||
|
}
|
||||||
22771
new-lamassu-admin/package-lock.json
generated
22771
new-lamassu-admin/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,6 +16,7 @@
|
||||||
"axios": "0.19.0",
|
"axios": "0.19.0",
|
||||||
"bignumber.js": "9.0.0",
|
"bignumber.js": "9.0.0",
|
||||||
"classnames": "2.2.6",
|
"classnames": "2.2.6",
|
||||||
|
"d3": "^6.2.0",
|
||||||
"downshift": "3.3.4",
|
"downshift": "3.3.4",
|
||||||
"file-saver": "2.0.2",
|
"file-saver": "2.0.2",
|
||||||
"formik": "2.2.0",
|
"formik": "2.2.0",
|
||||||
|
|
@ -36,7 +37,7 @@
|
||||||
"react-virtualized": "^9.21.2",
|
"react-virtualized": "^9.21.2",
|
||||||
"sanctuary": "^2.0.1",
|
"sanctuary": "^2.0.1",
|
||||||
"uuid": "^7.0.2",
|
"uuid": "^7.0.2",
|
||||||
"yup": "0.29.3"
|
"yup": "0.32.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@storybook/addon-actions": "6.0.26",
|
"@storybook/addon-actions": "6.0.26",
|
||||||
|
|
@ -47,11 +48,12 @@
|
||||||
"@storybook/preset-create-react-app": "^3.1.4",
|
"@storybook/preset-create-react-app": "^3.1.4",
|
||||||
"@storybook/react": "6.0.26",
|
"@storybook/react": "6.0.26",
|
||||||
"@welldone-software/why-did-you-render": "^3.3.9",
|
"@welldone-software/why-did-you-render": "^3.3.9",
|
||||||
|
"eslint": "^7.19.0",
|
||||||
"eslint-config-prettier": "^6.7.0",
|
"eslint-config-prettier": "^6.7.0",
|
||||||
"eslint-config-prettier-standard": "^3.0.1",
|
"eslint-config-prettier-standard": "^3.0.1",
|
||||||
"eslint-config-standard": "^14.1.0",
|
"eslint-config-standard": "^14.1.0",
|
||||||
"eslint-import-resolver-alias": "^1.1.2",
|
"eslint-import-resolver-alias": "^1.1.2",
|
||||||
"eslint-plugin-import": "^2.20.0",
|
"eslint-plugin-import": "^2.22.1",
|
||||||
"eslint-plugin-node": "^10.0.0",
|
"eslint-plugin-node": "^10.0.0",
|
||||||
"eslint-plugin-prettier": "^3.1.2",
|
"eslint-plugin-prettier": "^3.1.2",
|
||||||
"eslint-plugin-promise": "^4.2.1",
|
"eslint-plugin-promise": "^4.2.1",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||||
/>
|
/>
|
||||||
|
<meta name="robots" content="noindex"/>
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<!--
|
<!--
|
||||||
manifest.json provides metadata used when your web app is installed on a
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import CssBaseline from '@material-ui/core/CssBaseline'
|
import CssBaseline from '@material-ui/core/CssBaseline'
|
||||||
import Grid from '@material-ui/core/Grid'
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import Slide from '@material-ui/core/Slide'
|
||||||
import {
|
import {
|
||||||
StylesProvider,
|
StylesProvider,
|
||||||
jssPreset,
|
jssPreset,
|
||||||
|
|
@ -96,7 +97,17 @@ const Main = () => {
|
||||||
{!is404 && wizardTested && <Header tree={tree} />}
|
{!is404 && wizardTested && <Header tree={tree} />}
|
||||||
<main className={classes.wrapper}>
|
<main className={classes.wrapper}>
|
||||||
{sidebar && !is404 && wizardTested && (
|
{sidebar && !is404 && wizardTested && (
|
||||||
<TitleSection title={parent.title}></TitleSection>
|
<Slide
|
||||||
|
direction="left"
|
||||||
|
in={true}
|
||||||
|
mountOnEnter
|
||||||
|
unmountOnExit
|
||||||
|
children={
|
||||||
|
<div>
|
||||||
|
<TitleSection title={parent.title}></TitleSection>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Grid container className={classes.grid}>
|
<Grid container className={classes.grid}>
|
||||||
|
|
|
||||||
41
new-lamassu-admin/src/components/CollapsibleCard.js
Normal file
41
new-lamassu-admin/src/components/CollapsibleCard.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import classnames from 'classnames'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { white } from 'src/styling/variables'
|
||||||
|
|
||||||
|
const cardState = Object.freeze({
|
||||||
|
DEFAULT: 'default',
|
||||||
|
SHRUNK: 'shrunk',
|
||||||
|
EXPANDED: 'expanded'
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
card: {
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
boxShadow: '0 0 4px 0 rgba(0, 0, 0, 0.08)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 24,
|
||||||
|
backgroundColor: white
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const CollapsibleCard = ({ className, state, shrunkComponent, children }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
return (
|
||||||
|
<Grid item className={classnames(className, classes.card)}>
|
||||||
|
{state === cardState.SHRUNK ? shrunkComponent : children}
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CollapsibleCard.propTypes = {
|
||||||
|
shrunkComponent: PropTypes.node.isRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CollapsibleCard
|
||||||
|
export { cardState }
|
||||||
|
|
@ -115,7 +115,6 @@ export const ConfirmDialog = memo(
|
||||||
error={error}
|
error={error}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={() => setError(isOnErrorState)}
|
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions className={classes.dialogActions}>
|
<DialogActions className={classes.dialogActions}>
|
||||||
|
|
|
||||||
96
new-lamassu-admin/src/components/DeleteDialog.js
Normal file
96
new-lamassu-admin/src/components/DeleteDialog.js
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
makeStyles
|
||||||
|
} from '@material-ui/core'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Button, IconButton } from 'src/components/buttons'
|
||||||
|
import { H4, P } from 'src/components/typography'
|
||||||
|
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
|
||||||
|
import { spacer } from 'src/styling/variables'
|
||||||
|
|
||||||
|
import ErrorMessage from './ErrorMessage'
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
content: {
|
||||||
|
width: 434,
|
||||||
|
padding: spacer * 2,
|
||||||
|
paddingRight: spacer * 3.5
|
||||||
|
},
|
||||||
|
titleSection: {
|
||||||
|
padding: spacer * 2,
|
||||||
|
paddingRight: spacer * 1.5,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
margin: 0
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
padding: spacer * 4,
|
||||||
|
paddingTop: spacer * 2
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
margin: 0
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 0,
|
||||||
|
marginTop: -(spacer / 2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DialogTitle = ({ children, close }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
return (
|
||||||
|
<div className={classes.titleSection}>
|
||||||
|
{children}
|
||||||
|
{close && (
|
||||||
|
<IconButton
|
||||||
|
size={16}
|
||||||
|
aria-label="close"
|
||||||
|
onClick={close}
|
||||||
|
className={classes.closeButton}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteDialog = ({
|
||||||
|
title = 'Confirm Delete',
|
||||||
|
open = false,
|
||||||
|
onConfirmed,
|
||||||
|
onDismissed,
|
||||||
|
item = 'item',
|
||||||
|
confirmationMessage = `Are you sure you want to delete this ${item}?`,
|
||||||
|
errorMessage = ''
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} aria-labelledby="form-dialog-title">
|
||||||
|
<DialogTitle close={() => onDismissed()}>
|
||||||
|
<H4 className={classes.title}>{title}</H4>
|
||||||
|
</DialogTitle>
|
||||||
|
{errorMessage && (
|
||||||
|
<DialogTitle>
|
||||||
|
<ErrorMessage>
|
||||||
|
{errorMessage.split(':').map(error => (
|
||||||
|
<>
|
||||||
|
{error}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</ErrorMessage>
|
||||||
|
</DialogTitle>
|
||||||
|
)}
|
||||||
|
<DialogContent className={classes.content}>
|
||||||
|
{confirmationMessage && <P>{confirmationMessage}</P>}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions className={classes.actions}>
|
||||||
|
<Button onClick={onConfirmed}>Confirm</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
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, useEffect } 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
|
||||||
|
type
|
||||||
|
detail
|
||||||
|
message
|
||||||
|
created
|
||||||
|
read
|
||||||
|
valid
|
||||||
|
}
|
||||||
|
hasUnreadNotifications
|
||||||
|
machines {
|
||||||
|
deviceId
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const TOGGLE_CLEAR_NOTIFICATION = gql`
|
||||||
|
mutation toggleClearNotification($id: ID!, $read: Boolean!) {
|
||||||
|
toggleClearNotification(id: $id, read: $read) {
|
||||||
|
id
|
||||||
|
read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const CLEAR_ALL_NOTIFICATIONS = gql`
|
||||||
|
mutation clearAllNotifications {
|
||||||
|
clearAllNotifications {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const NotificationCenter = ({
|
||||||
|
close,
|
||||||
|
hasUnreadProp,
|
||||||
|
buttonCoords,
|
||||||
|
popperRef,
|
||||||
|
refetchHasUnreadHeader
|
||||||
|
}) => {
|
||||||
|
const { data, loading } = useQuery(GET_NOTIFICATIONS, {
|
||||||
|
pollInterval: 60000
|
||||||
|
})
|
||||||
|
const [xOffset, setXoffset] = useState(300)
|
||||||
|
|
||||||
|
const [showingUnread, setShowingUnread] = useState(false)
|
||||||
|
const classes = useStyles({ buttonCoords, xOffset })
|
||||||
|
const machines = R.compose(
|
||||||
|
R.map(R.prop('name')),
|
||||||
|
R.indexBy(R.prop('deviceId'))
|
||||||
|
)(R.path(['machines'])(data) ?? [])
|
||||||
|
const notifications = R.path(['notifications'])(data) ?? []
|
||||||
|
const [hasUnread, setHasUnread] = useState(hasUnreadProp)
|
||||||
|
|
||||||
|
const [toggleClearNotification] = useMutation(TOGGLE_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']
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setXoffset(popperRef.current.getBoundingClientRect().x)
|
||||||
|
if (data && data.hasUnreadNotifications !== hasUnread) {
|
||||||
|
refetchHasUnreadHeader()
|
||||||
|
setHasUnread(!hasUnread)
|
||||||
|
}
|
||||||
|
}, [popperRef, data, hasUnread, refetchHasUnreadHeader])
|
||||||
|
|
||||||
|
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}
|
||||||
|
toggleClear={() =>
|
||||||
|
toggleClearNotification({
|
||||||
|
variables: { id: n.id, read: !n.read }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{hasUnread && (
|
||||||
|
<ActionButton
|
||||||
|
color="primary"
|
||||||
|
Icon={ClearAllIcon}
|
||||||
|
InverseIcon={ClearAllIconInverse}
|
||||||
|
className={classes.clearAllButton}
|
||||||
|
onClick={clearAllNotifications}>
|
||||||
|
Mark all as read
|
||||||
|
</ActionButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={classes.notificationsList}>
|
||||||
|
{!loading && buildNotifications()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={classes.background} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationCenter
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
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,
|
||||||
|
height: 0
|
||||||
|
},
|
||||||
|
notificationIcon: ({ buttonCoords, xOffset }) => ({
|
||||||
|
position: 'absolute',
|
||||||
|
top: buttonCoords ? buttonCoords.y - 1 : 0,
|
||||||
|
left: buttonCoords ? buttonCoords.x - xOffset : 0,
|
||||||
|
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: spacer * 3,
|
||||||
|
marginLeft: 0,
|
||||||
|
marginRight: -50,
|
||||||
|
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,
|
||||||
|
marginTop: 5,
|
||||||
|
width: '12px',
|
||||||
|
height: '12px',
|
||||||
|
backgroundColor: secondaryColor,
|
||||||
|
borderRadius: '50%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
zIndex: 1
|
||||||
|
},
|
||||||
|
readIcon: {
|
||||||
|
marginLeft: spacer,
|
||||||
|
marginTop: 5,
|
||||||
|
width: '12px',
|
||||||
|
height: '12px',
|
||||||
|
border: [[1, 'solid', comet]],
|
||||||
|
borderRadius: '50%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
zIndex: 1
|
||||||
|
},
|
||||||
|
notificationTitle: {
|
||||||
|
margin: 0,
|
||||||
|
color: comet
|
||||||
|
},
|
||||||
|
notificationBody: {
|
||||||
|
margin: 0
|
||||||
|
},
|
||||||
|
notificationSubtitle: {
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: spacer,
|
||||||
|
color: comet
|
||||||
|
},
|
||||||
|
stripes: {
|
||||||
|
position: 'absolute',
|
||||||
|
height: '100%',
|
||||||
|
top: '0px',
|
||||||
|
opacity: '60%'
|
||||||
|
},
|
||||||
|
hasUnread: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 16,
|
||||||
|
width: '9px',
|
||||||
|
height: '9px',
|
||||||
|
backgroundColor: secondaryColor,
|
||||||
|
borderRadius: '50%'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default styles
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import classnames from 'classnames'
|
||||||
|
import prettyMs from 'pretty-ms'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
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 = {
|
||||||
|
transaction: { display: 'Transactions', icon: <Transaction /> },
|
||||||
|
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,
|
||||||
|
toggleClear
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const typeDisplay = R.path([type, 'display'])(types) ?? null
|
||||||
|
const icon = R.path([type, 'icon'])(types) ?? <Wrench />
|
||||||
|
const age = prettyMs(new Date().getTime() - new Date(created).getTime(), {
|
||||||
|
compact: true,
|
||||||
|
verbose: true
|
||||||
|
})
|
||||||
|
const notificationTitle =
|
||||||
|
typeDisplay && deviceName
|
||||||
|
? `${typeDisplay} - ${deviceName}`
|
||||||
|
: !typeDisplay && deviceName
|
||||||
|
? `${deviceName}`
|
||||||
|
: `${typeDisplay}`
|
||||||
|
|
||||||
|
const iconClass = {
|
||||||
|
[classes.readIcon]: read,
|
||||||
|
[classes.unreadIcon]: !read
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
className={classnames(
|
||||||
|
classes.notificationRow,
|
||||||
|
!read && valid ? classes.unread : ''
|
||||||
|
)}>
|
||||||
|
<Grid item xs={2} className={classes.notificationRowIcon}>
|
||||||
|
{icon}
|
||||||
|
</Grid>
|
||||||
|
<Grid item container xs={7} direction="row">
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Label2 className={classes.notificationTitle}>
|
||||||
|
{notificationTitle}
|
||||||
|
</Label2>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TL2 className={classes.notificationBody}>{message}</TL2>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Label1 className={classes.notificationSubtitle}>{age}</Label1>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={3} style={{ zIndex: 1 }}>
|
||||||
|
<div
|
||||||
|
onClick={() => toggleClear(id)}
|
||||||
|
className={classnames(iconClass)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
{!valid && <StripesSvg className={classes.stripes} />}
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationRow
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
import NotificationCenter from './NotificationCenter'
|
||||||
|
export default NotificationCenter
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useFormikContext } from 'formik'
|
import { useFormikContext } from 'formik'
|
||||||
import React from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { Prompt } from 'react-router-dom'
|
import { Prompt } from 'react-router-dom'
|
||||||
|
|
||||||
const PROMPT_DEFAULT_MESSAGE =
|
const PROMPT_DEFAULT_MESSAGE =
|
||||||
|
|
@ -8,9 +8,21 @@ const PROMPT_DEFAULT_MESSAGE =
|
||||||
const PromptWhenDirty = ({ message = PROMPT_DEFAULT_MESSAGE }) => {
|
const PromptWhenDirty = ({ message = PROMPT_DEFAULT_MESSAGE }) => {
|
||||||
const formik = useFormikContext()
|
const formik = useFormikContext()
|
||||||
|
|
||||||
return (
|
const hasChanges = formik.dirty && formik.submitCount === 0
|
||||||
<Prompt when={formik.dirty && formik.submitCount === 0} message={message} />
|
|
||||||
)
|
useEffect(() => {
|
||||||
|
if (hasChanges) {
|
||||||
|
window.onbeforeunload = confirmExit
|
||||||
|
} else {
|
||||||
|
window.onbeforeunload = undefined
|
||||||
|
}
|
||||||
|
}, [hasChanges])
|
||||||
|
|
||||||
|
const confirmExit = () => {
|
||||||
|
return PROMPT_DEFAULT_MESSAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Prompt when={hasChanges} message={message} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PromptWhenDirty
|
export default PromptWhenDirty
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const useStyles = makeStyles({
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const Tooltip = memo(({ children, width, Icon = HelpIcon }) => {
|
const usePopperHandler = width => {
|
||||||
const classes = useStyles({ width })
|
const classes = useStyles({ width })
|
||||||
const [helpPopperAnchorEl, setHelpPopperAnchorEl] = useState(null)
|
const [helpPopperAnchorEl, setHelpPopperAnchorEl] = useState(null)
|
||||||
|
|
||||||
|
|
@ -32,23 +32,56 @@ const Tooltip = memo(({ children, width, Icon = HelpIcon }) => {
|
||||||
|
|
||||||
const helpPopperOpen = Boolean(helpPopperAnchorEl)
|
const helpPopperOpen = Boolean(helpPopperAnchorEl)
|
||||||
|
|
||||||
|
return {
|
||||||
|
classes,
|
||||||
|
helpPopperAnchorEl,
|
||||||
|
helpPopperOpen,
|
||||||
|
handleOpenHelpPopper,
|
||||||
|
handleCloseHelpPopper
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tooltip = memo(({ children, width, Icon = HelpIcon }) => {
|
||||||
|
const handler = usePopperHandler(width)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClickAwayListener onClickAway={handleCloseHelpPopper}>
|
<ClickAwayListener onClickAway={handler.handleCloseHelpPopper}>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
className={classes.transparentButton}
|
type="button"
|
||||||
onClick={handleOpenHelpPopper}>
|
className={handler.classes.transparentButton}
|
||||||
|
onClick={handler.handleOpenHelpPopper}>
|
||||||
<Icon />
|
<Icon />
|
||||||
</button>
|
</button>
|
||||||
<Popper
|
<Popper
|
||||||
open={helpPopperOpen}
|
open={handler.helpPopperOpen}
|
||||||
anchorEl={helpPopperAnchorEl}
|
anchorEl={handler.helpPopperAnchorEl}
|
||||||
placement="bottom">
|
placement="bottom">
|
||||||
<div className={classes.popoverContent}>{children}</div>
|
<div className={handler.classes.popoverContent}>{children}</div>
|
||||||
</Popper>
|
</Popper>
|
||||||
</div>
|
</div>
|
||||||
</ClickAwayListener>
|
</ClickAwayListener>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default Tooltip
|
const HoverableTooltip = memo(({ parentElements, children, width }) => {
|
||||||
|
const handler = usePopperHandler(width)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
onMouseEnter={handler.handleOpenHelpPopper}
|
||||||
|
onMouseLeave={handler.handleCloseHelpPopper}>
|
||||||
|
{parentElements}
|
||||||
|
</div>
|
||||||
|
<Popper
|
||||||
|
open={handler.helpPopperOpen}
|
||||||
|
anchorEl={handler.helpPopperAnchorEl}
|
||||||
|
placement="bottom">
|
||||||
|
<div className={handler.classes.popoverContent}>{children}</div>
|
||||||
|
</Popper>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export { Tooltip, HoverableTooltip }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
import { useFormikContext, Form, Formik, Field as FormikField } from 'formik'
|
import { useFormikContext, Form, Formik, Field as FormikField } from 'formik'
|
||||||
import _ from 'lodash'
|
import * as R from 'ramda'
|
||||||
import React, { useState, memo } from 'react'
|
import React, { useState, memo } from 'react'
|
||||||
import * as Yup from 'yup'
|
import * as Yup from 'yup'
|
||||||
|
|
||||||
|
|
@ -26,8 +26,11 @@ const BooleanCell = ({ name }) => {
|
||||||
|
|
||||||
const BooleanPropertiesTable = memo(
|
const BooleanPropertiesTable = memo(
|
||||||
({ title, disabled, data, elements, save, forcedEditing = false }) => {
|
({ title, disabled, data, elements, save, forcedEditing = false }) => {
|
||||||
const initialValues = _.fromPairs(elements.map(it => [it.name, '']))
|
const initialValues = R.fromPairs(
|
||||||
const schemaValidation = _.fromPairs(
|
elements.map(it => [it.name, data[it.name] ?? null])
|
||||||
|
)
|
||||||
|
|
||||||
|
const schemaValidation = R.fromPairs(
|
||||||
elements.map(it => [it.name, Yup.boolean().required()])
|
elements.map(it => [it.name, Yup.boolean().required()])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -36,74 +39,84 @@ const BooleanPropertiesTable = memo(
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
|
|
||||||
const innerSave = async value => {
|
const innerSave = async value => {
|
||||||
save(value)
|
save(R.filter(R.complement(R.isNil), value))
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const innerCancel = () => setEditing(false)
|
|
||||||
|
|
||||||
const radioButtonOptions = [
|
const radioButtonOptions = [
|
||||||
{ display: 'Yes', code: 'true' },
|
{ display: 'Yes', code: 'true' },
|
||||||
{ display: 'No', code: 'false' }
|
{ display: 'No', code: 'false' }
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.booleanPropertiesTableWrapper}>
|
<div className={classes.booleanPropertiesTableWrapper}>
|
||||||
<Formik
|
<Formik
|
||||||
|
validateOnBlur={false}
|
||||||
|
validateOnChange={false}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
onSubmit={innerSave}
|
onSubmit={innerSave}
|
||||||
initialValues={data || initialValues}
|
initialValues={initialValues}
|
||||||
schemaValidation={schemaValidation}>
|
schemaValidation={schemaValidation}>
|
||||||
<Form>
|
{({ resetForm }) => {
|
||||||
<div className={classes.rowWrapper}>
|
return (
|
||||||
<H4>{title}</H4>
|
<Form>
|
||||||
{editing ? (
|
<div className={classes.rowWrapper}>
|
||||||
<div className={classes.rightAligned}>
|
<H4>{title}</H4>
|
||||||
<Link type="submit" color="primary">
|
{editing ? (
|
||||||
Save
|
<div className={classes.rightAligned}>
|
||||||
</Link>
|
<Link type="submit" color="primary">
|
||||||
<Link
|
Save
|
||||||
className={classes.rightLink}
|
</Link>
|
||||||
onClick={innerCancel}
|
<Link
|
||||||
color="secondary">
|
type="reset"
|
||||||
Cancel
|
className={classes.rightLink}
|
||||||
</Link>
|
onClick={() => {
|
||||||
|
resetForm()
|
||||||
|
setEditing(false)
|
||||||
|
}}
|
||||||
|
color="secondary">
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
className={classes.transparentButton}
|
||||||
|
onClick={() => setEditing(true)}>
|
||||||
|
{disabled ? <EditIconDisabled /> : <EditIcon />}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<PromptWhenDirty />
|
||||||
<IconButton
|
<Table className={classes.fillColumn}>
|
||||||
className={classes.transparentButton}
|
<TableBody className={classes.fillColumn}>
|
||||||
onClick={() => setEditing(true)}>
|
{elements.map((it, idx) => (
|
||||||
{disabled ? <EditIconDisabled /> : <EditIcon />}
|
<TableRow
|
||||||
</IconButton>
|
key={idx}
|
||||||
)}
|
size="sm"
|
||||||
</div>
|
className={classes.tableRow}>
|
||||||
<PromptWhenDirty />
|
<TableCell className={classes.leftTableCell}>
|
||||||
<Table className={classes.fillColumn}>
|
{it.display}
|
||||||
<TableBody className={classes.fillColumn}>
|
</TableCell>
|
||||||
{elements.map((it, idx) => (
|
<TableCell className={classes.rightTableCell}>
|
||||||
<TableRow key={idx} size="sm" className={classes.tableRow}>
|
{editing && (
|
||||||
<TableCell className={classes.leftTableCell}>
|
<FormikField
|
||||||
{it.display}
|
component={RadioGroup}
|
||||||
</TableCell>
|
name={it.name}
|
||||||
<TableCell className={classes.rightTableCell}>
|
options={radioButtonOptions}
|
||||||
{editing && (
|
className={classnames(
|
||||||
<FormikField
|
classes.radioButtons,
|
||||||
component={RadioGroup}
|
classes.rightTableCell
|
||||||
name={it.name}
|
)}
|
||||||
options={radioButtonOptions}
|
/>
|
||||||
className={classnames(
|
|
||||||
classes.radioButtons,
|
|
||||||
classes.rightTableCell
|
|
||||||
)}
|
)}
|
||||||
/>
|
{!editing && <BooleanCell name={it.name} />}
|
||||||
)}
|
</TableCell>
|
||||||
{!editing && <BooleanCell name={it.name} />}
|
</TableRow>
|
||||||
</TableCell>
|
))}
|
||||||
</TableRow>
|
</TableBody>
|
||||||
))}
|
</Table>
|
||||||
</TableBody>
|
</Form>
|
||||||
</Table>
|
)
|
||||||
</Form>
|
}}
|
||||||
</Formik>
|
</Formik>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { ClickAwayListener } from '@material-ui/core'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
import React, { useState, memo } from 'react'
|
import React, { useState, memo } from 'react'
|
||||||
|
|
@ -101,22 +102,24 @@ const IDButton = memo(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<ClickAwayListener onClickAway={handleClose}>
|
||||||
aria-describedby={id}
|
<button
|
||||||
onClick={handleClick}
|
aria-describedby={id}
|
||||||
className={classnames(classNames, className)}
|
onClick={handleClick}
|
||||||
{...props}>
|
className={classnames(classNames, className)}
|
||||||
{Icon && !open && (
|
{...props}>
|
||||||
<div className={classnames(iconClassNames)}>
|
{Icon && !open && (
|
||||||
<Icon />
|
<div className={classnames(iconClassNames)}>
|
||||||
</div>
|
<Icon />
|
||||||
)}
|
</div>
|
||||||
{InverseIcon && open && (
|
)}
|
||||||
<div className={classnames(iconClassNames)}>
|
{InverseIcon && open && (
|
||||||
<InverseIcon />
|
<div className={classnames(iconClassNames)}>
|
||||||
</div>
|
<InverseIcon />
|
||||||
)}
|
</div>
|
||||||
</button>
|
)}
|
||||||
|
</button>
|
||||||
|
</ClickAwayListener>
|
||||||
<Popover
|
<Popover
|
||||||
className={popoverClassname}
|
className={popoverClassname}
|
||||||
id={id}
|
id={id}
|
||||||
|
|
|
||||||
|
|
@ -25,17 +25,16 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
buttonIcon: {
|
buttonIcon: {
|
||||||
'& svg': {
|
width: 16,
|
||||||
width: 16,
|
height: 16,
|
||||||
height: 16,
|
overflow: 'visible',
|
||||||
overflow: 'visible',
|
'& g': {
|
||||||
'& g': {
|
strokeWidth: 1.8
|
||||||
strokeWidth: 1.8
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
buttonIconActiveLeft: {
|
buttonIconActiveLeft: {
|
||||||
marginRight: 12
|
marginRight: 12,
|
||||||
|
marginLeft: 4
|
||||||
},
|
},
|
||||||
buttonIconActiveRight: {
|
buttonIconActiveRight: {
|
||||||
marginRight: 5,
|
marginRight: 5,
|
||||||
|
|
|
||||||
|
|
@ -1,239 +1,272 @@
|
||||||
import { makeStyles } from '@material-ui/core'
|
import { makeStyles } from '@material-ui/core'
|
||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
import { Field, useFormikContext } from 'formik'
|
import { Field, useFormikContext } from 'formik'
|
||||||
import * as R from 'ramda'
|
import * as R from 'ramda'
|
||||||
import React, { useContext } from 'react'
|
import React, { useContext, useState } from 'react'
|
||||||
|
|
||||||
import { Link, IconButton } from 'src/components/buttons'
|
import { DeleteDialog } from 'src/components/DeleteDialog'
|
||||||
import { Td, Tr } from 'src/components/fake-table/Table'
|
import { Link, IconButton } from 'src/components/buttons'
|
||||||
import { Switch } from 'src/components/inputs'
|
import { Td, Tr } from 'src/components/fake-table/Table'
|
||||||
import { TL2 } from 'src/components/typography'
|
import { Switch } from 'src/components/inputs'
|
||||||
import { ReactComponent as DisabledDeleteIcon } from 'src/styling/icons/action/delete/disabled.svg'
|
import { TL2 } from 'src/components/typography'
|
||||||
import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg'
|
import { ReactComponent as DisabledDeleteIcon } from 'src/styling/icons/action/delete/disabled.svg'
|
||||||
import { ReactComponent as DisabledEditIcon } from 'src/styling/icons/action/edit/disabled.svg'
|
import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg'
|
||||||
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
|
import { ReactComponent as DisabledEditIcon } from 'src/styling/icons/action/edit/disabled.svg'
|
||||||
import { ReactComponent as StripesSvg } from 'src/styling/icons/stripes.svg'
|
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
|
||||||
|
import { ReactComponent as StripesSvg } from 'src/styling/icons/stripes.svg'
|
||||||
import TableCtx from './Context'
|
|
||||||
import styles from './Row.styles'
|
import TableCtx from './Context'
|
||||||
|
import styles from './Row.styles'
|
||||||
const useStyles = makeStyles(styles)
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
const ActionCol = ({ disabled, editing }) => {
|
|
||||||
const classes = useStyles()
|
const ActionCol = ({ disabled, editing }) => {
|
||||||
const { values, submitForm, resetForm } = useFormikContext()
|
const classes = useStyles()
|
||||||
const {
|
const { values, submitForm, resetForm } = useFormikContext()
|
||||||
editWidth,
|
const {
|
||||||
onEdit,
|
editWidth,
|
||||||
enableEdit,
|
onEdit,
|
||||||
enableDelete,
|
enableEdit,
|
||||||
disableRowEdit,
|
enableDelete,
|
||||||
onDelete,
|
disableRowEdit,
|
||||||
deleteWidth,
|
onDelete,
|
||||||
enableToggle,
|
deleteWidth,
|
||||||
onToggle,
|
enableToggle,
|
||||||
toggleWidth,
|
onToggle,
|
||||||
forceAdd,
|
toggleWidth,
|
||||||
clearError,
|
forceAdd,
|
||||||
actionColSize
|
clearError,
|
||||||
} = useContext(TableCtx)
|
actionColSize,
|
||||||
|
error
|
||||||
const disableEdit = disabled || (disableRowEdit && disableRowEdit(values))
|
} = useContext(TableCtx)
|
||||||
const cancel = () => {
|
|
||||||
clearError()
|
const disableEdit = disabled || (disableRowEdit && disableRowEdit(values))
|
||||||
resetForm()
|
const cancel = () => {
|
||||||
}
|
clearError()
|
||||||
|
resetForm()
|
||||||
return (
|
}
|
||||||
<>
|
|
||||||
{editing && (
|
const [deleteDialog, setDeleteDialog] = useState(false)
|
||||||
<Td textAlign="center" width={actionColSize}>
|
|
||||||
<Link
|
const onConfirmed = () => {
|
||||||
className={classes.saveButton}
|
onDelete(values.id).then(res => {
|
||||||
type="submit"
|
if (!R.isNil(res)) setDeleteDialog(false)
|
||||||
color="primary"
|
})
|
||||||
onClick={submitForm}>
|
}
|
||||||
Save
|
|
||||||
</Link>
|
return (
|
||||||
{!forceAdd && (
|
<>
|
||||||
<Link color="secondary" onClick={cancel}>
|
{editing && (
|
||||||
Cancel
|
<Td textAlign="center" width={actionColSize}>
|
||||||
</Link>
|
<Link
|
||||||
)}
|
className={classes.saveButton}
|
||||||
</Td>
|
type="submit"
|
||||||
)}
|
color="primary"
|
||||||
{!editing && enableEdit && (
|
onClick={submitForm}>
|
||||||
<Td textAlign="center" width={editWidth}>
|
Save
|
||||||
<IconButton
|
</Link>
|
||||||
disabled={disableEdit}
|
{!forceAdd && (
|
||||||
className={classes.editButton}
|
<Link color="secondary" onClick={cancel}>
|
||||||
onClick={() => onEdit && onEdit(values.id)}>
|
Cancel
|
||||||
{disableEdit ? <DisabledEditIcon /> : <EditIcon />}
|
</Link>
|
||||||
</IconButton>
|
)}
|
||||||
</Td>
|
</Td>
|
||||||
)}
|
)}
|
||||||
{!editing && enableDelete && (
|
{!editing && enableEdit && (
|
||||||
<Td textAlign="center" width={deleteWidth}>
|
<Td textAlign="center" width={editWidth}>
|
||||||
<IconButton disabled={disabled} onClick={() => onDelete(values.id)}>
|
<IconButton
|
||||||
{disabled ? <DisabledDeleteIcon /> : <DeleteIcon />}
|
disabled={disableEdit}
|
||||||
</IconButton>
|
className={classes.editButton}
|
||||||
</Td>
|
onClick={() => onEdit && onEdit(values.id)}>
|
||||||
)}
|
{disableEdit ? <DisabledEditIcon /> : <EditIcon />}
|
||||||
{!editing && enableToggle && (
|
</IconButton>
|
||||||
<Td textAlign="center" width={toggleWidth}>
|
</Td>
|
||||||
<Switch
|
)}
|
||||||
checked={!!values.active}
|
{!editing && enableDelete && (
|
||||||
value={!!values.active}
|
<Td textAlign="center" width={deleteWidth}>
|
||||||
disabled={disabled}
|
<IconButton
|
||||||
onChange={() => onToggle(values.id)}
|
disabled={disabled}
|
||||||
/>
|
onClick={() => {
|
||||||
</Td>
|
setDeleteDialog(true)
|
||||||
)}
|
}}>
|
||||||
</>
|
{disabled ? <DisabledDeleteIcon /> : <DeleteIcon />}
|
||||||
)
|
</IconButton>
|
||||||
}
|
<DeleteDialog
|
||||||
|
open={deleteDialog}
|
||||||
const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => {
|
setDeleteDialog={setDeleteDialog}
|
||||||
const {
|
onConfirmed={onConfirmed}
|
||||||
name,
|
onDismissed={() => {
|
||||||
bypassField,
|
setDeleteDialog(false)
|
||||||
input,
|
clearError()
|
||||||
editable = true,
|
}}
|
||||||
size,
|
errorMessage={error}
|
||||||
bold,
|
/>
|
||||||
width,
|
</Td>
|
||||||
textAlign,
|
)}
|
||||||
suffix,
|
{!editing && enableToggle && (
|
||||||
SuffixComponent = TL2,
|
<Td textAlign="center" width={toggleWidth}>
|
||||||
view = it => it?.toString(),
|
<Switch
|
||||||
inputProps = {}
|
checked={!!values.active}
|
||||||
} = config
|
value={!!values.active}
|
||||||
|
disabled={disabled}
|
||||||
const { values } = useFormikContext()
|
onChange={() => onToggle(values.id)}
|
||||||
const classes = useStyles({ textAlign, size })
|
/>
|
||||||
|
</Td>
|
||||||
const innerProps = {
|
)}
|
||||||
fullWidth: true,
|
</>
|
||||||
autoFocus: focus,
|
)
|
||||||
size,
|
}
|
||||||
bold,
|
|
||||||
textAlign,
|
const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => {
|
||||||
...inputProps
|
const {
|
||||||
}
|
name,
|
||||||
|
bypassField,
|
||||||
// Autocomplete
|
input,
|
||||||
if (innerProps.options && !innerProps.getLabel) {
|
editable = true,
|
||||||
innerProps.getLabel = view
|
size,
|
||||||
}
|
bold,
|
||||||
|
width,
|
||||||
const isEditing = editing && editable
|
textAlign,
|
||||||
const isField = !bypassField
|
editingAlign = textAlign,
|
||||||
|
suffix,
|
||||||
return (
|
SuffixComponent = TL2,
|
||||||
<Td
|
textStyle = it => {},
|
||||||
className={{
|
view = it => it?.toString(),
|
||||||
[classes.extraPaddingRight]: extraPaddingRight,
|
inputProps = {}
|
||||||
[classes.extraPadding]: extraPadding,
|
} = config
|
||||||
[classes.withSuffix]: suffix
|
|
||||||
}}
|
const { values } = useFormikContext()
|
||||||
width={width}
|
|
||||||
size={size}
|
const isEditing = editing && editable
|
||||||
bold={bold}
|
const isField = !bypassField
|
||||||
textAlign={textAlign}>
|
|
||||||
{isEditing && isField && (
|
const classes = useStyles({
|
||||||
<Field name={name} component={input} {...innerProps} />
|
textAlign: isEditing ? editingAlign : textAlign,
|
||||||
)}
|
size
|
||||||
{isEditing && !isField && <config.input name={name} />}
|
})
|
||||||
{!isEditing && values && <>{view(values[name], values)}</>}
|
|
||||||
{suffix && (
|
const innerProps = {
|
||||||
<SuffixComponent className={classes.suffix}>{suffix}</SuffixComponent>
|
fullWidth: true,
|
||||||
)}
|
autoFocus: focus,
|
||||||
</Td>
|
size,
|
||||||
)
|
bold,
|
||||||
}
|
textAlign: isEditing ? editingAlign : textAlign,
|
||||||
|
...inputProps
|
||||||
const groupStriped = elements => {
|
}
|
||||||
const [toStripe, noStripe] = R.partition(R.has('stripe'))(elements)
|
|
||||||
|
return (
|
||||||
if (!toStripe.length) {
|
<Td
|
||||||
return elements
|
className={{
|
||||||
}
|
[classes.extraPaddingRight]: extraPaddingRight,
|
||||||
|
[classes.extraPadding]: extraPadding,
|
||||||
const index = R.indexOf(toStripe[0], elements)
|
[classes.withSuffix]: suffix
|
||||||
const width = R.compose(R.sum, R.map(R.path(['width'])))(toStripe)
|
}}
|
||||||
|
width={width}
|
||||||
return R.insert(
|
size={size}
|
||||||
index,
|
bold={bold}
|
||||||
{ width, editable: false, view: () => <StripesSvg /> },
|
textAlign={textAlign}>
|
||||||
noStripe
|
{isEditing && isField && (
|
||||||
)
|
<Field name={name} component={input} {...innerProps} />
|
||||||
}
|
)}
|
||||||
|
{isEditing && !isField && <config.input name={name} />}
|
||||||
const ERow = ({ editing, disabled, lastOfGroup }) => {
|
{!isEditing && values && (
|
||||||
const { touched, errors, values } = useFormikContext()
|
<div style={textStyle(values, isEditing)}>
|
||||||
const {
|
{view(values[name], values)}
|
||||||
elements,
|
</div>
|
||||||
enableEdit,
|
)}
|
||||||
enableDelete,
|
{suffix && (
|
||||||
error,
|
<SuffixComponent
|
||||||
enableToggle,
|
className={classes.suffix}
|
||||||
rowSize,
|
style={isEditing ? {} : textStyle(values, isEditing)}>
|
||||||
stripeWhen
|
{suffix}
|
||||||
} = useContext(TableCtx)
|
</SuffixComponent>
|
||||||
|
)}
|
||||||
const classes = useStyles()
|
</Td>
|
||||||
|
)
|
||||||
const shouldStripe = stripeWhen && stripeWhen(values) && !editing
|
}
|
||||||
|
|
||||||
const innerElements = shouldStripe ? groupStriped(elements) : elements
|
const groupStriped = elements => {
|
||||||
const [toSHeader] = R.partition(R.has('doubleHeader'))(elements)
|
const [toStripe, noStripe] = R.partition(R.propEq('stripe', true))(elements)
|
||||||
|
|
||||||
const extraPaddingIndex = toSHeader?.length
|
if (!toStripe.length) {
|
||||||
? R.indexOf(toSHeader[0], elements)
|
return elements
|
||||||
: -1
|
}
|
||||||
|
|
||||||
const extraPaddingRightIndex = toSHeader?.length
|
const index = R.indexOf(toStripe[0], elements)
|
||||||
? R.indexOf(toSHeader[toSHeader.length - 1], elements)
|
const width = R.compose(R.sum, R.map(R.path(['width'])))(toStripe)
|
||||||
: -1
|
|
||||||
|
return R.insert(
|
||||||
const elementToFocusIndex = innerElements.findIndex(
|
index,
|
||||||
it => it.editable === undefined || it.editable
|
{ width, editable: false, view: () => <StripesSvg /> },
|
||||||
)
|
noStripe
|
||||||
|
)
|
||||||
const classNames = {
|
}
|
||||||
[classes.lastOfGroup]: lastOfGroup
|
|
||||||
}
|
const ERow = ({ editing, disabled, lastOfGroup }) => {
|
||||||
|
const { touched, errors, values } = useFormikContext()
|
||||||
const touchedErrors = R.pick(R.keys(touched), errors)
|
const {
|
||||||
const hasTouchedErrors = touchedErrors && R.keys(touchedErrors).length > 0
|
elements,
|
||||||
const hasErrors = hasTouchedErrors || !!error
|
enableEdit,
|
||||||
|
enableDelete,
|
||||||
const errorMessage =
|
error,
|
||||||
error || (touchedErrors && R.values(touchedErrors).join(', '))
|
enableToggle,
|
||||||
|
rowSize,
|
||||||
return (
|
stripeWhen
|
||||||
<Tr
|
} = useContext(TableCtx)
|
||||||
className={classnames(classNames)}
|
|
||||||
size={rowSize}
|
const classes = useStyles()
|
||||||
error={editing && hasErrors}
|
|
||||||
errorMessage={errorMessage}>
|
const shouldStripe = stripeWhen && stripeWhen(values)
|
||||||
{innerElements.map((it, idx) => {
|
|
||||||
return (
|
const innerElements = shouldStripe ? groupStriped(elements) : elements
|
||||||
<ECol
|
const [toSHeader] = R.partition(R.has('doubleHeader'))(elements)
|
||||||
key={idx}
|
|
||||||
config={it}
|
const extraPaddingIndex = toSHeader?.length
|
||||||
editing={editing}
|
? R.indexOf(toSHeader[0], elements)
|
||||||
focus={idx === elementToFocusIndex && editing}
|
: -1
|
||||||
extraPaddingRight={extraPaddingRightIndex === idx}
|
|
||||||
extraPadding={extraPaddingIndex === idx}
|
const extraPaddingRightIndex = toSHeader?.length
|
||||||
/>
|
? R.indexOf(toSHeader[toSHeader.length - 1], elements)
|
||||||
)
|
: -1
|
||||||
})}
|
|
||||||
{(enableEdit || enableDelete || enableToggle) && (
|
const elementToFocusIndex = innerElements.findIndex(
|
||||||
<ActionCol disabled={disabled} editing={editing} />
|
it => it.editable === undefined || it.editable
|
||||||
)}
|
)
|
||||||
</Tr>
|
|
||||||
)
|
const classNames = {
|
||||||
}
|
[classes.lastOfGroup]: lastOfGroup
|
||||||
|
}
|
||||||
export default ERow
|
|
||||||
|
const touchedErrors = R.pick(R.keys(touched), errors)
|
||||||
|
const hasTouchedErrors = touchedErrors && R.keys(touchedErrors).length > 0
|
||||||
|
const hasErrors = hasTouchedErrors || !!error
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
error || (touchedErrors && R.values(touchedErrors).join(', '))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr
|
||||||
|
className={classnames(classNames)}
|
||||||
|
size={rowSize}
|
||||||
|
error={editing && hasErrors}
|
||||||
|
errorMessage={errorMessage}>
|
||||||
|
{innerElements.map((it, idx) => {
|
||||||
|
return (
|
||||||
|
<ECol
|
||||||
|
key={idx}
|
||||||
|
config={it}
|
||||||
|
editing={editing}
|
||||||
|
focus={idx === elementToFocusIndex && editing}
|
||||||
|
extraPaddingRight={extraPaddingRightIndex === idx}
|
||||||
|
extraPadding={extraPaddingIndex === idx}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{(enableEdit || enableDelete || enableToggle) && (
|
||||||
|
<ActionCol disabled={disabled} editing={editing} />
|
||||||
|
)}
|
||||||
|
</Tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ERow
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,8 @@ const ETable = ({
|
||||||
groupBy,
|
groupBy,
|
||||||
sortBy,
|
sortBy,
|
||||||
createText = 'Add override',
|
createText = 'Add override',
|
||||||
forceAdd = false
|
forceAdd = false,
|
||||||
|
tbodyWrapperClass
|
||||||
}) => {
|
}) => {
|
||||||
const [editingId, setEditingId] = useState(null)
|
const [editingId, setEditingId] = useState(null)
|
||||||
const [adding, setAdding] = useState(false)
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
@ -180,53 +181,60 @@ const ETable = ({
|
||||||
)}
|
)}
|
||||||
<Table>
|
<Table>
|
||||||
<Header />
|
<Header />
|
||||||
<TBody>
|
<div className={tbodyWrapperClass}>
|
||||||
{adding && (
|
<TBody>
|
||||||
<Formik
|
{adding && (
|
||||||
initialValues={{ id: v4(), ...initialValues }}
|
|
||||||
onReset={onReset}
|
|
||||||
validationSchema={validationSchema}
|
|
||||||
onSubmit={innerSave}>
|
|
||||||
<Form>
|
|
||||||
<PromptWhenDirty />
|
|
||||||
<ERow editing={true} disabled={forceDisable} />
|
|
||||||
</Form>
|
|
||||||
</Formik>
|
|
||||||
)}
|
|
||||||
{innerData.map((it, idx) => {
|
|
||||||
const nextElement = innerData[idx + 1]
|
|
||||||
|
|
||||||
const canGroup = !!groupBy && nextElement
|
|
||||||
const isFunction = R.type(groupBy) === 'Function'
|
|
||||||
const groupFunction = isFunction ? groupBy : R.prop(groupBy)
|
|
||||||
|
|
||||||
const isLastOfGroup =
|
|
||||||
canGroup && groupFunction(it) !== groupFunction(nextElement)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Formik
|
<Formik
|
||||||
key={it.id ?? idx}
|
validateOnBlur={false}
|
||||||
enableReinitialize
|
validateOnChange={false}
|
||||||
initialValues={it}
|
initialValues={{ id: v4(), ...initialValues }}
|
||||||
onReset={onReset}
|
onReset={onReset}
|
||||||
validationSchema={validationSchema}
|
validationSchema={validationSchema}
|
||||||
onSubmit={innerSave}>
|
onSubmit={innerSave}>
|
||||||
<Form>
|
<Form>
|
||||||
<PromptWhenDirty />
|
<PromptWhenDirty />
|
||||||
<ERow
|
<ERow editing={true} disabled={forceDisable} />
|
||||||
lastOfGroup={isLastOfGroup}
|
|
||||||
editing={editingId === it.id}
|
|
||||||
disabled={
|
|
||||||
forceDisable ||
|
|
||||||
(editingId && editingId !== it.id) ||
|
|
||||||
adding
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Form>
|
</Form>
|
||||||
</Formik>
|
</Formik>
|
||||||
)
|
)}
|
||||||
})}
|
{innerData.map((it, idx) => {
|
||||||
</TBody>
|
const nextElement = innerData[idx + 1]
|
||||||
|
|
||||||
|
const canGroup = !!groupBy && nextElement
|
||||||
|
const isFunction = R.type(groupBy) === 'Function'
|
||||||
|
const groupFunction = isFunction ? groupBy : R.prop(groupBy)
|
||||||
|
|
||||||
|
const isLastOfGroup =
|
||||||
|
canGroup &&
|
||||||
|
groupFunction(it) !== groupFunction(nextElement)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
validateOnBlur={false}
|
||||||
|
validateOnChange={false}
|
||||||
|
key={it.id ?? idx}
|
||||||
|
enableReinitialize
|
||||||
|
initialValues={it}
|
||||||
|
onReset={onReset}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
onSubmit={innerSave}>
|
||||||
|
<Form>
|
||||||
|
<PromptWhenDirty />
|
||||||
|
<ERow
|
||||||
|
lastOfGroup={isLastOfGroup}
|
||||||
|
editing={editingId === it.id}
|
||||||
|
disabled={
|
||||||
|
forceDisable ||
|
||||||
|
(editingId && editingId !== it.id) ||
|
||||||
|
adding
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TBody>
|
||||||
|
</div>
|
||||||
</Table>
|
</Table>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ const Td = ({
|
||||||
[classes.size]: !header,
|
[classes.size]: !header,
|
||||||
[classes.bold]: !header && bold
|
[classes.bold]: !header && bold
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={classnames(className, classNames)}>{children}</div>
|
return <div className={classnames(className, classNames)}>{children}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,11 @@ export default {
|
||||||
backgroundColor: tableErrorColor
|
backgroundColor: tableErrorColor
|
||||||
},
|
},
|
||||||
mainContent: ({ size }) => {
|
mainContent: ({ size }) => {
|
||||||
const minHeight = size === 'lg' ? 68 : 48
|
const sizes = {
|
||||||
|
sm: 34,
|
||||||
|
lg: 68
|
||||||
|
}
|
||||||
|
const minHeight = sizes[size] || 48
|
||||||
return {
|
return {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const Autocomplete = ({
|
||||||
valueProp,
|
valueProp,
|
||||||
multiple,
|
multiple,
|
||||||
onChange,
|
onChange,
|
||||||
getLabel,
|
labelProp,
|
||||||
value: outsideValue,
|
value: outsideValue,
|
||||||
error,
|
error,
|
||||||
fullWidth,
|
fullWidth,
|
||||||
|
|
@ -49,8 +49,10 @@ const Autocomplete = ({
|
||||||
return multiple ? value : [value]
|
return multiple ? value : [value]
|
||||||
}
|
}
|
||||||
|
|
||||||
const filter = (array, input) =>
|
const filter = (array, input) => {
|
||||||
sort(array, input, { keys: ['code', 'display'] })
|
if (!input) return array
|
||||||
|
return sort(array, input, { keys: [valueProp, labelProp] })
|
||||||
|
}
|
||||||
|
|
||||||
const filterOptions = (array, { inputValue }) =>
|
const filterOptions = (array, { inputValue }) =>
|
||||||
R.union(
|
R.union(
|
||||||
|
|
@ -68,7 +70,7 @@ const Autocomplete = ({
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={innerOnChange}
|
onChange={innerOnChange}
|
||||||
getOptionLabel={getLabel}
|
getOptionLabel={R.path([labelProp])}
|
||||||
forcePopupIcon={false}
|
forcePopupIcon={false}
|
||||||
filterOptions={filterOptions}
|
filterOptions={filterOptions}
|
||||||
openOnFocus
|
openOnFocus
|
||||||
|
|
|
||||||
|
|
@ -2,35 +2,34 @@ import React, { memo, useState } from 'react'
|
||||||
|
|
||||||
import { TextInput } from '../base'
|
import { TextInput } from '../base'
|
||||||
|
|
||||||
const SecretInput = memo(({ value, onFocus, onBlur, ...props }) => {
|
const SecretInput = memo(
|
||||||
const [focused, setFocused] = useState(false)
|
({ value, onFocus, isPasswordFilled, onBlur, ...props }) => {
|
||||||
|
const [focused, setFocused] = useState(false)
|
||||||
|
const placeholder = '⚬ ⚬ ⚬ This field is set ⚬ ⚬ ⚬'
|
||||||
|
const innerOnFocus = event => {
|
||||||
|
setFocused(true)
|
||||||
|
onFocus && onFocus(event)
|
||||||
|
}
|
||||||
|
|
||||||
const placeholder = '⚬ ⚬ ⚬ This field is set ⚬ ⚬ ⚬'
|
const innerOnBlur = event => {
|
||||||
const previouslyFilled = !!value
|
setFocused(false)
|
||||||
const tempValue = previouslyFilled ? '' : value
|
onBlur && onBlur(event)
|
||||||
|
}
|
||||||
|
|
||||||
const innerOnFocus = event => {
|
return (
|
||||||
setFocused(true)
|
<TextInput
|
||||||
onFocus && onFocus(event)
|
{...props}
|
||||||
|
type="password"
|
||||||
|
onFocus={innerOnFocus}
|
||||||
|
onBlur={innerOnBlur}
|
||||||
|
isPasswordFilled={isPasswordFilled}
|
||||||
|
value={value}
|
||||||
|
InputProps={{ value: value }}
|
||||||
|
InputLabelProps={{ shrink: isPasswordFilled || value || focused }}
|
||||||
|
placeholder={isPasswordFilled ? placeholder : ''}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
const innerOnBlur = event => {
|
|
||||||
setFocused(false)
|
|
||||||
onBlur && onBlur(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TextInput
|
|
||||||
{...props}
|
|
||||||
type="password"
|
|
||||||
onFocus={innerOnFocus}
|
|
||||||
onBlur={innerOnBlur}
|
|
||||||
value={value}
|
|
||||||
InputProps={{ value: !focused ? tempValue : value }}
|
|
||||||
InputLabelProps={{ shrink: previouslyFilled || focused }}
|
|
||||||
placeholder={previouslyFilled ? placeholder : ''}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export default SecretInput
|
export default SecretInput
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,12 @@ import { useSelect } from 'downshift'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { ReactComponent as Arrowdown } from 'src/styling/icons/action/arrow/regular.svg'
|
import { ReactComponent as Arrowdown } from 'src/styling/icons/action/arrow/regular.svg'
|
||||||
import { startCase } from 'src/utils/string'
|
|
||||||
|
|
||||||
import styles from './Select.styles'
|
import styles from './Select.styles'
|
||||||
|
|
||||||
const useStyles = makeStyles(styles)
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
function Select({ label, items, ...props }) {
|
function Select({ className, label, items, ...props }) {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -35,17 +34,17 @@ function Select({ label, items, ...props }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classnames(selectClassNames)}>
|
<div className={classnames(selectClassNames, className)}>
|
||||||
<label {...getLabelProps()}>{startCase(label)}</label>
|
<label {...getLabelProps()}>{label}</label>
|
||||||
<button {...getToggleButtonProps()}>
|
<button {...getToggleButtonProps()}>
|
||||||
<span className={classes.selectedItem}>{startCase(selectedItem)}</span>
|
<span className={classes.selectedItem}>{selectedItem.display}</span>
|
||||||
<Arrowdown />
|
<Arrowdown />
|
||||||
</button>
|
</button>
|
||||||
<ul {...getMenuProps()}>
|
<ul {...getMenuProps()}>
|
||||||
{isOpen &&
|
{isOpen &&
|
||||||
items.map((item, index) => (
|
items.map(({ code, display }, index) => (
|
||||||
<li key={`${item}${index}`} {...getItemProps({ item, index })}>
|
<li key={`${code}${index}`} {...getItemProps({ code, index })}>
|
||||||
<span>{startCase(item)}</span>
|
<span>{display}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const useStyles = makeStyles(styles)
|
||||||
const TextInput = memo(
|
const TextInput = memo(
|
||||||
({
|
({
|
||||||
name,
|
name,
|
||||||
|
isPasswordFilled,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
value,
|
value,
|
||||||
|
|
@ -26,8 +27,8 @@ const TextInput = memo(
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const classes = useStyles({ textAlign, width, size })
|
const classes = useStyles({ textAlign, width, size })
|
||||||
const filled = !error && !R.isNil(value) && !R.isEmpty(value)
|
const isTextFilled = !error && !R.isNil(value) && !R.isEmpty(value)
|
||||||
|
const filled = isPasswordFilled || isTextFilled
|
||||||
const inputClasses = {
|
const inputClasses = {
|
||||||
[classes.bold]: bold
|
[classes.bold]: bold
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,27 @@ import { cashboxStyles, gridStyles } from './Cashbox.styles'
|
||||||
const cashboxClasses = makeStyles(cashboxStyles)
|
const cashboxClasses = makeStyles(cashboxStyles)
|
||||||
const gridClasses = makeStyles(gridStyles)
|
const gridClasses = makeStyles(gridStyles)
|
||||||
|
|
||||||
const Cashbox = ({ percent = 0, cashOut = false, className }) => {
|
const Cashbox = ({
|
||||||
|
percent = 0,
|
||||||
|
cashOut = false,
|
||||||
|
className,
|
||||||
|
emptyPartClassName,
|
||||||
|
labelClassName
|
||||||
|
}) => {
|
||||||
const classes = cashboxClasses({ percent, cashOut })
|
const classes = cashboxClasses({ percent, cashOut })
|
||||||
const threshold = 51
|
const threshold = 51
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classnames(className, classes.cashbox)}>
|
<div className={classnames(className, classes.cashbox)}>
|
||||||
<div className={classes.emptyPart}>
|
<div className={classnames(emptyPartClassName, classes.emptyPart)}>
|
||||||
{percent <= threshold && <Label2>{percent.toFixed(0)}%</Label2>}
|
{percent <= threshold && (
|
||||||
|
<Label2 className={labelClassName}>{percent.toFixed(0)}%</Label2>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.fullPart}>
|
<div className={classes.fullPart}>
|
||||||
{percent > threshold && <Label2>{percent.toFixed(0)}%</Label2>}
|
{percent > threshold && (
|
||||||
|
<Label2 className={labelClassName}>{percent.toFixed(0)}%</Label2>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -31,19 +41,19 @@ const Cashbox = ({ percent = 0, cashOut = false, className }) => {
|
||||||
|
|
||||||
// https://support.lamassu.is/hc/en-us/articles/360025595552-Installing-the-Sintra-Forte
|
// https://support.lamassu.is/hc/en-us/articles/360025595552-Installing-the-Sintra-Forte
|
||||||
// Sintra and Sintra Forte can have up to 500 notes per cashOut box and up to 1000 per cashIn box
|
// Sintra and Sintra Forte can have up to 500 notes per cashOut box and up to 1000 per cashIn box
|
||||||
const CashIn = ({ capacity = 1000, notes = 0, total = 0 }) => {
|
const CashIn = ({ currency, notes, total }) => {
|
||||||
const percent = (100 * notes) / capacity
|
|
||||||
const classes = gridClasses()
|
const classes = gridClasses()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={classes.row}>
|
<div className={classes.row}>
|
||||||
<div>
|
<div>
|
||||||
<Cashbox percent={percent} />
|
<div className={classes.innerRow}>
|
||||||
</div>
|
|
||||||
<div className={classes.col2}>
|
|
||||||
<div>
|
|
||||||
<Info2 className={classes.noMarginText}>{notes} notes</Info2>
|
<Info2 className={classes.noMarginText}>{notes} notes</Info2>
|
||||||
<Label1 className={classes.noMarginText}>{total}</Label1>
|
</div>
|
||||||
|
<div className={classes.innerRow}>
|
||||||
|
<Label1 className={classes.noMarginText}>
|
||||||
|
{total} {currency.code}
|
||||||
|
</Label1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,7 +104,8 @@ const CashOut = ({
|
||||||
denomination = 0,
|
denomination = 0,
|
||||||
currency,
|
currency,
|
||||||
notes,
|
notes,
|
||||||
className
|
className,
|
||||||
|
editingMode = false
|
||||||
}) => {
|
}) => {
|
||||||
const percent = (100 * notes) / capacity
|
const percent = (100 * notes) / capacity
|
||||||
const classes = gridClasses()
|
const classes = gridClasses()
|
||||||
|
|
@ -104,20 +115,22 @@ const CashOut = ({
|
||||||
<div className={classes.col}>
|
<div className={classes.col}>
|
||||||
<Cashbox className={className} percent={percent} cashOut />
|
<Cashbox className={className} percent={percent} cashOut />
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.col2}>
|
{!editingMode && (
|
||||||
<div className={classes.innerRow}>
|
<div className={classes.col2}>
|
||||||
<Info2 className={classes.noMarginText}>{notes}</Info2>
|
<div className={classes.innerRow}>
|
||||||
<Chip
|
<Info2 className={classes.noMarginText}>{notes}</Info2>
|
||||||
className={classes.chip}
|
<Chip
|
||||||
label={`${denomination} ${currency.code}`}
|
className={classes.chip}
|
||||||
/>
|
label={`${denomination} ${currency.code}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={classes.innerRow}>
|
||||||
|
<Label1 className={classes.noMarginText}>
|
||||||
|
{notes * denomination} {currency.code}
|
||||||
|
</Label1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.innerRow}>
|
)}
|
||||||
<Label1 className={classes.noMarginText}>
|
|
||||||
{notes * denomination} {currency.code}
|
|
||||||
</Label1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,7 @@ const cashboxStyles = {
|
||||||
|
|
||||||
const gridStyles = {
|
const gridStyles = {
|
||||||
row: {
|
row: {
|
||||||
display: 'flex',
|
display: 'flex'
|
||||||
justifyContent: 'space-between'
|
|
||||||
},
|
},
|
||||||
innerRow: {
|
innerRow: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { makeStyles } from '@material-ui/core'
|
||||||
|
import React, { memo, useState } from 'react'
|
||||||
|
|
||||||
|
import { CashOut } from 'src/components/inputs/cashbox/Cashbox'
|
||||||
|
|
||||||
|
import { NumberInput } from '../base'
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
flex: {
|
||||||
|
display: 'flex'
|
||||||
|
},
|
||||||
|
cashCassette: {
|
||||||
|
width: 80,
|
||||||
|
height: 36,
|
||||||
|
marginRight: 16
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const CashCassetteInput = memo(({ decimalPlaces, ...props }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const { name, onChange, onBlur, value } = props.field
|
||||||
|
const { touched, errors } = props.form
|
||||||
|
const [notes, setNotes] = useState(value)
|
||||||
|
const error = !!(touched[name] && errors[name])
|
||||||
|
return (
|
||||||
|
<div className={classes.flex}>
|
||||||
|
<CashOut
|
||||||
|
className={classes.cashCassette}
|
||||||
|
notes={notes}
|
||||||
|
editingMode={true}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
name={name}
|
||||||
|
onChange={e => {
|
||||||
|
setNotes(e.target.value)
|
||||||
|
return onChange(e)
|
||||||
|
}}
|
||||||
|
onBlur={onBlur}
|
||||||
|
value={value}
|
||||||
|
error={error}
|
||||||
|
decimalPlaces={decimalPlaces}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default CashCassetteInput
|
||||||
|
|
@ -1,23 +1,24 @@
|
||||||
import React, { memo } from 'react'
|
import React, { memo } from 'react'
|
||||||
|
|
||||||
import { SecretInput } from '../base'
|
import { SecretInput } from '../base'
|
||||||
|
|
||||||
const SecretInputFormik = memo(({ ...props }) => {
|
const SecretInputFormik = memo(({ isPasswordFilled, ...props }) => {
|
||||||
const { name, onChange, onBlur, value } = props.field
|
const { name, onChange, onBlur, value } = props.field
|
||||||
const { touched, errors } = props.form
|
const { touched, errors } = props.form
|
||||||
|
|
||||||
const error = !!(touched[name] && errors[name])
|
const error = !isPasswordFilled && !!(touched[name] && errors[name])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecretInput
|
<SecretInput
|
||||||
name={name}
|
name={name}
|
||||||
onChange={onChange}
|
isPasswordFilled={isPasswordFilled}
|
||||||
onBlur={onBlur}
|
onChange={onChange}
|
||||||
value={value}
|
onBlur={onBlur}
|
||||||
error={error}
|
value={value}
|
||||||
{...props}
|
error={error}
|
||||||
/>
|
{...props}
|
||||||
)
|
/>
|
||||||
})
|
)
|
||||||
|
})
|
||||||
export default SecretInputFormik
|
|
||||||
|
export default SecretInputFormik
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Autocomplete from './Autocomplete'
|
import Autocomplete from './Autocomplete'
|
||||||
|
import CashCassetteInput from './CashCassetteInput'
|
||||||
import Checkbox from './Checkbox'
|
import Checkbox from './Checkbox'
|
||||||
import NumberInput from './NumberInput'
|
import NumberInput from './NumberInput'
|
||||||
import RadioGroup from './RadioGroup'
|
import RadioGroup from './RadioGroup'
|
||||||
|
|
@ -11,5 +12,6 @@ export {
|
||||||
TextInput,
|
TextInput,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
SecretInput,
|
SecretInput,
|
||||||
RadioGroup
|
RadioGroup,
|
||||||
|
CashCassetteInput
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,32 @@
|
||||||
|
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 React, { memo, useState } from 'react'
|
import gql from 'graphql-tag'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { memo, useState, useEffect, useRef } 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)
|
||||||
|
|
||||||
|
|
@ -44,23 +57,63 @@ const Subheader = ({ item, classes }) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notNil = R.compose(R.not, R.isNil)
|
||||||
|
|
||||||
const Header = memo(({ tree }) => {
|
const Header = memo(({ tree }) => {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [anchorEl, setAnchorEl] = useState(null)
|
||||||
|
const [notifButtonCoords, setNotifButtonCoords] = useState({ x: 0, y: 0 })
|
||||||
const [active, setActive] = useState()
|
const [active, setActive] = useState()
|
||||||
|
const [hasUnread, setHasUnread] = useState(false)
|
||||||
|
|
||||||
|
const { data, refetch } = useQuery(HAS_UNREAD, { pollInterval: 60000 })
|
||||||
|
const notifCenterButtonRef = useRef()
|
||||||
|
const popperRef = useRef()
|
||||||
const history = useHistory()
|
const history = useHistory()
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.hasUnreadNotifications) return setHasUnread(true)
|
||||||
|
// if not true, make sure it's false and not undefined
|
||||||
|
if (notNil(data?.hasUnreadNotifications)) return setHasUnread(false)
|
||||||
|
}, [data])
|
||||||
|
|
||||||
const onPaired = machine => {
|
const onPaired = machine => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
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 => {
|
||||||
|
const coords = notifCenterButtonRef.current.getBoundingClientRect()
|
||||||
|
setNotifButtonCoords({ x: coords.x, y: coords.y })
|
||||||
|
|
||||||
|
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 className={classes.logo}>
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setActive(false)
|
||||||
|
history.push('/dashboard')
|
||||||
|
}}
|
||||||
|
className={classnames(classes.logo, classes.logoLink)}>
|
||||||
<Logo />
|
<Logo />
|
||||||
<H4 className={classes.white}>Lamassu Admin</H4>
|
<H4 className={classes.white}>Lamassu Admin</H4>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -85,6 +138,8 @@ const Header = memo(({ tree }) => {
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div className={classes.actionButtonsContainer}>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
color="secondary"
|
color="secondary"
|
||||||
Icon={AddIcon}
|
Icon={AddIcon}
|
||||||
|
|
@ -92,7 +147,38 @@ const Header = memo(({ tree }) => {
|
||||||
onClick={() => setOpen(true)}>
|
onClick={() => setOpen(true)}>
|
||||||
Add machine
|
Add machine
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</nav>
|
<ClickAwayListener onClickAway={onClickAway}>
|
||||||
|
<div ref={notifCenterButtonRef}>
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={classes.notificationIcon}>
|
||||||
|
<NotificationIcon />
|
||||||
|
{hasUnread && <div className={classes.hasUnread} />}
|
||||||
|
</button>
|
||||||
|
<Popper
|
||||||
|
ref={popperRef}
|
||||||
|
id={id}
|
||||||
|
open={popperOpen}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
className={classes.popper}
|
||||||
|
disablePortal={false}
|
||||||
|
modifiers={{
|
||||||
|
preventOverflow: {
|
||||||
|
enabled: true,
|
||||||
|
boundariesElement: 'viewport'
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<NotificationCenter
|
||||||
|
popperRef={popperRef}
|
||||||
|
buttonCoords={notifButtonCoords}
|
||||||
|
close={onClickAway}
|
||||||
|
hasUnreadProp={hasUnread}
|
||||||
|
refetchHasUnreadHeader={refetch}
|
||||||
|
/>
|
||||||
|
</Popper>
|
||||||
|
</div>
|
||||||
|
</ClickAwayListener>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{active && active.children && (
|
{active && active.children && (
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
spacer,
|
spacer,
|
||||||
white,
|
white,
|
||||||
primaryColor,
|
primaryColor,
|
||||||
|
secondaryColor,
|
||||||
placeholderColor,
|
placeholderColor,
|
||||||
subheaderColor,
|
subheaderColor,
|
||||||
fontColor
|
fontColor
|
||||||
|
|
@ -20,7 +21,10 @@ if (version === 8) {
|
||||||
subheaderHeight = spacer * 7
|
subheaderHeight = spacer * 7
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
const styles = {
|
||||||
|
headerContainer: {
|
||||||
|
position: 'relative'
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
backgroundColor: primaryColor,
|
backgroundColor: primaryColor,
|
||||||
color: white,
|
color: white,
|
||||||
|
|
@ -80,27 +84,6 @@ export default {
|
||||||
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',
|
||||||
|
|
@ -164,5 +147,39 @@ export default {
|
||||||
'& > svg': {
|
'& > svg': {
|
||||||
marginRight: 16
|
marginRight: 16
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
logoLink: {
|
||||||
|
cursor: 'pointer'
|
||||||
|
},
|
||||||
|
actionButtonsContainer: {
|
||||||
|
zIndex: 1,
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
minWidth: 200,
|
||||||
|
transform: 'translateZ(0)'
|
||||||
|
},
|
||||||
|
notificationIcon: {
|
||||||
|
marginTop: spacer / 2,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: 'transparent',
|
||||||
|
boxShadow: '0px 0px 0px transparent',
|
||||||
|
border: '0px solid transparent',
|
||||||
|
textShadow: '0px 0px 0px transparent',
|
||||||
|
outline: 'none'
|
||||||
|
},
|
||||||
|
hasUnread: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
left: 182,
|
||||||
|
width: '9px',
|
||||||
|
height: '9px',
|
||||||
|
backgroundColor: secondaryColor,
|
||||||
|
borderRadius: '50%'
|
||||||
|
},
|
||||||
|
popper: {
|
||||||
|
zIndex: 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default styles
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,21 @@ import React from 'react'
|
||||||
|
|
||||||
import ErrorMessage from 'src/components/ErrorMessage'
|
import ErrorMessage from 'src/components/ErrorMessage'
|
||||||
import Title from 'src/components/Title'
|
import Title from 'src/components/Title'
|
||||||
import { Label1 } from 'src/components/typography'
|
import { SubpageButton } from 'src/components/buttons'
|
||||||
|
import { Info1, Label1 } from 'src/components/typography'
|
||||||
|
|
||||||
import styles from './TitleSection.styles'
|
import styles from './TitleSection.styles'
|
||||||
|
|
||||||
const useStyles = makeStyles(styles)
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
const TitleSection = ({ className, title, error, labels, children }) => {
|
const TitleSection = ({
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
error,
|
||||||
|
labels,
|
||||||
|
button,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
return (
|
return (
|
||||||
<div className={classnames(classes.titleWrapper, className)}>
|
<div className={classnames(classes.titleWrapper, className)}>
|
||||||
|
|
@ -19,6 +27,15 @@ const TitleSection = ({ className, title, error, labels, children }) => {
|
||||||
{error && (
|
{error && (
|
||||||
<ErrorMessage className={classes.error}>Failed to save</ErrorMessage>
|
<ErrorMessage className={classes.error}>Failed to save</ErrorMessage>
|
||||||
)}
|
)}
|
||||||
|
{button && (
|
||||||
|
<SubpageButton
|
||||||
|
className={classes.subpageButton}
|
||||||
|
Icon={button.icon}
|
||||||
|
InverseIcon={button.inverseIcon}
|
||||||
|
toggle={button.toggle}>
|
||||||
|
<Info1 className={classes.buttonText}>{button.text}</Info1>
|
||||||
|
</SubpageButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Box display="flex" flexDirection="row">
|
<Box display="flex" flexDirection="row">
|
||||||
{(labels ?? []).map(({ icon, label }, idx) => (
|
{(labels ?? []).map(({ icon, label }, idx) => (
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { backgroundColor } from 'src/styling/variables'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
titleWrapper: {
|
titleWrapper: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -6,11 +8,19 @@ export default {
|
||||||
flexDirection: 'row'
|
flexDirection: 'row'
|
||||||
},
|
},
|
||||||
titleAndButtonsContainer: {
|
titleAndButtonsContainer: {
|
||||||
display: 'flex'
|
display: 'flex',
|
||||||
|
alignItems: 'center'
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
marginLeft: 12
|
marginLeft: 12
|
||||||
},
|
},
|
||||||
|
subpageButton: {
|
||||||
|
marginLeft: 12
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: backgroundColor,
|
||||||
|
fontSize: 15
|
||||||
|
},
|
||||||
icon: {
|
icon: {
|
||||||
marginRight: 6
|
marginRight: 6
|
||||||
},
|
},
|
||||||
|
|
|
||||||
30
new-lamassu-admin/src/components/table/EmptyTable.js
Normal file
30
new-lamassu-admin/src/components/table/EmptyTable.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { makeStyles } from '@material-ui/core'
|
||||||
|
import React, { memo } from 'react'
|
||||||
|
|
||||||
|
import { H4 } from 'src/components/typography'
|
||||||
|
import { ReactComponent as EmptyTableIcon } from 'src/styling/icons/table/empty-table.svg'
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
emptyTable: {
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 52
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const EmptyTable = memo(({ message }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.emptyTable}>
|
||||||
|
<EmptyTableIcon />
|
||||||
|
<H4>{message}</H4>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default EmptyTable
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import EditCell from './EditCell'
|
import EditCell from './EditCell'
|
||||||
|
import EmptyTable from './EmptyTable'
|
||||||
import Table from './Table'
|
import Table from './Table'
|
||||||
import TableBody from './TableBody'
|
import TableBody from './TableBody'
|
||||||
import TableCell from './TableCell'
|
import TableCell from './TableCell'
|
||||||
|
|
@ -8,6 +9,7 @@ import TableRow from './TableRow'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
EditCell,
|
EditCell,
|
||||||
|
EmptyTable,
|
||||||
Table,
|
Table,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
Td,
|
Td,
|
||||||
Th
|
Th
|
||||||
} from 'src/components/fake-table/Table'
|
} from 'src/components/fake-table/Table'
|
||||||
|
import { EmptyTable } from 'src/components/table'
|
||||||
import { H4 } from 'src/components/typography'
|
import { H4 } from 'src/components/typography'
|
||||||
import { ReactComponent as ExpandClosedIcon } from 'src/styling/icons/action/expand/closed.svg'
|
import { ReactComponent as ExpandClosedIcon } from 'src/styling/icons/action/expand/closed.svg'
|
||||||
import { ReactComponent as ExpandOpenIcon } from 'src/styling/icons/action/expand/open.svg'
|
import { ReactComponent as ExpandOpenIcon } from 'src/styling/icons/action/expand/open.svg'
|
||||||
|
|
@ -35,7 +36,8 @@ const Row = ({
|
||||||
expandRow,
|
expandRow,
|
||||||
expWidth,
|
expWidth,
|
||||||
expandable,
|
expandable,
|
||||||
onClick
|
onClick,
|
||||||
|
size
|
||||||
}) => {
|
}) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
|
|
||||||
|
|
@ -45,14 +47,14 @@ const Row = ({
|
||||||
[classes.row]: true,
|
[classes.row]: true,
|
||||||
[classes.expanded]: expanded
|
[classes.expanded]: expanded
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.rowWrapper}>
|
<div className={classes.rowWrapper}>
|
||||||
<div className={classnames({ [classes.before]: expanded && id !== 0 })}>
|
<div className={classnames({ [classes.before]: expanded && id !== 0 })}>
|
||||||
<Tr
|
<Tr
|
||||||
|
size={size}
|
||||||
className={classnames(trClasses)}
|
className={classnames(trClasses)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
expandable && expandRow(id)
|
expandable && expandRow(id, data)
|
||||||
onClick && onClick(data)
|
onClick && onClick(data)
|
||||||
}}
|
}}
|
||||||
error={data.error}
|
error={data.error}
|
||||||
|
|
@ -65,7 +67,7 @@ const Row = ({
|
||||||
{expandable && (
|
{expandable && (
|
||||||
<Td width={expWidth} textAlign="center">
|
<Td width={expWidth} textAlign="center">
|
||||||
<button
|
<button
|
||||||
onClick={() => expandRow(id)}
|
onClick={() => expandRow(id, data)}
|
||||||
className={classes.expandButton}>
|
className={classes.expandButton}>
|
||||||
{expanded && <ExpandOpenIcon />}
|
{expanded && <ExpandOpenIcon />}
|
||||||
{!expanded && <ExpandClosedIcon />}
|
{!expanded && <ExpandClosedIcon />}
|
||||||
|
|
@ -97,6 +99,7 @@ const DataTable = ({
|
||||||
onClick,
|
onClick,
|
||||||
loading,
|
loading,
|
||||||
emptyText,
|
emptyText,
|
||||||
|
rowSize,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [expanded, setExpanded] = useState(initialExpanded)
|
const [expanded, setExpanded] = useState(initialExpanded)
|
||||||
|
|
@ -109,12 +112,18 @@ const DataTable = ({
|
||||||
|
|
||||||
const classes = useStyles({ width })
|
const classes = useStyles({ width })
|
||||||
|
|
||||||
const expandRow = id => {
|
const expandRow = (id, data) => {
|
||||||
setExpanded(id === expanded ? null : id)
|
if (data.id) {
|
||||||
|
cache.clear(data.id)
|
||||||
|
setExpanded(data.id === expanded ? null : data.id)
|
||||||
|
} else {
|
||||||
|
cache.clear(id)
|
||||||
|
setExpanded(id === expanded ? null : id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache = new CellMeasurerCache({
|
const cache = new CellMeasurerCache({
|
||||||
defaultHeight: 62,
|
defaultHeight: 58,
|
||||||
fixedWidth: true
|
fixedWidth: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -126,20 +135,27 @@ const DataTable = ({
|
||||||
key={key}
|
key={key}
|
||||||
parent={parent}
|
parent={parent}
|
||||||
rowIndex={index}>
|
rowIndex={index}>
|
||||||
<div style={style}>
|
{({ registerChild }) => (
|
||||||
<Row
|
<div ref={registerChild} style={style}>
|
||||||
width={width}
|
<Row
|
||||||
id={index}
|
width={width}
|
||||||
expWidth={expWidth}
|
size={rowSize}
|
||||||
elements={elements}
|
id={data[index].id ? data[index].id : index}
|
||||||
data={data[index]}
|
expWidth={expWidth}
|
||||||
Details={Details}
|
elements={elements}
|
||||||
expanded={index === expanded}
|
data={data[index]}
|
||||||
expandRow={expandRow}
|
Details={Details}
|
||||||
expandable={expandable}
|
expanded={
|
||||||
onClick={onClick}
|
data[index].id
|
||||||
/>
|
? data[index].id === expanded
|
||||||
</div>
|
: index === expanded
|
||||||
|
}
|
||||||
|
expandRow={expandRow}
|
||||||
|
expandable={expandable}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CellMeasurer>
|
</CellMeasurer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -161,7 +177,7 @@ const DataTable = ({
|
||||||
</THead>
|
</THead>
|
||||||
<TBody className={classes.body}>
|
<TBody className={classes.body}>
|
||||||
{loading && <H4>Loading...</H4>}
|
{loading && <H4>Loading...</H4>}
|
||||||
{!loading && R.isEmpty(data) && <H4>{emptyText}</H4>}
|
{!loading && R.isEmpty(data) && <EmptyTable message={emptyText} />}
|
||||||
<AutoSizer disableWidth>
|
<AutoSizer disableWidth>
|
||||||
{({ height }) => (
|
{({ height }) => (
|
||||||
<List
|
<List
|
||||||
|
|
@ -173,7 +189,7 @@ const DataTable = ({
|
||||||
rowCount={data.length}
|
rowCount={data.length}
|
||||||
rowHeight={cache.rowHeight}
|
rowHeight={cache.rowHeight}
|
||||||
rowRenderer={rowRenderer}
|
rowRenderer={rowRenderer}
|
||||||
overscanRowCount={50}
|
overscanRowCount={5}
|
||||||
deferredMeasurementCache={cache}
|
deferredMeasurementCache={cache}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -39,5 +39,12 @@ export default {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column'
|
flexDirection: 'column'
|
||||||
})
|
}),
|
||||||
|
emptyTable: {
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 52
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Form, Formik, FastField } from 'formik'
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
import QRCode from 'qrcode.react'
|
import QRCode from 'qrcode.react'
|
||||||
import * as R from 'ramda'
|
import * as R from 'ramda'
|
||||||
import React, { memo, useState } from 'react'
|
import React, { memo, useState, useEffect, useRef } from 'react'
|
||||||
import * as Yup from 'yup'
|
import * as Yup from 'yup'
|
||||||
|
|
||||||
import Title from 'src/components/Title'
|
import Title from 'src/components/Title'
|
||||||
|
|
@ -15,6 +15,7 @@ import { TextInput } from 'src/components/inputs/formik'
|
||||||
import Sidebar from 'src/components/layout/Sidebar'
|
import Sidebar from 'src/components/layout/Sidebar'
|
||||||
import { Info2, P } from 'src/components/typography'
|
import { Info2, P } from 'src/components/typography'
|
||||||
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
|
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
|
||||||
|
import { ReactComponent as CompleteStageIconSpring } from 'src/styling/icons/stage/spring/complete.svg'
|
||||||
import { ReactComponent as CompleteStageIconZodiac } from 'src/styling/icons/stage/zodiac/complete.svg'
|
import { ReactComponent as CompleteStageIconZodiac } from 'src/styling/icons/stage/zodiac/complete.svg'
|
||||||
import { ReactComponent as CurrentStageIconZodiac } from 'src/styling/icons/stage/zodiac/current.svg'
|
import { ReactComponent as CurrentStageIconZodiac } from 'src/styling/icons/stage/zodiac/current.svg'
|
||||||
import { ReactComponent as EmptyStageIconZodiac } from 'src/styling/icons/stage/zodiac/empty.svg'
|
import { ReactComponent as EmptyStageIconZodiac } from 'src/styling/icons/stage/zodiac/empty.svg'
|
||||||
|
|
@ -42,10 +43,26 @@ const useStyles = makeStyles(styles)
|
||||||
const getSize = R.compose(R.length, R.pathOr([], ['machines']))
|
const getSize = R.compose(R.length, R.pathOr([], ['machines']))
|
||||||
|
|
||||||
const QrCodeComponent = ({ classes, qrCode, name, count, onPaired }) => {
|
const QrCodeComponent = ({ classes, qrCode, name, count, onPaired }) => {
|
||||||
|
const timeout = useRef(null)
|
||||||
|
const CLOSE_SCREEN_TIMEOUT = 2000
|
||||||
const { data } = useQuery(GET_MACHINES, { pollInterval: 10000 })
|
const { data } = useQuery(GET_MACHINES, { pollInterval: 10000 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeout.current) {
|
||||||
|
clearTimeout(timeout.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const addedMachine = data?.machines?.find(m => m.name === name)
|
const addedMachine = data?.machines?.find(m => m.name === name)
|
||||||
if (getSize(data) > count && addedMachine) onPaired(addedMachine)
|
const hasNewMachine = getSize(data) > count && addedMachine
|
||||||
|
if (hasNewMachine) {
|
||||||
|
timeout.current = setTimeout(
|
||||||
|
() => onPaired(addedMachine),
|
||||||
|
CLOSE_SCREEN_TIMEOUT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -57,17 +74,29 @@ const QrCodeComponent = ({ classes, qrCode, name, count, onPaired }) => {
|
||||||
<QRCode size={240} fgColor={primaryColor} value={qrCode} />
|
<QRCode size={240} fgColor={primaryColor} value={qrCode} />
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.qrTextWrapper}>
|
<div className={classes.qrTextWrapper}>
|
||||||
<div className={classes.qrCodeWrapper}>
|
<div className={classes.qrTextInfoWrapper}>
|
||||||
<div className={classes.qrTextIcon}>
|
<div className={classes.qrTextIcon}>
|
||||||
<WarningIcon />
|
<WarningIcon />
|
||||||
</div>
|
</div>
|
||||||
<P className={classes.qrText}>
|
<div className={classes.textWrapper}>
|
||||||
To pair the machine you need scan the QR code with your machine.
|
<P className={classes.qrText}>
|
||||||
To do this either snap a picture of this QR code or download it
|
To pair the machine you need scan the QR code with your machine.
|
||||||
through the button above and scan it with the scanning bay on your
|
To do this either snap a picture of this QR code or download it
|
||||||
machine.
|
through the button above and scan it with the scanning bay on
|
||||||
</P>
|
your machine.
|
||||||
|
</P>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{hasNewMachine && (
|
||||||
|
<div className={classes.successMessageWrapper}>
|
||||||
|
<div className={classes.successMessageIcon}>
|
||||||
|
<CompleteStageIconSpring />
|
||||||
|
</div>
|
||||||
|
<Info2 className={classes.successMessage}>
|
||||||
|
Machine has been successfully paired!
|
||||||
|
</Info2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -102,6 +131,8 @@ const MachineNameComponent = ({ nextStep, classes, setQrCode, setName }) => {
|
||||||
Machine Name (ex: Coffee shop 01)
|
Machine Name (ex: Coffee shop 01)
|
||||||
</Info2>
|
</Info2>
|
||||||
<Formik
|
<Formik
|
||||||
|
validateOnBlur={false}
|
||||||
|
validateOnChange={false}
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
validationSchema={validationSchema}
|
validationSchema={validationSchema}
|
||||||
onSubmit={({ name }) => {
|
onSubmit={({ name }) => {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import {
|
||||||
placeholderColor,
|
placeholderColor,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
primaryColor,
|
primaryColor,
|
||||||
mainWidth
|
mainWidth,
|
||||||
|
spring2,
|
||||||
|
spring3
|
||||||
} from 'src/styling/variables'
|
} from 'src/styling/variables'
|
||||||
|
|
||||||
const { tl2, p } = typographyStyles
|
const { tl2, p } = typographyStyles
|
||||||
|
|
@ -55,12 +57,19 @@ const styles = {
|
||||||
qrCodeWrapper: {
|
qrCodeWrapper: {
|
||||||
display: 'flex'
|
display: 'flex'
|
||||||
},
|
},
|
||||||
|
qrTextInfoWrapper: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row'
|
||||||
|
},
|
||||||
qrTextWrapper: {
|
qrTextWrapper: {
|
||||||
width: 381,
|
width: 381,
|
||||||
marginLeft: 80,
|
marginLeft: 80,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column'
|
||||||
alignItems: 'center'
|
},
|
||||||
|
textWrapper: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
},
|
},
|
||||||
qrTextIcon: {
|
qrTextIcon: {
|
||||||
marginRight: 16
|
marginRight: 16
|
||||||
|
|
@ -95,6 +104,24 @@ const styles = {
|
||||||
},
|
},
|
||||||
stepperPast: {
|
stepperPast: {
|
||||||
border: [[1, 'solid', primaryColor]]
|
border: [[1, 'solid', primaryColor]]
|
||||||
|
},
|
||||||
|
successMessageWrapper: {
|
||||||
|
backgroundColor: spring3,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
padding: '0px 10px',
|
||||||
|
borderRadius: '8px'
|
||||||
|
},
|
||||||
|
successMessage: {
|
||||||
|
color: spring2,
|
||||||
|
margin: '8px 0px'
|
||||||
|
},
|
||||||
|
successMessageIcon: {
|
||||||
|
marginRight: 16,
|
||||||
|
marginBottom: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'col',
|
||||||
|
alignItems: 'center'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import gql from 'graphql-tag'
|
||||||
import * as R from 'ramda'
|
import * as R from 'ramda'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
import Tooltip from 'src/components/Tooltip'
|
import { Tooltip } from 'src/components/Tooltip'
|
||||||
import { Link } from 'src/components/buttons'
|
import { Link } from 'src/components/buttons'
|
||||||
import { Switch } from 'src/components/inputs'
|
import { Switch } from 'src/components/inputs'
|
||||||
import Sidebar from 'src/components/layout/Sidebar'
|
import Sidebar from 'src/components/layout/Sidebar'
|
||||||
|
|
@ -74,9 +74,14 @@ const Blacklist = () => {
|
||||||
display: 'Bitcoin'
|
display: 'Bitcoin'
|
||||||
})
|
})
|
||||||
const [errorMsg, setErrorMsg] = useState(null)
|
const [errorMsg, setErrorMsg] = useState(null)
|
||||||
|
const [deleteDialog, setDeleteDialog] = useState(false)
|
||||||
|
|
||||||
const [deleteEntry] = useMutation(DELETE_ROW, {
|
const [deleteEntry] = useMutation(DELETE_ROW, {
|
||||||
onError: () => console.error('Error while deleting row'),
|
onError: ({ message }) => {
|
||||||
|
const errorMessage = message ?? 'Error while deleting row'
|
||||||
|
setErrorMsg(errorMessage)
|
||||||
|
},
|
||||||
|
onCompleted: () => setDeleteDialog(false),
|
||||||
refetchQueries: () => ['getBlacklistData']
|
refetchQueries: () => ['getBlacklistData']
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -134,11 +139,11 @@ const Blacklist = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TitleSection title="Blacklisted addresses">
|
<TitleSection title="Blacklisted addresses">
|
||||||
<div>
|
<Box display="flex" justifyContent="flex-end">
|
||||||
<Link onClick={() => setShowModal(true)}>
|
<Link color="primary" onClick={() => setShowModal(true)}>
|
||||||
Blacklist new addresses
|
Blacklist new addresses
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</Box>
|
||||||
</TitleSection>
|
</TitleSection>
|
||||||
<Grid container className={classes.grid}>
|
<Grid container className={classes.grid}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
|
@ -181,6 +186,10 @@ const Blacklist = () => {
|
||||||
data={formattedData}
|
data={formattedData}
|
||||||
selectedCoin={clickedItem}
|
selectedCoin={clickedItem}
|
||||||
handleDeleteEntry={handleDeleteEntry}
|
handleDeleteEntry={handleDeleteEntry}
|
||||||
|
errorMessage={errorMsg}
|
||||||
|
setErrorMessage={setErrorMsg}
|
||||||
|
deleteDialog={deleteDialog}
|
||||||
|
setDeleteDialog={setDeleteDialog}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ const BlackListModal = ({
|
||||||
handleClose={onClose}
|
handleClose={onClose}
|
||||||
open={true}>
|
open={true}>
|
||||||
<Formik
|
<Formik
|
||||||
|
validateOnBlur={false}
|
||||||
|
validateOnChange={false}
|
||||||
initialValues={{
|
initialValues={{
|
||||||
address: ''
|
address: ''
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import * as R from 'ramda'
|
import * as R from 'ramda'
|
||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
import { DeleteDialog } from 'src/components/DeleteDialog'
|
||||||
import { IconButton } from 'src/components/buttons'
|
import { IconButton } from 'src/components/buttons'
|
||||||
import DataTable from 'src/components/tables/DataTable'
|
import DataTable from 'src/components/tables/DataTable'
|
||||||
import { Label1 } from 'src/components/typography'
|
import { Label1 } from 'src/components/typography'
|
||||||
|
|
@ -12,9 +13,19 @@ import styles from './Blacklist.styles'
|
||||||
|
|
||||||
const useStyles = makeStyles(styles)
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
const BlacklistTable = ({ data, selectedCoin, handleDeleteEntry }) => {
|
const BlacklistTable = ({
|
||||||
|
data,
|
||||||
|
selectedCoin,
|
||||||
|
handleDeleteEntry,
|
||||||
|
errorMessage,
|
||||||
|
setErrorMessage,
|
||||||
|
deleteDialog,
|
||||||
|
setDeleteDialog
|
||||||
|
}) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const [toBeDeleted, setToBeDeleted] = useState()
|
||||||
|
|
||||||
const elements = [
|
const elements = [
|
||||||
{
|
{
|
||||||
name: 'address',
|
name: 'address',
|
||||||
|
|
@ -37,12 +48,10 @@ const BlacklistTable = ({ data, selectedCoin, handleDeleteEntry }) => {
|
||||||
view: it => (
|
view: it => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={classes.deleteButton}
|
className={classes.deleteButton}
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
handleDeleteEntry(
|
setDeleteDialog(true)
|
||||||
R.path(['cryptoCode'], it),
|
setToBeDeleted(it)
|
||||||
R.path(['address'], it)
|
}}>
|
||||||
)
|
|
||||||
}>
|
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)
|
)
|
||||||
|
|
@ -53,7 +62,29 @@ const BlacklistTable = ({ data, selectedCoin, handleDeleteEntry }) => {
|
||||||
: data[R.keys(data)[0]]
|
: data[R.keys(data)[0]]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable data={dataToShow} elements={elements} name="blacklistTable" />
|
<>
|
||||||
|
<DataTable
|
||||||
|
data={dataToShow}
|
||||||
|
elements={elements}
|
||||||
|
emptyText="No blacklisted addresses so far"
|
||||||
|
name="blacklistTable"
|
||||||
|
/>
|
||||||
|
<DeleteDialog
|
||||||
|
open={deleteDialog}
|
||||||
|
onDismissed={() => {
|
||||||
|
setDeleteDialog(false)
|
||||||
|
setErrorMessage(null)
|
||||||
|
}}
|
||||||
|
onConfirmed={() => {
|
||||||
|
setErrorMessage(null)
|
||||||
|
handleDeleteEntry(
|
||||||
|
R.path(['cryptoCode'], toBeDeleted),
|
||||||
|
R.path(['address'], toBeDeleted)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ import gql from 'graphql-tag'
|
||||||
import * as R from 'ramda'
|
import * as R from 'ramda'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
import Tooltip from 'src/components/Tooltip'
|
import { Tooltip } from 'src/components/Tooltip'
|
||||||
import { NamespacedTable as EditableTable } from 'src/components/editableTable'
|
import { NamespacedTable as EditableTable } from 'src/components/editableTable'
|
||||||
import { Switch } from 'src/components/inputs'
|
import { Switch } from 'src/components/inputs'
|
||||||
import TitleSection from 'src/components/layout/TitleSection'
|
import TitleSection from 'src/components/layout/TitleSection'
|
||||||
|
import { EmptyTable } from 'src/components/table'
|
||||||
import { P, Label2 } from 'src/components/typography'
|
import { P, Label2 } from 'src/components/typography'
|
||||||
import { fromNamespace, toNamespace } from 'src/utils/config'
|
import { fromNamespace, toNamespace } from 'src/utils/config'
|
||||||
|
|
||||||
|
|
@ -71,6 +72,8 @@ const CashOut = ({ name: SCREEN_KEY }) => {
|
||||||
save(toNamespace(id, { active: !namespaced?.active }))
|
save(toNamespace(id, { active: !namespaced?.active }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wasNeverEnabled = it => R.compose(R.length, R.keys)(it) === 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TitleSection title="Cash-out">
|
<TitleSection title="Cash-out">
|
||||||
|
|
@ -101,7 +104,7 @@ const CashOut = ({ name: SCREEN_KEY }) => {
|
||||||
<EditableTable
|
<EditableTable
|
||||||
namespaces={R.map(R.path(['deviceId']))(machines)}
|
namespaces={R.map(R.path(['deviceId']))(machines)}
|
||||||
data={config}
|
data={config}
|
||||||
stripeWhen={it => !DenominationsSchema.isValidSync(it)}
|
stripeWhen={wasNeverEnabled}
|
||||||
enableEdit
|
enableEdit
|
||||||
editWidth={134}
|
editWidth={134}
|
||||||
enableToggle
|
enableToggle
|
||||||
|
|
@ -113,6 +116,7 @@ const CashOut = ({ name: SCREEN_KEY }) => {
|
||||||
disableRowEdit={R.compose(R.not, R.path(['active']))}
|
disableRowEdit={R.compose(R.not, R.path(['active']))}
|
||||||
elements={getElements(machines, locale)}
|
elements={getElements(machines, locale)}
|
||||||
/>
|
/>
|
||||||
|
{R.isEmpty(config) && <EmptyTable message="No machines so far" />}
|
||||||
{wizard && (
|
{wizard && (
|
||||||
<Wizard
|
<Wizard
|
||||||
machine={R.find(R.propEq('deviceId', wizard))(machines)}
|
machine={R.find(R.propEq('deviceId', wizard))(machines)}
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,22 @@ const Wizard = ({ machine, locale, onClose, save, error }) => {
|
||||||
{
|
{
|
||||||
type: 'top',
|
type: 'top',
|
||||||
display: 'Cassette 1 (Top)',
|
display: 'Cassette 1 (Top)',
|
||||||
component: Autocomplete
|
component: Autocomplete,
|
||||||
|
inputProps: {
|
||||||
|
options: R.map(it => ({ code: it, display: it }))(options),
|
||||||
|
labelProp: 'display',
|
||||||
|
valueProp: 'code'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'bottom',
|
type: 'bottom',
|
||||||
display: 'Cassette 2',
|
display: 'Cassette 2',
|
||||||
component: Autocomplete
|
component: Autocomplete,
|
||||||
|
inputProps: {
|
||||||
|
options: R.map(it => ({ code: it, display: it }))(options),
|
||||||
|
labelProp: 'display',
|
||||||
|
valueProp: 'code'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'zeroConfLimit',
|
type: 'zeroConfLimit',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { makeStyles } from '@material-ui/core'
|
import { makeStyles } from '@material-ui/core'
|
||||||
import { Formik, Form, Field } from 'formik'
|
import { Formik, Form, Field } from 'formik'
|
||||||
import * as R from 'ramda'
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import ErrorMessage from 'src/components/ErrorMessage'
|
import ErrorMessage from 'src/components/ErrorMessage'
|
||||||
|
|
@ -44,6 +43,8 @@ const WizardStep = ({
|
||||||
|
|
||||||
{step <= 2 && (
|
{step <= 2 && (
|
||||||
<Formik
|
<Formik
|
||||||
|
validateOnBlur={false}
|
||||||
|
validateOnChange={false}
|
||||||
onSubmit={onContinue}
|
onSubmit={onContinue}
|
||||||
initialValues={{ top: '', bottom: '' }}
|
initialValues={{ top: '', bottom: '' }}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
|
|
@ -71,7 +72,7 @@ const WizardStep = ({
|
||||||
name={type}
|
name={type}
|
||||||
options={options}
|
options={options}
|
||||||
valueProp={'code'}
|
valueProp={'code'}
|
||||||
getLabel={R.path(['display'])}></Field>
|
labelProp={'display'}></Field>
|
||||||
<Info1 noMargin className={classes.suffix}>
|
<Info1 noMargin className={classes.suffix}>
|
||||||
{fiatCurrency}
|
{fiatCurrency}
|
||||||
</Info1>
|
</Info1>
|
||||||
|
|
@ -96,6 +97,8 @@ const WizardStep = ({
|
||||||
|
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<Formik
|
<Formik
|
||||||
|
validateOnBlur={false}
|
||||||
|
validateOnChange={false}
|
||||||
onSubmit={onContinue}
|
onSubmit={onContinue}
|
||||||
initialValues={{ zeroConfLimit: '' }}
|
initialValues={{ zeroConfLimit: '' }}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,12 @@ const DenominationsSchema = Yup.object().shape({
|
||||||
top: Yup.number()
|
top: Yup.number()
|
||||||
.label('Cassette 1 (Top)')
|
.label('Cassette 1 (Top)')
|
||||||
.required()
|
.required()
|
||||||
.min(0)
|
.min(1)
|
||||||
.max(currencyMax),
|
.max(currencyMax),
|
||||||
bottom: Yup.number()
|
bottom: Yup.number()
|
||||||
.label('Cassette 2 (Bottom)')
|
.label('Cassette 2 (Bottom)')
|
||||||
.required()
|
.required()
|
||||||
.min(0)
|
.min(1)
|
||||||
.max(currencyMax),
|
.max(currencyMax),
|
||||||
zeroConfLimit: Yup.number()
|
zeroConfLimit: Yup.number()
|
||||||
.label('0-conf Limit')
|
.label('0-conf Limit')
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
import { useQuery, useMutation } from '@apollo/react-hooks'
|
import { useQuery, useMutation } from '@apollo/react-hooks'
|
||||||
|
import { makeStyles } from '@material-ui/core'
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
import * as R from 'ramda'
|
import * as R from 'ramda'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
import { Table as EditableTable } from 'src/components/editableTable'
|
|
||||||
import Section from 'src/components/layout/Section'
|
|
||||||
import TitleSection from 'src/components/layout/TitleSection'
|
import TitleSection from 'src/components/layout/TitleSection'
|
||||||
|
import { ReactComponent as ReverseListingViewIcon } from 'src/styling/icons/circle buttons/listing-view/white.svg'
|
||||||
|
import { ReactComponent as ListingViewIcon } from 'src/styling/icons/circle buttons/listing-view/zodiac.svg'
|
||||||
|
import { ReactComponent as OverrideLabelIcon } from 'src/styling/icons/status/spring2.svg'
|
||||||
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
|
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
|
||||||
|
|
||||||
import {
|
import CommissionsDetails from './components/CommissionsDetails'
|
||||||
mainFields,
|
import CommissionsList from './components/CommissionsList'
|
||||||
overrides,
|
|
||||||
schema,
|
const styles = {
|
||||||
getOverridesSchema,
|
listViewButton: {
|
||||||
defaults,
|
marginLeft: 4
|
||||||
overridesDefaults,
|
}
|
||||||
getOrder
|
}
|
||||||
} from './helper'
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
const GET_DATA = gql`
|
const GET_DATA = gql`
|
||||||
query getData {
|
query getData {
|
||||||
|
|
@ -37,27 +40,27 @@ const SAVE_CONFIG = gql`
|
||||||
saveConfig(config: $config)
|
saveConfig(config: $config)
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
const removeCoinFromOverride = crypto => override =>
|
||||||
|
R.mergeRight(override, {
|
||||||
|
cryptoCurrencies: R.without([crypto], override.cryptoCurrencies)
|
||||||
|
})
|
||||||
|
|
||||||
const Commissions = ({ name: SCREEN_KEY }) => {
|
const Commissions = ({ name: SCREEN_KEY }) => {
|
||||||
const [isEditingDefault, setEditingDefault] = useState(false)
|
const classes = useStyles()
|
||||||
const [isEditingOverrides, setEditingOverrides] = useState(false)
|
const [showMachines, setShowMachines] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
const { data } = useQuery(GET_DATA)
|
const { data } = useQuery(GET_DATA)
|
||||||
const [saveConfig, { error }] = useMutation(SAVE_CONFIG, {
|
const [saveConfig] = useMutation(SAVE_CONFIG, {
|
||||||
refetchQueries: () => ['getData']
|
refetchQueries: () => ['getData'],
|
||||||
|
onError: error => setError(error)
|
||||||
})
|
})
|
||||||
|
|
||||||
const config = data?.config && fromNamespace(SCREEN_KEY)(data.config)
|
const config = data?.config && fromNamespace(SCREEN_KEY)(data.config)
|
||||||
const currency = R.path(['fiatCurrency'])(
|
const localeConfig =
|
||||||
fromNamespace(namespaces.LOCALE)(data?.config)
|
data?.config && fromNamespace(namespaces.LOCALE)(data.config)
|
||||||
)
|
|
||||||
|
|
||||||
const commission = config && !R.isEmpty(config) ? config : defaults
|
const currency = R.prop('fiatCurrency')(localeConfig)
|
||||||
const commissionOverrides = commission?.overrides ?? []
|
const overrides = R.prop('overrides')(config)
|
||||||
|
|
||||||
const orderedCommissionsOverrides = R.sortWith([
|
|
||||||
R.ascend(getOrder),
|
|
||||||
R.ascend(R.prop('machine'))
|
|
||||||
])(commissionOverrides)
|
|
||||||
|
|
||||||
const save = it => {
|
const save = it => {
|
||||||
const config = toNamespace(SCREEN_KEY)(it.commissions[0])
|
const config = toNamespace(SCREEN_KEY)(it.commissions[0])
|
||||||
|
|
@ -66,54 +69,75 @@ const Commissions = ({ name: SCREEN_KEY }) => {
|
||||||
|
|
||||||
const saveOverrides = it => {
|
const saveOverrides = it => {
|
||||||
const config = toNamespace(SCREEN_KEY)(it)
|
const config = toNamespace(SCREEN_KEY)(it)
|
||||||
|
setError(null)
|
||||||
return saveConfig({ variables: { config } })
|
return saveConfig({ variables: { config } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onEditingDefault = (it, editing) => setEditingDefault(editing)
|
const saveOverridesFromList = it => (_, override) => {
|
||||||
const onEditingOverrides = (it, editing) => setEditingOverrides(editing)
|
const cryptoOverriden = R.path(['cryptoCurrencies', 0], override)
|
||||||
|
|
||||||
|
const sameMachine = R.eqProps('machine', override)
|
||||||
|
const notSameOverride = it => !R.eqProps('cryptoCurrencies', override, it)
|
||||||
|
|
||||||
|
const filterMachine = R.filter(R.both(sameMachine, notSameOverride))
|
||||||
|
const removeCoin = removeCoinFromOverride(cryptoOverriden)
|
||||||
|
|
||||||
|
const machineOverrides = R.map(removeCoin)(filterMachine(it))
|
||||||
|
|
||||||
|
const overrides = machineOverrides.concat(
|
||||||
|
R.filter(it => !sameMachine(it), it)
|
||||||
|
)
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
commissions_overrides: R.prepend(override, overrides)
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveConfig({ variables: { config } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = showMachines
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Override value',
|
||||||
|
icon: <OverrideLabelIcon />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TitleSection title="Commissions" />
|
<TitleSection
|
||||||
<Section>
|
title="Commissions"
|
||||||
<EditableTable
|
labels={labels}
|
||||||
error={error?.message}
|
button={{
|
||||||
title="Default setup"
|
text: 'List view',
|
||||||
rowSize="lg"
|
icon: ListingViewIcon,
|
||||||
titleLg
|
inverseIcon: ReverseListingViewIcon,
|
||||||
name="commissions"
|
toggle: setShowMachines
|
||||||
enableEdit
|
}}
|
||||||
initialValues={commission}
|
iconClassName={classes.listViewButton}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!showMachines && (
|
||||||
|
<CommissionsDetails
|
||||||
|
config={config}
|
||||||
|
currency={currency}
|
||||||
|
data={data}
|
||||||
|
error={error}
|
||||||
save={save}
|
save={save}
|
||||||
validationSchema={schema}
|
saveOverrides={saveOverrides}
|
||||||
data={R.of(commission)}
|
|
||||||
elements={mainFields(currency)}
|
|
||||||
setEditing={onEditingDefault}
|
|
||||||
forceDisable={isEditingOverrides}
|
|
||||||
/>
|
/>
|
||||||
</Section>
|
)}
|
||||||
<Section>
|
{showMachines && (
|
||||||
<EditableTable
|
<CommissionsList
|
||||||
error={error?.message}
|
config={config}
|
||||||
title="Overrides"
|
localeConfig={localeConfig}
|
||||||
titleLg
|
currency={currency}
|
||||||
name="overrides"
|
data={data}
|
||||||
enableDelete
|
error={error}
|
||||||
enableEdit
|
saveOverrides={saveOverridesFromList(overrides)}
|
||||||
enableCreate
|
|
||||||
groupBy={getOrder}
|
|
||||||
initialValues={overridesDefaults}
|
|
||||||
save={saveOverrides}
|
|
||||||
validationSchema={getOverridesSchema(
|
|
||||||
orderedCommissionsOverrides,
|
|
||||||
data
|
|
||||||
)}
|
|
||||||
data={orderedCommissionsOverrides}
|
|
||||||
elements={overrides(data, currency, orderedCommissionsOverrides)}
|
|
||||||
setEditing={onEditingOverrides}
|
|
||||||
forceDisable={isEditingDefault}
|
|
||||||
/>
|
/>
|
||||||
</Section>
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { useState, memo } from 'react'
|
||||||
|
|
||||||
|
import { Table as EditableTable } from 'src/components/editableTable'
|
||||||
|
import Section from 'src/components/layout/Section'
|
||||||
|
import {
|
||||||
|
mainFields,
|
||||||
|
overrides,
|
||||||
|
schema,
|
||||||
|
getOverridesSchema,
|
||||||
|
defaults,
|
||||||
|
overridesDefaults,
|
||||||
|
getOrder
|
||||||
|
} from 'src/pages/Commissions/helper'
|
||||||
|
|
||||||
|
const CommissionsDetails = memo(
|
||||||
|
({ config, currency, data, error, save, saveOverrides }) => {
|
||||||
|
const [isEditingDefault, setEditingDefault] = useState(false)
|
||||||
|
const [isEditingOverrides, setEditingOverrides] = useState(false)
|
||||||
|
|
||||||
|
const commission = config && !R.isEmpty(config) ? config : defaults
|
||||||
|
const commissionOverrides = commission?.overrides ?? []
|
||||||
|
|
||||||
|
const orderedCommissionsOverrides = R.sortWith([
|
||||||
|
R.ascend(getOrder),
|
||||||
|
R.ascend(R.prop('machine'))
|
||||||
|
])(commissionOverrides)
|
||||||
|
|
||||||
|
const onEditingDefault = (it, editing) => setEditingDefault(editing)
|
||||||
|
const onEditingOverrides = (it, editing) => setEditingOverrides(editing)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section>
|
||||||
|
<EditableTable
|
||||||
|
error={error?.message}
|
||||||
|
title="Default setup"
|
||||||
|
rowSize="lg"
|
||||||
|
titleLg
|
||||||
|
name="commissions"
|
||||||
|
enableEdit
|
||||||
|
initialValues={commission}
|
||||||
|
save={save}
|
||||||
|
validationSchema={schema}
|
||||||
|
data={R.of(commission)}
|
||||||
|
elements={mainFields(currency)}
|
||||||
|
setEditing={onEditingDefault}
|
||||||
|
forceDisable={isEditingOverrides}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section>
|
||||||
|
<EditableTable
|
||||||
|
error={error?.message}
|
||||||
|
title="Overrides"
|
||||||
|
titleLg
|
||||||
|
name="overrides"
|
||||||
|
enableDelete
|
||||||
|
enableEdit
|
||||||
|
enableCreate
|
||||||
|
groupBy={getOrder}
|
||||||
|
initialValues={overridesDefaults}
|
||||||
|
save={saveOverrides}
|
||||||
|
validationSchema={getOverridesSchema(
|
||||||
|
orderedCommissionsOverrides,
|
||||||
|
data
|
||||||
|
)}
|
||||||
|
data={orderedCommissionsOverrides}
|
||||||
|
elements={overrides(data, currency, orderedCommissionsOverrides)}
|
||||||
|
setEditing={onEditingOverrides}
|
||||||
|
forceDisable={isEditingDefault}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default CommissionsDetails
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { makeStyles } from '@material-ui/core'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { memo, useState } from 'react'
|
||||||
|
|
||||||
|
import { Table as EditableTable } from 'src/components/editableTable'
|
||||||
|
import { Select } from 'src/components/inputs'
|
||||||
|
import {
|
||||||
|
overridesDefaults,
|
||||||
|
getCommissions,
|
||||||
|
getListCommissionsSchema,
|
||||||
|
commissionsList
|
||||||
|
} from 'src/pages/Commissions/helper'
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
headerLine: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: '',
|
||||||
|
marginBottom: 24
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
marginRight: 24
|
||||||
|
},
|
||||||
|
tableWrapper: {
|
||||||
|
flex: 1,
|
||||||
|
display: 'block',
|
||||||
|
overflowY: 'auto',
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: '70vh'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHOW_ALL = {
|
||||||
|
code: 'SHOW_ALL',
|
||||||
|
display: 'Show all'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDER_OPTIONS = [
|
||||||
|
{
|
||||||
|
code: 'machine',
|
||||||
|
display: 'Machine Name'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'cryptoCurrencies',
|
||||||
|
display: 'Cryptocurrency'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'cashIn',
|
||||||
|
display: 'Cash-in'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'cashOut',
|
||||||
|
display: 'Cash-out'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'fixedFee',
|
||||||
|
display: 'Fixed Fee'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'minimumTx',
|
||||||
|
display: 'Minimum Tx'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const getElement = (code, display) => ({
|
||||||
|
code: code,
|
||||||
|
display: display || code
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortCommissionsBy = prop => {
|
||||||
|
switch (prop) {
|
||||||
|
case ORDER_OPTIONS[0]:
|
||||||
|
return R.sortBy(R.find(R.propEq('code', R.prop('machine'))))
|
||||||
|
case ORDER_OPTIONS[1]:
|
||||||
|
return R.sortBy(R.path(['cryptoCurrencies', 0]))
|
||||||
|
default:
|
||||||
|
return R.sortBy(R.prop(prop.code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterCommissions = (coinFilter, machineFilter) =>
|
||||||
|
R.compose(
|
||||||
|
R.filter(
|
||||||
|
it => (machineFilter === SHOW_ALL) | (machineFilter.code === it.machine)
|
||||||
|
),
|
||||||
|
R.filter(
|
||||||
|
it =>
|
||||||
|
(coinFilter === SHOW_ALL) | (coinFilter.code === it.cryptoCurrencies[0])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const CommissionsList = memo(
|
||||||
|
({ config, localeConfig, currency, data, error, saveOverrides }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const [machineFilter, setMachineFilter] = useState(SHOW_ALL)
|
||||||
|
const [coinFilter, setCoinFilter] = useState(SHOW_ALL)
|
||||||
|
const [orderProp, setOrderProp] = useState(ORDER_OPTIONS[0])
|
||||||
|
|
||||||
|
const coins = R.prop('cryptoCurrencies', localeConfig)
|
||||||
|
|
||||||
|
const getMachineCoins = deviceId => {
|
||||||
|
const override = R.prop('overrides', localeConfig)?.find(
|
||||||
|
R.propEq('machine', deviceId)
|
||||||
|
)
|
||||||
|
|
||||||
|
const machineCoins = override
|
||||||
|
? R.prop('cryptoCurrencies', override)
|
||||||
|
: coins
|
||||||
|
|
||||||
|
return R.xprod([deviceId], machineCoins)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMachineElement = it =>
|
||||||
|
getElement(R.prop('deviceId', it), R.prop('name', it))
|
||||||
|
|
||||||
|
const cryptoData = R.map(getElement)(coins)
|
||||||
|
|
||||||
|
const machineData = R.sortBy(
|
||||||
|
R.prop('display'),
|
||||||
|
R.map(getMachineElement)(R.prop('machines', data))
|
||||||
|
)
|
||||||
|
|
||||||
|
const machinesCoinsTuples = R.unnest(
|
||||||
|
R.map(getMachineCoins)(machineData.map(R.prop('code')))
|
||||||
|
)
|
||||||
|
|
||||||
|
const commissions = R.map(([deviceId, cryptoCode]) =>
|
||||||
|
getCommissions(cryptoCode, deviceId, config)
|
||||||
|
)(machinesCoinsTuples)
|
||||||
|
|
||||||
|
const tableData = R.compose(
|
||||||
|
sortCommissionsBy(orderProp),
|
||||||
|
filterCommissions(coinFilter, machineFilter)
|
||||||
|
)(commissions)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={classes.headerLine}>
|
||||||
|
<Select
|
||||||
|
className={classes.select}
|
||||||
|
onSelectedItemChange={setMachineFilter}
|
||||||
|
label="Machines"
|
||||||
|
default={SHOW_ALL}
|
||||||
|
items={[SHOW_ALL].concat(machineData)}
|
||||||
|
selectedItem={machineFilter}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
className={classes.select}
|
||||||
|
onSelectedItemChange={setCoinFilter}
|
||||||
|
label="Cryptocurrency"
|
||||||
|
default={SHOW_ALL}
|
||||||
|
items={[SHOW_ALL].concat(cryptoData)}
|
||||||
|
selectedItem={coinFilter}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
onSelectedItemChange={setOrderProp}
|
||||||
|
label="Sort by"
|
||||||
|
default={ORDER_OPTIONS[0]}
|
||||||
|
items={ORDER_OPTIONS}
|
||||||
|
selectedItem={orderProp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={classes.tableWrapper}>
|
||||||
|
<EditableTable
|
||||||
|
error={error?.message}
|
||||||
|
name="comissionsList"
|
||||||
|
enableEdit
|
||||||
|
save={saveOverrides}
|
||||||
|
initialValues={overridesDefaults}
|
||||||
|
validationSchema={getListCommissionsSchema()}
|
||||||
|
data={tableData}
|
||||||
|
elements={commissionsList(data, currency)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default CommissionsList
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue