From c457faab40390545fc3d66908b6711a94fd3609e Mon Sep 17 00:00:00 2001 From: Cesar <26280794+csrapr@users.noreply.github.com> Date: Thu, 17 Dec 2020 22:18:35 +0000 Subject: [PATCH] Chore: make notification center UI Chore: fiatBalancesNotify refactor Chore: removed now-unused code in some files Feat: change column "detail" in database to use jsonb Chore: add notification center background and button Chore: notifications screen scaffolding Fix: change position of notification UI Feat: join backend and frontend Feat: notification icons and machine names Feat: add clear all button, stripe overlay on invalid notification Fix: rework notification styles Feat: use popper to render notifications Feat: make notification center UI Fix: fix css on notification center Fix: fix invalidateNotification Chore: apply PR requested changes Fix: PR fixes Fix: make toggleable body/root styles be handled by react Chore: delete old notifier file Fix: undo variable name changes for cryptobalance notifs --- lib/blacklist.js | 9 +- lib/cash-in/cash-in-tx.js | 10 +- lib/cash-out/cash-out-atomic.js | 8 +- lib/customers.js | 21 +- lib/machine-loader.js | 39 +- lib/new-admin/graphql/schema.js | 30 +- lib/new-settings-loader.js | 12 - lib/notifier.js | 351 --------------- lib/notifier/codes.js | 11 +- lib/notifier/email.js | 49 +- lib/notifier/index.js | 424 ++++++++---------- lib/notifier/queries.js | 113 +++-- lib/notifier/sms.js | 29 +- lib/notifier/test/notifier.test.js | 318 ++++++------- lib/notifier/test/utils.test.js | 136 +++--- lib/notifier/utils.js | 93 ++-- lib/plugins.js | 424 +++++++----------- lib/plugins/sms/mock-sms/mock-sms.js | 2 +- lib/poller.js | 2 +- lib/routes.js | 3 +- ...607009558538-create-notifications-table.js | 17 +- .../NotificationCenter/NotificationCenter.js | 144 ++++++ .../NotificationCenter.styles.js | 119 +++++ .../NotificationCenter/NotificationRow.js | 82 ++++ .../components/NotificationCenter/index.js | 2 + .../src/components/layout/Header.js | 67 ++- .../src/components/layout/Header.styles.js | 54 ++- .../src/pages/Customers/CustomerProfile.js | 7 +- .../src/pages/Customers/CustomersList.js | 8 +- .../Customers/components/CustomerDetails.js | 9 +- .../src/pages/Customers/helper.js | 7 +- .../sections/CryptoBalanceOverrides.js | 4 +- new-lamassu-admin/src/styling/global/index.js | 12 + .../styling/icons/action/wrench/zodiac.svg | 12 + .../src/styling/icons/arrow/transaction.svg | 19 + .../icons/menu/notification-zodiac.svg | 13 + .../src/styling/icons/stage/zodiac/full.svg | 9 + 37 files changed, 1337 insertions(+), 1332 deletions(-) delete mode 100644 lib/notifier.js create mode 100644 new-lamassu-admin/src/components/NotificationCenter/NotificationCenter.js create mode 100644 new-lamassu-admin/src/components/NotificationCenter/NotificationCenter.styles.js create mode 100644 new-lamassu-admin/src/components/NotificationCenter/NotificationRow.js create mode 100644 new-lamassu-admin/src/components/NotificationCenter/index.js create mode 100644 new-lamassu-admin/src/styling/icons/action/wrench/zodiac.svg create mode 100644 new-lamassu-admin/src/styling/icons/arrow/transaction.svg create mode 100644 new-lamassu-admin/src/styling/icons/menu/notification-zodiac.svg create mode 100644 new-lamassu-admin/src/styling/icons/stage/zodiac/full.svg diff --git a/lib/blacklist.js b/lib/blacklist.js index d6fa4f18..d5d45027 100644 --- a/lib/blacklist.js +++ b/lib/blacklist.js @@ -1,4 +1,5 @@ const db = require('./db') +const notifier = require('./notifier') // Get all blacklist rows from the DB "blacklist" table that were manually inserted by the operator const getBlacklist = () => { @@ -13,11 +14,9 @@ const getBlacklist = () => { // Delete row from blacklist table by crypto code and address const deleteFromBlacklist = (cryptoCode, address) => { - const sql = `DELETE FROM blacklist WHERE crypto_code = $1 AND address = $2; - UPDATE notifications SET valid = 'f', read = 't' WHERE valid = 't' AND detail IN ($3^)` - - const detail = `'${cryptoCode}_BLOCKED_${address}', '${cryptoCode}_REUSED_${address}'` - return db.none(sql, [cryptoCode, address, detail]) + const sql = `DELETE FROM blacklist WHERE crypto_code = $1 AND address = $2` + notifier.clearBlacklistNotification(cryptoCode, address) + return db.none(sql, [cryptoCode, address]) } const insertIntoBlacklist = (cryptoCode, address) => { diff --git a/lib/cash-in/cash-in-tx.js b/lib/cash-in/cash-in-tx.js index 77e6276a..3c9f8463 100644 --- a/lib/cash-in/cash-in-tx.js +++ b/lib/cash-in/cash-in-tx.js @@ -8,7 +8,7 @@ const plugins = require('../plugins') const logger = require('../logger') const settingsLoader = require('../new-settings-loader') const configManager = require('../new-config-manager') -const notifier = require("../notifier/index") +const notifier = require('../notifier') const cashInAtomic = require('./cash-in-atomic') const cashInLow = require('./cash-in-low') @@ -16,7 +16,7 @@ const cashInLow = require('./cash-in-low') const PENDING_INTERVAL = '60 minutes' const MAX_PENDING = 10 -module.exports = {post, monitorPending, cancel, PENDING_INTERVAL} +module.exports = { post, monitorPending, cancel, PENDING_INTERVAL } function post (machineTx, pi) { return db.tx(cashInAtomic.atomic(machineTx, pi)) @@ -29,10 +29,10 @@ function post (machineTx, pi) { .then(([{ config }, blacklistItems]) => { const rejectAddressReuseActive = configManager.getCompliance(config).rejectAddressReuse - if (_.some(it => it.created_by_operator === true)(blacklistItems)) { + if (_.some(it => it.created_by_operator)(blacklistItems)) { blacklisted = true notifier.blacklistNotify(r.tx, false) - } else if (_.some(it => it.created_by_operator === false)(blacklistItems) && rejectAddressReuseActive) { + } else if (_.some(it => !it.created_by_operator)(blacklistItems) && rejectAddressReuseActive) { notifier.blacklistNotify(r.tx, true) addressReuse = true } @@ -65,7 +65,7 @@ function logAction (rec, tx) { } function logActionById (action, _rec, txId) { - const rec = _.assign(_rec, {action, tx_id: txId}) + const rec = _.assign(_rec, { action, tx_id: txId }) const sql = pgp.helpers.insert(rec, null, 'cash_in_actions') return db.none(sql) diff --git a/lib/cash-out/cash-out-atomic.js b/lib/cash-out/cash-out-atomic.js index ea65402f..f3365123 100644 --- a/lib/cash-out/cash-out-atomic.js +++ b/lib/cash-out/cash-out-atomic.js @@ -9,8 +9,6 @@ const helper = require('./cash-out-helper') const cashOutActions = require('./cash-out-actions') const cashOutLow = require('./cash-out-low') -const notifier = require("../notifier/index") - const toObj = helper.toObj module.exports = {atomic} @@ -124,10 +122,8 @@ function updateCassettes (t, tx) { tx.deviceId ] - return t.one(sql, values).then(r => { - notifier.cashCassettesNotify(r, tx.deviceId) - return socket.emit(_.assign(r, {op: 'cassetteUpdate', deviceId: tx.deviceId})) - }) + return t.one(sql, values) + .then(r => socket.emit(_.assign(r, {op: 'cassetteUpdate', deviceId: tx.deviceId}))) } function wasJustAuthorized (oldTx, newTx, isZeroConf) { diff --git a/lib/customers.js b/lib/customers.js index 0e3b535a..6a336a3c 100644 --- a/lib/customers.js +++ b/lib/customers.js @@ -15,7 +15,8 @@ const complianceOverrides = require('./compliance_overrides') const users = require('./users') const options = require('./options') const writeFile = util.promisify(fs.writeFile) - +const notifierQueries = require('./notifier/queries') +const notifierUtils = require('./notifier/utils') const NUM_RESULTS = 1000 const idPhotoCardBasedir = _.get('idPhotoCardDir', options) const frontCameraBaseDir = _.get('frontCameraDir', options) @@ -115,7 +116,7 @@ async function updateCustomer (id, data, userToken) { const sql = Pgp.helpers.update(updateData, _.keys(updateData), 'customers') + ' where id=$1' - invalidateCustomerNotifications(id, formattedData) + invalidateCustomerNotifications(id, formattedData).catch(console.error) await db.none(sql, [id]) @@ -123,12 +124,10 @@ async function updateCustomer (id, data, userToken) { } const invalidateCustomerNotifications = (id, data) => { - let detail = ''; - if(data.authorized_override === 'verified') { - detail = `BLOCKED_${id}` - } - const sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE valid = 't' AND detail = $1` - return db.none(sql, [detail]) + if (data.authorized_override !== 'verified') return Promise.resolve() + + const detailB = notifierUtils.buildDetail({ code: 'BLOCKED', customerId: id }) + return notifierQueries.invalidateNotification(detailB, 'compliance').catch(console.error) } /** @@ -409,7 +408,7 @@ function populateOverrideUsernames (customer) { return users.getByIds(queryTokens) .then(usersList => { return _.map(userField => { - const user = _.find({token: userField.token}, usersList) + const user = _.find({ token: userField.token }, usersList) return { [userField.field]: user ? user.name : null } @@ -443,7 +442,7 @@ function batch () { // 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 * transactions * @@ -481,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 * transactions * diff --git a/lib/machine-loader.js b/lib/machine-loader.js index e3b787f9..0ba4a610 100644 --- a/lib/machine-loader.js +++ b/lib/machine-loader.js @@ -1,18 +1,17 @@ const _ = require('lodash/fp') const axios = require('axios') -const logger = require('./logger') const db = require('./db') const pairing = require('./pairing') const notifier = require('./notifier') const dbm = require('./postgresql_interface') const configManager = require('./new-config-manager') const settingsLoader = require('./new-settings-loader') - -module.exports = {getMachineName, getMachines, getMachine, getMachineNames, setMachine} +const notifierUtils = require('./notifier/utils') +const notifierQueries = require('./notifier/queries') function getMachines () { - return db.any('select * from devices where display=TRUE order by created') + return db.any('SELECT * FROM devices WHERE display=TRUE ORDER BY created') .then(rr => rr.map(r => ({ deviceId: r.device_id, cashbox: r.cashbox, @@ -36,10 +35,10 @@ function getConfig (defaultConfig) { } function getMachineNames (config) { - const fullyFunctionalStatus = {label: 'Fully functional', type: 'success'} - const unresponsiveStatus = {label: 'Unresponsive', type: 'error'} - const stuckStatus = {label: 'Stuck', type: 'error'} - + const fullyFunctionalStatus = { label: 'Fully functional', type: 'success' } + const unresponsiveStatus = { label: 'Unresponsive', type: 'error' } + const stuckStatus = { label: 'Stuck', type: 'error' } + return Promise.all([getMachines(), getConfig(config)]) .then(([machines, config]) => Promise.all( [machines, notifier.checkPings(machines), dbm.machineEvents(), config] @@ -49,7 +48,7 @@ function getMachineNames (config) { if (ping && ping.age) return unresponsiveStatus if (stuck && stuck.age) return stuckStatus - + return fullyFunctionalStatus } @@ -57,7 +56,7 @@ function getMachineNames (config) { const cashOutConfig = configManager.getCashOut(r.deviceId, config) const cashOut = !!cashOutConfig.active - + const statuses = [ getStatus( _.first(pings[r.deviceId]), @@ -65,7 +64,7 @@ function getMachineNames (config) { ) ] - return _.assign(r, {cashOut, statuses}) + return _.assign(r, { cashOut, statuses }) } return _.map(addName, machines) @@ -83,31 +82,29 @@ function getMachineNames (config) { * @returns {string} machine name */ function getMachineName (machineId) { - const sql = 'select * from devices where device_id=$1' + const sql = 'SELECT * FROM devices WHERE device_id=$1' return db.oneOrNone(sql, [machineId]) .then(it => it.name) } function getMachine (machineId) { - const sql = 'select * from devices where device_id=$1' + const sql = 'SELECT * FROM devices WHERE device_id=$1' return db.oneOrNone(sql, [machineId]).then(res => _.mapKeys(_.camelCase)(res)) } function renameMachine (rec) { - const sql = 'update devices set name=$1 where device_id=$2' + const sql = 'UPDATE devices SET name=$1 WHERE device_id=$2' return db.none(sql, [rec.newName, rec.deviceId]) } function resetCashOutBills (rec) { - const sql = ` - update devices set cassette1=$1, cassette2=$2 where device_id=$3; - update notifications set read = 't', valid = 'f' where read = 'f' AND valid = 't' AND device_id = $3 AND type = 'fiatBalance'; - ` - return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.deviceId]) + const detailB = notifierUtils.buildDetail({ deviceId: rec.deviceId }) + const sql = `UPDATE devices SET cassette1=$1, cassette2=$2 WHERE device_id=$3;` + return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.deviceId]).then(() => notifierQueries.invalidateNotification(detailB, 'fiatBalance')) } function emptyCashInBills (rec) { - const sql = 'update devices set cashbox=0 where device_id=$1' + const sql = 'UPDATE devices SET cashbox=0 WHERE device_id=$1' return db.none(sql, [rec.deviceId]) } @@ -145,3 +142,5 @@ function setMachine (rec) { default: throw new Error('No such action: ' + rec.action) } } + +module.exports = { getMachineName, getMachines, getMachine, getMachineNames, setMachine } diff --git a/lib/new-admin/graphql/schema.js b/lib/new-admin/graphql/schema.js index 32250765..c363faff 100644 --- a/lib/new-admin/graphql/schema.js +++ b/lib/new-admin/graphql/schema.js @@ -12,8 +12,9 @@ const logs = require('../../logs') const settingsLoader = require('../../new-settings-loader') // const tokenManager = require('../../token-manager') const blacklist = require('../../blacklist') -const machineEventsByIdBatch = require("../../postgresql_interface").machineEventsByIdBatch +const machineEventsByIdBatch = require('../../postgresql_interface').machineEventsByIdBatch const promoCodeManager = require('../../promo-codes') +const notifierQueries = require('../../notifier/queries') const serverVersion = require('../../../package.json').version const transactions = require('../transactions') @@ -247,6 +248,17 @@ const typeDefs = gql` rate: Float } + type Notification { + id: ID! + deviceName: String + type: String + detail: JSON + message: String + created: Date + read: Boolean + valid: Boolean + } + type Query { countries: [Country] currencies: [Currency] @@ -279,6 +291,8 @@ const typeDefs = gql` promoCodes: [PromoCode] cryptoRates: JSONObject fiatRates: [Rate] + notifications: [Notification] + hasUnreadNotifications: Boolean } type SupportLogsResponse { @@ -312,6 +326,8 @@ const typeDefs = gql` insertBlacklistRow(cryptoCode: String!, address: String!): Blacklist createPromoCode(code: String!, discount: Int!): PromoCode deletePromoCode(codeId: ID!): PromoCode + clearNotification(id: ID!): Notification + clearAllNotifications: Notification } ` @@ -344,9 +360,9 @@ const resolvers = { customers: () => customers.getCustomersList(), customer: (...[, { customerId }]) => customers.getCustomerById(customerId), funding: () => funding.getFunding(), - machineLogs: (...[, { deviceId, from, until, limit, offset }]) => + machineLogs: (...[, { 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), serverVersion: () => serverVersion, uptime: () => supervisor.getAllProcessInfo(), @@ -373,7 +389,9 @@ const resolvers = { } }) }), - fiatRates: () => forex.getFiatRates() + fiatRates: () => forex.getFiatRates(), + notifications: () => notifierQueries.getNotifications(), + hasUnreadNotifications: () => notifierQueries.hasUnreadNotifications() }, Mutation: { machineAction: (...[, { deviceId, action, cashbox, cassette1, cassette2, newName }]) => machineAction({ deviceId, action, cashbox, cassette1, cassette2, newName }), @@ -397,7 +415,9 @@ const resolvers = { blacklist.insertIntoBlacklist(cryptoCode, address), // revokeToken: (...[, { token }]) => tokenManager.revokeToken(token) createPromoCode: (...[, { code, discount }]) => promoCodeManager.createPromoCode(code, discount), - deletePromoCode: (...[, { codeId }]) => promoCodeManager.deletePromoCode(codeId) + deletePromoCode: (...[, { codeId }]) => promoCodeManager.deletePromoCode(codeId), + clearNotification: (...[, { id }]) => notifierQueries.markAsRead(id), + clearAllNotifications: () => notifierQueries.markAllAsRead() } } diff --git a/lib/new-settings-loader.js b/lib/new-settings-loader.js index 1f88ce8b..916c6c5b 100644 --- a/lib/new-settings-loader.js +++ b/lib/new-settings-loader.js @@ -44,18 +44,6 @@ const configSql = 'insert into user_config (type, data, valid, schema_version) v function saveConfig (config) { return loadLatestConfigOrNone() .then(currentConfig => { - if(config.notifications_cryptoHighBalance || config.notifications_cryptoLowBalance) { - clearCryptoBalanceNotifications(currentConfig, config, false) - } - if(config.notifications_cryptoBalanceOverrides) { - clearCryptoBalanceNotifications(currentConfig.notifications_cryptoBalanceOverrides, config.notifications_cryptoBalanceOverrides, true) - } - if(config.notifications_fiatBalanceCassette1 || config.notifications_fiatBalanceCassette2) { - clearCassetteNotifications(currentConfig, config, false) - } - if(config.notifications_fiatBalanceOverrides) { - clearCassetteNotifications(currentConfig.notifications_fiatBalanceOverrides, config.notifications_fiatBalanceOverrides, true) - } const newConfig = _.assign(currentConfig, config) return db.none(configSql, ['config', { config: newConfig }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION]) }) diff --git a/lib/notifier.js b/lib/notifier.js deleted file mode 100644 index 96941bea..00000000 --- a/lib/notifier.js +++ /dev/null @@ -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 -} diff --git a/lib/notifier/codes.js b/lib/notifier/codes.js index 7211b802..8e8a7cc4 100644 --- a/lib/notifier/codes.js +++ b/lib/notifier/codes.js @@ -20,6 +20,14 @@ const NETWORK_DOWN_TIME = 1 * T.minute const STALE_STATE = 7 * T.minute const ALERT_SEND_INTERVAL = T.hour +const NOTIFICATION_TYPES = { + HIGH_VALUE_TX: 'highValueTransaction', + FIAT_BALANCE: 'fiatBalance', + CRYPTO_BALANCE: 'cryptoBalance', + COMPLIANCE: 'compliance', + ERROR: 'error' +} + module.exports = { PING, STALE, @@ -30,5 +38,6 @@ module.exports = { CODES_DISPLAY, NETWORK_DOWN_TIME, STALE_STATE, - ALERT_SEND_INTERVAL + ALERT_SEND_INTERVAL, + NOTIFICATION_TYPES } diff --git a/lib/notifier/email.js b/lib/notifier/email.js index 7790127b..289180c0 100644 --- a/lib/notifier/email.js +++ b/lib/notifier/email.js @@ -1,5 +1,5 @@ const _ = require('lodash/fp') -const utils = require("./utils") +const utils = require('./utils') const email = require('../email') @@ -12,61 +12,47 @@ const { LOW_CASH_OUT } = require('./codes') -function alertSubject(alertRec, config) { +function alertSubject (alertRec, config) { let alerts = [] if (config.balance) { alerts = _.concat(alerts, alertRec.general) } - _.keys(alertRec.devices).forEach(function (device) { - if (config.balance) { - alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts) - } - - if (config.errors) { - alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts) - } - }) + _.forEach(device => { + alerts = _.concat(alerts, utils.deviceAlerts(config, alertRec, device)) + }, _.keys(alertRec.devices)) if (alerts.length === 0) return null - const alertTypes = _.map(codeDisplay, _.uniq(_.map('code', alerts))).sort() + const alertTypes = _.flow(_.map('code'), _.uniq, _.map(utils.codeDisplay), _.sortBy(o => o))(alerts) return '[Lamassu] Errors reported: ' + alertTypes.join(', ') } -function printEmailAlerts(alertRec, config) { +function printEmailAlerts (alertRec, config) { let body = 'Errors were reported by your Lamassu Machines.\n' if (config.balance && alertRec.general.length !== 0) { - body = body + '\nGeneral errors:\n' - body = body + emailAlerts(alertRec.general) + '\n' + body += '\nGeneral errors:\n' + body += emailAlerts(alertRec.general) + '\n' } - _.keys(alertRec.devices).forEach(function (device) { + _.forEach(device => { const deviceName = alertRec.deviceNames[device] - body = body + '\nErrors for ' + deviceName + ':\n' + body += '\nErrors for ' + deviceName + ':\n' - let alerts = [] - if (config.balance) { - alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts) - } - - if (config.errors) { - alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts) - } - - body = body + emailAlerts(alerts) - }) + const alerts = utils.deviceAlerts(config, alertRec, device) + body += emailAlerts(alerts) + }, _.keys(alertRec.devices)) return body } -function emailAlerts(alerts) { - return alerts.map(emailAlert).join('\n') + '\n' +function emailAlerts (alerts) { + return _.join('\n', _.map(emailAlert, alerts)) + '\n' } -function emailAlert(alert) { +function emailAlert (alert) { switch (alert.code) { case PING: if (alert.age) { @@ -98,5 +84,4 @@ function emailAlert(alert) { const sendMessage = email.sendMessage - module.exports = { alertSubject, printEmailAlerts, sendMessage } diff --git a/lib/notifier/index.js b/lib/notifier/index.js index 4484d29f..0466b4dd 100644 --- a/lib/notifier/index.js +++ b/lib/notifier/index.js @@ -11,8 +11,15 @@ const utils = require('./utils') const emailFuncs = require('./email') const smsFuncs = require('./sms') const { STALE, STALE_STATE, PING } = require('./codes') +const { NOTIFICATION_TYPES: { + HIGH_VALUE_TX, + FIAT_BALANCE, + CRYPTO_BALANCE, + COMPLIANCE, + ERROR } +} = require('./codes') -function buildMessage(alerts, notifications) { +function buildMessage (alerts, notifications) { const smsEnabled = utils.isActive(notifications.sms) const emailEnabled = utils.isActive(notifications.email) @@ -34,7 +41,7 @@ function buildMessage(alerts, notifications) { return rec } -function checkNotification(plugins) { +function checkNotification (plugins) { const notifications = plugins.getNotificationConfig() const smsEnabled = utils.isActive(notifications.sms) const emailEnabled = utils.isActive(notifications.email) @@ -50,28 +57,25 @@ function checkNotification(plugins) { ) if (!currentAlertFingerprint) { const inAlert = !!utils.getAlertFingerprint() - // (fingerprint = null, lastAlertTime = null) + // variables for setAlertFingerprint: (fingerprint = null, lastAlertTime = null) utils.setAlertFingerprint(null, null) - if (inAlert) { - return utils.sendNoAlerts(plugins, smsEnabled, emailEnabled) - } - } - if (utils.shouldNotAlert(currentAlertFingerprint)) { - return + if (inAlert) return utils.sendNoAlerts(plugins, smsEnabled, emailEnabled) } + if (utils.shouldNotAlert(currentAlertFingerprint)) return const message = buildMessage(alerts, notifications) utils.setAlertFingerprint(currentAlertFingerprint, Date.now()) return plugins.sendMessage(message) }) .then(results => { - if (results && results.length > 0) + if (results && results.length > 0) { logger.debug('Successfully sent alerts') + } }) .catch(logger.error) } -function getAlerts(plugins) { +function getAlerts (plugins) { return Promise.all([ plugins.checkBalances(), queries.machineEvents(), @@ -82,10 +86,10 @@ function getAlerts(plugins) { }) } -function buildAlerts(pings, balances, events, devices) { +function buildAlerts (pings, balances, events, devices) { const alerts = { devices: {}, deviceNames: {} } alerts.general = _.filter(r => !r.deviceId, balances) - devices.forEach(function (device) { + _.forEach(device => { const deviceId = device.deviceId const deviceName = device.name const deviceEvents = events.filter(function (eventRow) { @@ -94,83 +98,78 @@ function buildAlerts(pings, balances, events, devices) { const ping = pings[deviceId] || [] const stuckScreen = checkStuckScreen(deviceEvents, deviceName) - if (!alerts.devices[deviceId]) alerts.devices[deviceId] = {} - alerts.devices[deviceId].balanceAlerts = _.filter( + alerts.devices = _.set([deviceId, 'balanceAlerts'], _.filter( ['deviceId', deviceId], balances - ) + ), alerts.devices) alerts.devices[deviceId].deviceAlerts = _.isEmpty(ping) ? stuckScreen : ping alerts.deviceNames[deviceId] = deviceName - }) + }, devices) + return alerts } -function checkPings(devices) { +function checkPings (devices) { const deviceIds = _.map('deviceId', devices) const pings = _.map(utils.checkPing, devices) return _.zipObject(deviceIds)(pings) } -function checkStuckScreen(deviceEvents, machineName) { +function checkStuckScreen (deviceEvents, machineName) { const sortedEvents = _.sortBy( utils.getDeviceTime, _.map(utils.parseEventNote, deviceEvents) ) const lastEvent = _.last(sortedEvents) - if (!lastEvent) { - return [] - } + if (!lastEvent) return [] const state = lastEvent.note.state const isIdle = lastEvent.note.isIdle - if (isIdle) { - return [] - } + if (isIdle) return [] const age = Math.floor(lastEvent.age) - if (age > STALE_STATE) { - return [{ code: STALE, state, age, machineName }] - } + if (age > STALE_STATE) return [{ code: STALE, state, age, machineName }] return [] } -async function transactionNotify (tx, rec) { - const settings = await settingsLoader.loadLatest() - const notifSettings = configManager.getGlobalNotifications(settings.config) - const highValueTx = tx.fiat.gt(notifSettings.highValueTransaction || Infinity) - const isCashOut = tx.direction === 'cashOut' - - // high value tx on database - if(highValueTx && tx.direction === 'cashIn' || highValueTx && tx.direction === 'cashOut' && rec.isRedemption) { - queries.addHighValueTx(tx) - } - - // alert through sms or email any transaction or high value transaction, if SMS || email alerts are enabled - const cashOutConfig = configManager.getCashOut(tx.deviceId, settings.config) - const zeroConfLimit = cashOutConfig.zeroConfLimit - const zeroConf = isCashOut && tx.fiat.lte(zeroConfLimit) - const notificationsEnabled = notifSettings.sms.transactions || notifSettings.email.transactions - const customerPromise = tx.customerId ? customers.getById(tx.customerId) : Promise.resolve({}) - - 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) +function transactionNotify (tx, rec) { + return settingsLoader.loadLatest().then(settings => { + const notifSettings = configManager.getGlobalNotifications(settings.config) + const highValueTx = tx.fiat.gt(notifSettings.highValueTransaction || Infinity) + const isCashOut = tx.direction === 'cashOut' + // high value tx on database + if (highValueTx && (tx.direction === 'cashIn' || (tx.direction === 'cashOut' && rec.isRedemption))) { + const direction = tx.direction === 'cashOut' ? 'cash-out' : 'cash-in' + const message = `${tx.fiat} ${tx.fiatCode} ${direction} transaction` + const detailB = utils.buildDetail({ deviceId: tx.deviceId, direction, fiat: tx.fiat, fiatCode: tx.fiatCode, cryptoAddress: tx.toAddress }) + queries.addNotification(HIGH_VALUE_TX, message, detailB) + } - return Promise.all([ - machineLoader.getMachineName(tx.deviceId), - customerPromise - ]) - .then(([machineName, customer]) => { - return utils.buildTransactionMessage(tx, rec, highValueTx, machineName, customer) + // 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([ + machineLoader.getMachineName(tx.deviceId), + customerPromise + ]).then(([machineName, customer]) => { + return utils.buildTransactionMessage(tx, rec, highValueTx, machineName, customer) + }).then(([msg, highValueTx]) => sendTransactionMessage(msg, highValueTx)) }) - .then(([msg, highValueTx]) => sendTransactionMessage(msg, highValueTx)) } -function sendRedemptionMessage(txId, error) { +function sendRedemptionMessage (txId, error) { const subject = `Here's an update on transaction ${txId}` const body = error ? `Error: ${error}` @@ -188,218 +187,173 @@ function sendRedemptionMessage(txId, error) { return sendTransactionMessage(rec) } -async function sendTransactionMessage(rec, isHighValueTx) { - const settings = await settingsLoader.loadLatest() - const notifications = configManager.getGlobalNotifications(settings.config) +function sendTransactionMessage (rec, isHighValueTx) { + return settingsLoader.loadLatest().then(settings => { + const notifications = configManager.getGlobalNotifications(settings.config) - let promises = [] + const promises = [] - const emailActive = - notifications.email.active && - (notifications.email.transactions || isHighValueTx) - if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec)) + const emailActive = + notifications.email.active && + (notifications.email.transactions || isHighValueTx) + if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec)) - const smsActive = - notifications.sms.active && - (notifications.sms.transactions || isHighValueTx) - if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec)) + const smsActive = + notifications.sms.active && + (notifications.sms.transactions || isHighValueTx) + if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec)) - return Promise.all(promises) -} - -const cashCassettesNotify = (cassettes, deviceId) => { - settingsLoader.loadLatest() - .then(settings => - [ - configManager.getNotifications(null, deviceId, settings.config), - configManager.getCashOut(deviceId,settings.config).active - ]) - .then(([notifications, cashOutEnabled]) => { - const cassette1Count = cassettes.cassette1 - const cassette2Count = cassettes.cassette2 - const cassette1Threshold = notifications.fiatBalanceCassette1 - const cassette2Threshold = notifications.fiatBalanceCassette2 - - if(cashOutEnabled) { - // we only want to add this notification if there isn't one already set and unread in the database - Promise.all([queries.getUnreadCassetteNotifications(1, deviceId), queries.getUnreadCassetteNotifications(2, deviceId)]).then(res => { - if(res[0].length === 0 && cassette1Count < cassette1Threshold) { - console.log("Adding fiatBalance alert for cashbox 1 in database - count & threshold: ", cassette1Count, cassette1Threshold ) - return queries.addCashCassetteWarning(1, deviceId) - } - if(res[1].length === 0 && cassette2Count < cassette2Threshold) { - console.log("Adding fiatBalance alert for cashbox 2 in database - count & threshold: ", cassette2Count, cassette2Threshold ) - return queries.addCashCassetteWarning(2, deviceId) - } - }) - } + return Promise.all(promises) }) } +const clearOldCryptoNotifications = balances => { + return queries.getAllValidNotifications(CRYPTO_BALANCE).then(res => { + const filterByBalance = _.filter(notification => { + const { cryptoCode, code } = notification.detail + return !_.find(balance => balance.cryptoCode === cryptoCode && balance.code === code)(balances) + }) + const indexesToInvalidate = _.compose(_.map('id'), filterByBalance)(res) -/* - Notes for new "valid" column on notifications table: - - - We only want to add a new notification if it is present in the high or low warning consts. - - Before we add the notification we need to see if there is no "valid" notification in the database. - - Since the poller runs every few seconds, if the user marks it as read, this code would add a new notification - immediately. This new column helps us decide if a new notification should be added. - - "Valid" is defaulted to "true". When the cryptobalance goes over the low threshold or under the high threshold, - the notification will be marked as invalid. This will allow a new one to be sent. If the cryptobalance never goes - into the middle of the high and low thresholds, the old, "read" notification will still be relevant so we won't add - a new one. -*/ - -const clearOldCryptoNotifications = (balances) => { - // get valid crypto notifications from DB - // if that notification doesn't exist in balances, then make it invalid on the DB - queries.getAllValidNotifications('cryptoBalance').then(res => { - const notifications = _.map(it => { - return { - cryptoCode: it.detail.split('_')[0], - code: it.detail.split('_').splice(1).join('_') - } + const notInvalidated = _.filter(notification => { + return !_.find(id => notification.id === id)(indexesToInvalidate) }, res) - _.forEach(notification => { - const idx = _.findIndex(balance => { - return balance.code === notification.code && balance.cryptoCode === notification.cryptoCode - }, balances) + return (indexesToInvalidate.length ? queries.batchInvalidate(indexesToInvalidate) : Promise.resolve()).then(() => notInvalidated) + }) +} - if(idx !== -1) { - return - } - // if the notification doesn't exist in the new balances object, then it is outdated and is not valid anymore - return queries.invalidateNotification(notification.id) +const cryptoBalancesNotify = (cryptoWarnings) => { + return clearOldCryptoNotifications(cryptoWarnings).then(notInvalidated => { + return cryptoWarnings.forEach(balance => { + // if notification exists in DB and wasnt invalidated then don't add a duplicate + if (_.find(o => { + const { code, cryptoCode } = o.detail + return code === balance.code && cryptoCode === balance.cryptoCode + }, notInvalidated)) return + + const fiat = utils.formatCurrency(balance.fiatBalance.balance, balance.fiatCode) + const message = `${balance.code === 'HIGH_CRYPTO_BALANCE' ? 'High' : 'Low'} balance in ${balance.cryptoCode} [${fiat}]` + const detailB = utils.buildDetail({ cryptoCode: balance.cryptoCode, code: balance.code }) + return queries.addNotification(CRYPTO_BALANCE, message, detailB) + }) + }) +} + +const clearOldFiatNotifications = (balances) => { + return queries.getAllValidNotifications(FIAT_BALANCE).then(notifications => { + const filterByBalance = _.filter(notification => { + const { cassette, deviceId } = notification.detail + return !_.find(balance => balance.cassette === cassette && balance.deviceId === deviceId)(balances) + }) + const indexesToInvalidate = _.compose(_.map('id'), filterByBalance)(notifications) + const notInvalidated = _.filter(notification => { + return !_.find(id => notification.id === id)(indexesToInvalidate) }, notifications) + return (indexesToInvalidate.length ? queries.batchInvalidate(indexesToInvalidate) : Promise.resolve()).then(() => notInvalidated) + }) +} + +const fiatBalancesNotify = (fiatWarnings) => { + return clearOldFiatNotifications(fiatWarnings).then(notInvalidated => { + return fiatWarnings.forEach(balance => { + if (_.find(o => { + const { cassette, deviceId } = o.detail + return cassette === balance.cassette && deviceId === balance.deviceId + }, notInvalidated)) return + const message = `Cash-out cassette ${balance.cassette} almost empty!` + const detailB = utils.buildDetail({ deviceId: balance.deviceId, cassette: balance.cassette }) + return queries.addNotification(FIAT_BALANCE, message, detailB) + }) }) } const balancesNotify = (balances) => { - const highFilter = o => o.code === 'HIGH_CRYPTO_BALANCE' - const lowFilter = o => o.code === 'LOW_CRYPTO_BALANCE' - const highWarnings = _.filter(highFilter, balances) - const lowWarnings = _.filter(lowFilter, balances) - - clearOldCryptoNotifications(balances) - - highWarnings.forEach(warning => { - queries.getValidNotifications('cryptoBalance', `${warning.cryptoCode}_${warning.code}`).then(res => { - if (res.length > 0) { - return Promise.resolve() - } - console.log("Adding high balance alert for " + warning.cryptoCode + " - " + warning.fiatBalance.balance) - const balance = utils.formatCurrency(warning.fiatBalance.balance, warning.fiatCode) - return queries.addCryptoBalanceWarning(`${warning.cryptoCode}_${warning.code}`, `High balance in ${warning.cryptoCode} [${balance}]`) - }) - }) - lowWarnings.forEach(warning => { - queries.getValidNotifications('cryptoBalance', `${warning.cryptoCode}_${warning.code}`).then(res => { - if (res.length > 0) { - return Promise.resolve() - } - console.log("Adding low balance alert for " + warning.cryptoCode + " - " + warning.fiatBalance.balance) - const balance = utils.formatCurrency(warning.fiatBalance.balance, warning.fiatCode) - return queries.addCryptoBalanceWarning(`${warning.cryptoCode}_${warning.code}`, `Low balance in ${warning.cryptoCode} [${balance}]`) - }) - }) + const cryptoFilter = o => o.code === 'HIGH_CRYPTO_BALANCE' || o.code === 'LOW_CRYPTO_BALANCE' + const fiatFilter = o => o.code === 'LOW_CASH_OUT' + const cryptoWarnings = _.filter(cryptoFilter, balances) + const fiatWarnings = _.filter(fiatFilter, balances) + return Promise.all([cryptoBalancesNotify(cryptoWarnings), fiatBalancesNotify(fiatWarnings)]).catch(console.error) } -const clearOldErrorNotifications = (alerts) => { - queries.getAllValidNotifications('error').then(res => { - _.forEach(notification => { - const idx = _.findIndex(alert => { - return alert.code === notification.detail.split('_')[0] && alert.deviceId === notification.device_id - }, alerts) - if(idx !== -1) { - return - } - // if the notification doesn't exist, then it is outdated and is not valid anymore - return queries.invalidateNotification(notification.id) - }, res) - }) +const clearOldErrorNotifications = alerts => { + return queries.getAllValidNotifications(ERROR) + .then(res => { + // for each valid notification in DB see if it exists in alerts + // if the notification doesn't exist in alerts, it is not valid anymore + const filterByAlert = _.filter(notification => { + const { code, deviceId } = notification.detail + return !_.find(alert => alert.code === code && alert.deviceId === deviceId)(alerts) + }) + const indexesToInvalidate = _.compose(_.map('id'), filterByAlert)(res) + if (!indexesToInvalidate.length) return Promise.resolve() + return queries.batchInvalidate(indexesToInvalidate) + }) + .catch(console.error) } const errorAlertsNotify = (alertRec) => { - let alerts = [] - _.keys(alertRec.devices).forEach(function (device) { - // embed device ID in each alert object inside the deviceAlerts array - alertRec.devices[device].deviceAlerts = _.map(alert => { - return {...alert, deviceId: device} - }, alertRec.devices[device].deviceAlerts) - // concat every array into one - alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts) - }) - - // now that we have all the alerts, we want to add PING and STALE alerts to the DB - // if there is a valid alert on the DB that doesn't exist on the new alerts array, - // that alert should be considered invalid - // after that, for the alerts array, we have to see if there is a valid alert of - // the sorts already on the DB - clearOldErrorNotifications(alerts) + const embedDeviceId = deviceId => _.assign({ deviceId }) + const mapToAlerts = _.map(it => _.map(embedDeviceId(it), alertRec.devices[it].deviceAlerts)) + const alerts = _.compose(_.flatten, mapToAlerts, _.keys)(alertRec.devices) - _.forEach(alert => { - switch(alert.code) { - case PING: - return queries.getValidNotifications('error', PING, alert.deviceId).then(res => { - if(res.length > 0) { - return Promise.resolve() - } - console.log("Adding PING alert on database for " + alert.machineName) - const message = `Machine down` - return queries.addErrorNotification(`${PING}_${alert.age ? alert.age : '-1'}`, message, alert.deviceId) - }) - case STALE: - return queries.getValidNotifications('error', STALE, alert.deviceId).then(res => { - if(res.length > 0) { - return Promise.resolve() - } - console.log("Adding STALE alert on database for " + alert.machineName) - const message = `Machine is stuck on ${alert.state} screen` - return queries.addErrorNotification(STALE, message, alert.deviceId) - }) - default: - return - } - }, alerts) + return clearOldErrorNotifications(alerts).then(() => { + _.forEach(alert => { + switch (alert.code) { + case PING: { + const detailB = utils.buildDetail({ code: PING, age: alert.age ? alert.age : -1, deviceId: alert.deviceId }) + return queries.getValidNotifications(ERROR, _.omit(['age'], detailB)).then(res => { + if (res.length > 0) return Promise.resolve() + const message = `Machine down` + return queries.addNotification(ERROR, message, detailB) + }) + } + case STALE: { + const detailB = utils.buildDetail({ code: STALE, deviceId: alert.deviceId }) + return queries.getValidNotifications(ERROR, detailB).then(res => { + if (res.length > 0) return Promise.resolve() + const message = `Machine is stuck on ${alert.state} screen` + return queries.addNotification(ERROR, message, detailB) + }) + } + } + }, alerts) + }).catch(console.error) } const blacklistNotify = (tx, isAddressReuse) => { - let detail = '' - let message = '' - if(isAddressReuse) { - detail = `${tx.cryptoCode}_REUSED_${tx.toAddress}` - message = `Blocked reused address: ${tx.cryptoCode} ${tx.toAddress.substr(0,10)}...` - } else { - detail = `${tx.cryptoCode}_BLOCKED_${tx.toAddress}` - message = `Blocked blacklisted address: ${tx.cryptoCode} ${tx.toAddress.substr(0,10)}...` - } + const code = isAddressReuse ? 'REUSED' : 'BLOCKED' + const name = isAddressReuse ? 'reused' : 'blacklisted' - return queries.addComplianceNotification(tx.deviceId, detail, message) + const detailB = utils.buildDetail({ cryptoCode: tx.cryptoCode, code, cryptoAddress: tx.toAddress }) + const message = `Blocked ${name} address: ${tx.cryptoCode} ${tx.toAddress.substr(0, 10)}...` + return queries.addNotification(COMPLIANCE, message, detailB) +} + +const clearBlacklistNotification = (cryptoCode, cryptoAddress) => { + return queries.clearBlacklistNotification(cryptoCode, cryptoAddress).catch(console.error) } const clearOldCustomerSuspendedNotifications = (customerId, deviceId) => { - const detail = `SUSPENDED_${customerId}` - return queries.invalidateNotification(null, detail, deviceId) + const detailB = utils.buildDetail({ code: 'SUSPENDED', customerId, deviceId }) + return queries.invalidateNotification(detailB, 'compliance') } -const customerComplianceNotify = (customer, deviceId, prefix, days = null) => { - // prefix can be "BLOCKED", "SUSPENDED", etc - const detail = `${prefix}_${customer.id}` +const customerComplianceNotify = (customer, deviceId, code, days = null) => { + // code for now can be "BLOCKED", "SUSPENDED" + const detailB = utils.buildDetail({ customerId: customer.id, code, deviceId }) const date = new Date() if (days) { date.setDate(date.getDate() + days) } - const message = prefix === "SUSPENDED" ? `Customer suspended until ${date.toLocaleString()}` : `Customer blocked` + const message = code === 'SUSPENDED' ? `Customer suspended until ${date.toLocaleString()}` : `Customer blocked` - // we have to clear every notification for this user where the suspension ended before the current date - clearOldCustomerSuspendedNotifications(customer.id, deviceId).then(() => { - return queries.getValidNotifications('compliance', detail, deviceId) - }).then(res => { - if (res.length > 0) { - return Promise.resolve() - } - return queries.addComplianceNotification(deviceId, detail, message) - }) + return clearOldCustomerSuspendedNotifications(customer.id, deviceId) + .then(() => queries.getValidNotifications(COMPLIANCE, detailB)) + .then(res => { + if (res.length > 0) return Promise.resolve() + return queries.addNotification(COMPLIANCE, message, detailB) + }) + .catch(console.error) } module.exports = { @@ -408,7 +362,7 @@ module.exports = { checkPings, checkStuckScreen, sendRedemptionMessage, - cashCassettesNotify, blacklistNotify, customerComplianceNotify, + clearBlacklistNotification } diff --git a/lib/notifier/queries.js b/lib/notifier/queries.js index 3b1fb1b6..76a4eeeb 100644 --- a/lib/notifier/queries.js +++ b/lib/notifier/queries.js @@ -1,6 +1,9 @@ +const { v4: uuidv4 } = require('uuid') +const pgp = require('pg-promise')() +const _ = require('lodash/fp') + const dbm = require('../postgresql_interface') const db = require('../db') -const { v4: uuidv4 } = require('uuid') // types of notifications able to be inserted into db: /* @@ -11,76 +14,68 @@ compliance - notifications related to warnings triggered by compliance settings error - notifications related to errors */ -const addHighValueTx = (tx) => { - const sql = `INSERT INTO notifications (id, type, device_id, message, created) values ($1, $2, $3, $4, CURRENT_TIMESTAMP)` - const direction = tx.direction === "cashOut" ? 'cash-out' : 'cash-in' - const message = `${tx.fiat} ${tx.fiatCode} ${direction} transaction` - return db.oneOrNone(sql, [uuidv4(), 'highValueTransaction', tx.deviceId, message]) -} - -const addCashCassetteWarning = (cassetteNumber, deviceId) => { - const sql = `INSERT INTO notifications (id, type, detail, device_id, message, created) values ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)` - const message = `Cash-out cassette ${cassetteNumber} almost empty!` - return db.oneOrNone(sql, [uuidv4(), 'fiatBalance', cassetteNumber, deviceId, message]) -} - -const getUnreadCassetteNotifications = (cassetteNumber, deviceId) => { - const sql = `SELECT * FROM notifications WHERE read = 'f' AND device_id = $1 AND TYPE = 'fiatBalance' AND detail = '$2'` - return db.any(sql, [deviceId, cassetteNumber]) -} - -const addCryptoBalanceWarning = (detail, message) => { - const sql = `INSERT INTO notifications (id, type, detail, message, created) values ($1, $2, $3, $4, CURRENT_TIMESTAMP)` - return db.oneOrNone(sql, [uuidv4(), 'cryptoBalance', detail, message]) +const addNotification = (type, message, detail) => { + const sql = `INSERT INTO notifications (id, type, message, detail) VALUES ($1, $2, $3, $4)` + return db.oneOrNone(sql, [uuidv4(), type, message, detail]) } const getAllValidNotifications = (type) => { - const sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't'` - return db.any(sql, [type]) + const sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't'` + return db.any(sql, [type]) } -const addErrorNotification = (detail, message, deviceId) => { - const sql = `INSERT INTO notifications (id, type, detail, device_id, message, created) values ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)` - return db.oneOrNone(sql, [uuidv4(), 'error', detail, deviceId, message]) +const invalidateNotification = (detail, type) => { + detail = _.omitBy(_.isEmpty, detail) + const sql = `UPDATE notifications SET valid = 'f', read = 't', modified = CURRENT_TIMESTAMP WHERE valid = 't' AND type = $1 AND detail::jsonb @> $2::jsonb` + return db.none(sql, [type, detail]) } -const getValidNotifications = (type, detail, deviceId = null) => { - let sql; - if(!deviceId) { - sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't' AND detail LIKE $2` - } - else { - sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't' AND detail LIKE $2 AND device_id = $3` - } - return db.any(sql, [type, `%${detail}%`, deviceId]) +const batchInvalidate = (ids) => { + const formattedIds = _.map(pgp.as.text, ids).join(',') + const sql = `UPDATE notifications SET valid = 'f', read = 't', modified = CURRENT_TIMESTAMP WHERE id IN ($1^)` + return db.none(sql, [formattedIds]) } -const invalidateNotification = (id, detail = null, deviceId = null) => { - let sql = '' - if(id) { - sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE valid = 't' AND id = $1` - } - else { - sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE valid = 't' AND detail LIKE $2` - sql = deviceId ? sql + ' AND device_id = $3' : sql - } - return db.none(sql, [id, `%${detail}%`, deviceId]) +const clearBlacklistNotification = (cryptoCode, cryptoAddress) => { + const sql = `UPDATE notifications SET valid = 'f', read = 't', modified = CURRENT_TIMESTAMP WHERE type = 'compliance' AND detail->>'cryptoCode' = $1 AND detail->>'cryptoAddress' = $2 AND (detail->>'code' = 'BLOCKED' OR detail->>'code' = 'REUSED')` + return db.none(sql, [cryptoCode, cryptoAddress]) } -const addComplianceNotification = (deviceId, detail, message) => { - const sql = `INSERT INTO notifications (id, type, detail, device_id, message, created) values ($1, 'compliance', $2, $3, $4, CURRENT_TIMESTAMP)` - return db.oneOrNone(sql, [uuidv4(), detail, deviceId, message]) +const getValidNotifications = (type, detail) => { + const sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't' AND detail @> $2` + return db.any(sql, [type, detail]) +} + +const getNotifications = () => { + const sql = `SELECT * FROM notifications ORDER BY created DESC` + return db.any(sql) +} + +const markAsRead = (id) => { + const sql = `UPDATE notifications SET read = 't', modified = CURRENT_TIMESTAMP WHERE id = $1` + return db.none(sql, [id]) +} + +const markAllAsRead = () => { + const sql = `UPDATE notifications SET read = 't'` + return db.none(sql) +} + +const hasUnreadNotifications = () => { + const sql = `SELECT EXISTS (SELECT 1 FROM notifications WHERE read = 'f' LIMIT 1)` + return db.oneOrNone(sql).then(res => res.exists) } module.exports = { - machineEvents: dbm.machineEvents, - addHighValueTx, - addCashCassetteWarning, - addCryptoBalanceWarning, - addErrorNotification, - getUnreadCassetteNotifications, - getAllValidNotifications, - getValidNotifications, - invalidateNotification, - addComplianceNotification + machineEvents: dbm.machineEvents, + addNotification, + getAllValidNotifications, + invalidateNotification, + batchInvalidate, + clearBlacklistNotification, + getValidNotifications, + getNotifications, + markAsRead, + markAllAsRead, + hasUnreadNotifications } diff --git a/lib/notifier/sms.js b/lib/notifier/sms.js index 5c1e440b..65a64251 100644 --- a/lib/notifier/sms.js +++ b/lib/notifier/sms.js @@ -2,22 +2,16 @@ const _ = require('lodash/fp') const utils = require('./utils') const sms = require('../sms') -function printSmsAlerts(alertRec, config) { +function printSmsAlerts (alertRec, config) { let alerts = [] if (config.balance) { alerts = _.concat(alerts, alertRec.general) } - _.keys(alertRec.devices).forEach(function (device) { - if (config.balance) { - alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts) - } - - if (config.errors) { - alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts) - } - }) + _.forEach(device => { + alerts = _.concat(alerts, utils.deviceAlerts(config, alertRec, device)) + }, _.keys(alertRec.devices)) if (alerts.length === 0) return null @@ -27,12 +21,12 @@ function printSmsAlerts(alertRec, config) { const code = entry[0] const machineNames = _.filter( _.negate(_.isEmpty), - _.map('machineName', entry[1]), + _.map('machineName', entry[1]) ) const cryptoCodes = _.filter( _.negate(_.isEmpty), - _.map('cryptoCode', entry[1]), + _.map('cryptoCode', entry[1]) ) return { @@ -43,16 +37,11 @@ function printSmsAlerts(alertRec, config) { }, _.toPairs(alertsMap)) const mapByCodeDisplay = _.map(it => { - if(_.isEmpty(it.machineNames) && _.isEmpty(it.cryptoCodes)) { - return it.codeDisplay - } - if(_.isEmpty(it.machineNames)) { - return `${it.codeDisplay} (${it.cryptoCodes.join(', ')})` - } - else return `${it.codeDisplay} (${it.machineNames.join(', ')})` + if (_.isEmpty(it.machineNames) && _.isEmpty(it.cryptoCodes)) return it.codeDisplay + if (_.isEmpty(it.machineNames)) return `${it.codeDisplay} (${it.cryptoCodes.join(', ')})` + return `${it.codeDisplay} (${it.machineNames.join(', ')})` }) - const displayAlertTypes = _.compose( _.uniq, mapByCodeDisplay, diff --git a/lib/notifier/test/notifier.test.js b/lib/notifier/test/notifier.test.js index f7314af8..77d973d1 100644 --- a/lib/notifier/test/notifier.test.js +++ b/lib/notifier/test/notifier.test.js @@ -2,8 +2,6 @@ const BigNumber = require('../../../lib/bn') const notifier = require('..') const utils = require('../utils') -const queries = require("../queries") -const emailFuncs = require('../email') const smsFuncs = require('../sms') afterEach(() => { @@ -83,76 +81,79 @@ const notifSettings = { email_errors: false, sms_errors: true, sms_transactions: true, - highValueTransaction: Infinity, //this will make highValueTx always false + highValueTransaction: Infinity, // this will make highValueTx always false sms: { active: true, errors: true, - transactions: false // force early return + transactions: false // force early return }, email: { active: false, errors: false, - transactions: false // force early return + 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 } +describe('checkNotifications', () => { + test('Exits checkNotifications with Promise.resolve() if SMS and Email are disabled', async () => { + expect.assertions(1) + await expect( + notifier.checkNotification({ + getNotificationConfig: () => ({ + sms: { active: false, errors: false }, + email: { active: false, errors: false } + }) }) - }) - ).resolves.toBe(undefined) -}) - -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('Exits checkNotifications with Promise.resolve() if SMS and Email are disabled even if errors or balance are defined to something', async () => { + expect.assertions(1) + await expect( + notifier.checkNotification({ + getNotificationConfig: () => ({ + sms: { active: false, errors: true, balance: true }, + email: { active: false, errors: true, balance: true } + }) }) - }) - ).resolves.toBe(undefined) -}) - -test("Check Pings should return code PING for devices that haven't been pinged recently", () => { - expect( - notifier.checkPings([ - { - deviceId: - '7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4', - lastPing: '2020-11-16T13:11:03.169Z', - name: 'Abc123' - } - ]) - ).toMatchObject({ - '7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4': [ - { code: 'PING', machineName: 'Abc123' } - ] + ).resolves.toBe(undefined) }) }) -test('Checkpings returns empty array as the value for the id prop, if the lastPing is more recent than 60 seconds', () => { - expect( - notifier.checkPings([ - { - deviceId: - '7a531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4', - lastPing: new Date(), - name: 'Abc123' - } - ]) - ).toMatchObject({ - '7a531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4': [] +describe('checkPings', () => { + test("Check Pings should return code PING for devices that haven't been pinged recently", () => { + expect( + notifier.checkPings([ + { + deviceId: + '7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4', + lastPing: '2020-11-16T13:11:03.169Z', + name: 'Abc123' + } + ]) + ).toMatchObject({ + '7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4': [ + { code: 'PING', machineName: 'Abc123' } + ] + }) + }) + + test('Checkpings returns empty array as the value for the id prop, if the lastPing is more recent than 60 seconds', () => { + expect( + notifier.checkPings([ + { + deviceId: + '7a531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4', + lastPing: new Date(), + name: 'Abc123' + } + ]) + ).toMatchObject({ + '7a531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4': [] + }) }) }) - test('Check notification resolves to undefined if shouldNotAlert is called and is true', async () => { const mockShouldNotAlert = jest.spyOn(utils, 'shouldNotAlert') mockShouldNotAlert.mockReturnValue(true) @@ -190,25 +191,42 @@ test('If no alert fingerprint and inAlert is true, exits on call to sendNoAlerts expect(mockSendNoAlerts).toHaveBeenCalledTimes(1) }) -// vvv tests for checkstuckscreen... -test('checkStuckScreen returns [] when no events are found', () => { - expect(notifier.checkStuckScreen([], 'Abc123')).toEqual([]) -}) +describe('checkStuckScreen', () => { + test('checkStuckScreen returns [] when no events are found', () => { + expect(notifier.checkStuckScreen([], 'Abc123')).toEqual([]) + }) -test('checkStuckScreen returns [] if most recent event is idle', () => { - // device_time is what matters for the sorting of the events by recency - expect( - notifier.checkStuckScreen([ - { - id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2', - device_id: - 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05', - event_type: 'stateChange', - note: '{"state":"chooseCoin","isIdle":false}', - created: '2020-11-23T19:30:29.209Z', - device_time: '1999-11-23T19:30:29.177Z', - age: 157352628.123 - }, + test('checkStuckScreen returns [] if most recent event is idle', () => { + // device_time is what matters for the sorting of the events by recency + expect( + notifier.checkStuckScreen([ + { + id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2', + device_id: + 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05', + event_type: 'stateChange', + note: '{"state":"chooseCoin","isIdle":false}', + created: '2020-11-23T19:30:29.209Z', + device_time: '1999-11-23T19:30:29.177Z', + age: 157352628.123 + }, + { + id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2', + device_id: + 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05', + event_type: 'stateChange', + note: '{"state":"chooseCoin","isIdle":true}', + created: '2020-11-23T19:30:29.209Z', + device_time: '2020-11-23T19:30:29.177Z', + age: 157352628.123 + } + ]) + ).toEqual([]) + }) + + test('checkStuckScreen returns object array of length 1 with prop code: "STALE" if age > STALE_STATE', () => { + // there is an age 0 and an isIdle true in the first object but it will be below the second one in the sorting order and thus ignored + const result = notifier.checkStuckScreen([ { id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2', device_id: @@ -216,122 +234,106 @@ test('checkStuckScreen returns [] if most recent event is idle', () => { event_type: 'stateChange', note: '{"state":"chooseCoin","isIdle":true}', created: '2020-11-23T19:30:29.209Z', + device_time: '1999-11-23T19:30:29.177Z', + age: 0 + }, + { + id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2', + device_id: + 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05', + event_type: 'stateChange', + note: '{"state":"chooseCoin","isIdle":false}', + created: '2020-11-23T19:30:29.209Z', device_time: '2020-11-23T19:30:29.177Z', age: 157352628.123 } ]) - ).toEqual([]) + expect(result[0]).toMatchObject({ code: 'STALE' }) + }) + + test('checkStuckScreen returns empty array if age < STALE_STATE', () => { + const STALE_STATE = require('../codes').STALE_STATE + const result1 = notifier.checkStuckScreen([ + { + id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2', + device_id: + 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05', + event_type: 'stateChange', + note: '{"state":"chooseCoin","isIdle":false}', + created: '2020-11-23T19:30:29.209Z', + device_time: '2020-11-23T19:30:29.177Z', + age: 0 + } + ]) + const result2 = notifier.checkStuckScreen([ + { + id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2', + device_id: + 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05', + event_type: 'stateChange', + note: '{"state":"chooseCoin","isIdle":false}', + created: '2020-11-23T19:30:29.209Z', + device_time: '2020-11-23T19:30:29.177Z', + age: STALE_STATE + } + ]) + expect(result1).toEqual([]) + expect(result2).toEqual([]) + }) }) -test('checkStuckScreen returns object array of length 1 with prop code: "STALE" if age > STALE_STATE', () => { - // there is an age 0 and an isIdle true in the first object but it will be below the second one in the sorting order and thus ignored - const result = notifier.checkStuckScreen([ - { - id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2', - device_id: - 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05', - event_type: 'stateChange', - note: '{"state":"chooseCoin","isIdle":true}', - created: '2020-11-23T19:30:29.209Z', - device_time: '1999-11-23T19:30:29.177Z', - age: 0 - }, - { - id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2', - device_id: - 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05', - event_type: 'stateChange', - note: '{"state":"chooseCoin","isIdle":false}', - created: '2020-11-23T19:30:29.209Z', - device_time: '2020-11-23T19:30:29.177Z', - age: 157352628.123 - } - ]) - expect(result[0]).toMatchObject({ code: 'STALE' }) -}) - -test('checkStuckScreen returns empty array if age < STALE_STATE', () => { - const STALE_STATE = require('../codes').STALE_STATE - const result1 = notifier.checkStuckScreen([ - { - id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2', - device_id: - 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05', - event_type: 'stateChange', - note: '{"state":"chooseCoin","isIdle":false}', - created: '2020-11-23T19:30:29.209Z', - device_time: '2020-11-23T19:30:29.177Z', - age: 0 - } - ]) - const result2 = notifier.checkStuckScreen([ - { - id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2', - device_id: - 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05', - event_type: 'stateChange', - note: '{"state":"chooseCoin","isIdle":false}', - created: '2020-11-23T19:30:29.209Z', - device_time: '2020-11-23T19:30:29.177Z', - age: STALE_STATE - } - ]) - expect(result1).toEqual([]) - expect(result2).toEqual([]) -}) - -test("calls sendRedemptionMessage if !zeroConf and rec.isRedemption", async () => { +test('calls sendRedemptionMessage if !zeroConf and rec.isRedemption', () => { const configManager = require('../../new-config-manager') const settingsLoader = require('../../new-settings-loader') - const loadLatest = jest.spyOn(settingsLoader, 'loadLatest') + 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({}) - getGlobalNotifications.mockReturnValue({... notifSettings, sms: { active: true, errors: true, transactions: true }}) + getCashOut.mockReturnValue({ zeroConfLimit: -Infinity }) + loadLatest.mockReturnValue(Promise.resolve({})) + getGlobalNotifications.mockReturnValue({ ...notifSettings, sms: { active: true, errors: true, transactions: true } }) - const response = await notifier.transactionNotify(tx, {isRedemption: true}) - - // this type of response implies sendRedemptionMessage was called - expect(response[0]).toMatchObject({ - sms: { - body: "Here's an update on transaction bec8d452-9ea2-4846-841b-55a9df8bbd00 - It was just dispensed successfully" - }, - email: { - subject: "Here's an update on transaction bec8d452-9ea2-4846-841b-55a9df8bbd00", - body: 'It was just dispensed successfully' - } + return notifier.transactionNotify(tx, { isRedemption: true }).then(response => { + // this type of response implies sendRedemptionMessage was called + expect(response[0]).toMatchObject({ + sms: { + body: "Here's an update on transaction bec8d452-9ea2-4846-841b-55a9df8bbd00 - It was just dispensed successfully" + }, + email: { + subject: "Here's an update on transaction bec8d452-9ea2-4846-841b-55a9df8bbd00", + body: 'It was just dispensed successfully' + } + }) }) }) -test("calls sendTransactionMessage if !zeroConf and !rec.isRedemption", async () => { +test('calls sendTransactionMessage if !zeroConf and !rec.isRedemption', async () => { const configManager = require('../../new-config-manager') const settingsLoader = require('../../new-settings-loader') const machineLoader = require('../../machine-loader') - const loadLatest = jest.spyOn(settingsLoader, 'loadLatest') + 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]) + jest.spyOn(smsFuncs, 'sendMessage').mockImplementation((_, rec) => ({ prop: rec })) + buildTransactionMessage.mockImplementation(() => ['mock message', false]) - getMachineName.mockReturnValue("mockMachineName") - getCashOut.mockReturnValue({zeroConfLimit: -Infinity}) - loadLatest.mockReturnValue({}) - getGlobalNotifications.mockReturnValue({... notifSettings, sms: { active: true, errors: true, transactions: true }}) + getMachineName.mockReturnValue('mockMachineName') + getCashOut.mockReturnValue({ zeroConfLimit: -Infinity }) + loadLatest.mockReturnValue(Promise.resolve({})) + getGlobalNotifications.mockReturnValue({ ...notifSettings, sms: { active: true, errors: true, transactions: true } }) - const response = await notifier.transactionNotify(tx, {isRedemption: false}) + const response = await notifier.transactionNotify(tx, { isRedemption: false }) - // If the return object is this, it means the code went through all the functions expected to go through if + // 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'}]) -}) \ No newline at end of file + expect(response).toEqual([{ prop: 'mock message' }]) +}) diff --git a/lib/notifier/test/utils.test.js b/lib/notifier/test/utils.test.js index 785d76ef..aee67776 100644 --- a/lib/notifier/test/utils.test.js +++ b/lib/notifier/test/utils.test.js @@ -26,75 +26,79 @@ const notifications = { email: { active: false, errors: false } } -test('Build alert fingerprint returns null if no sms or email alerts', () => { - expect( - utils.buildAlertFingerprint( - { - devices: {}, - deviceNames: {}, - general: [] +describe('buildAlertFingerprint', () => { + test('Build alert fingerprint returns null if no sms or email alerts', () => { + expect( + utils.buildAlertFingerprint( + { + devices: {}, + deviceNames: {}, + general: [] + }, + notifications + ) + ).toBe(null) + }) + + test('Build alert fingerprint returns null if sms and email are disabled', () => { + expect( + utils.buildAlertFingerprint(alertRec, { + sms: { active: false, errors: true }, + email: { active: false, errors: false } + }) + ).toBe(null) + }) + + test('Build alert fingerprint returns hash if email or [sms] are enabled and there are alerts in alertrec', () => { + expect( + typeof utils.buildAlertFingerprint(alertRec, { + sms: { active: true, errors: true }, + email: { active: false, errors: false } + }) + ).toBe('string') + }) + + test('Build alert fingerprint returns hash if [email] or sms are enabled and there are alerts in alertrec', () => { + expect( + typeof utils.buildAlertFingerprint(alertRec, { + sms: { active: false, errors: false }, + email: { active: true, errors: true } + }) + ).toBe('string') + }) +}) + +describe('sendNoAlerts', () => { + test('Send no alerts returns empty object with sms and email disabled', () => { + expect(utils.sendNoAlerts(plugins, false, false)).toEqual({}) + }) + + test('Send no alerts returns object with sms prop with sms only enabled', () => { + expect(utils.sendNoAlerts(plugins, true, false)).toEqual({ + sms: { + body: '[Lamassu] All clear' + } + }) + }) + + test('Send no alerts returns object with sms and email prop with both enabled', () => { + expect(utils.sendNoAlerts(plugins, true, true)).toEqual({ + email: { + body: 'No errors are reported for your machines.', + subject: '[Lamassu] All clear' }, - notifications - ) - ).toBe(null) -}) - -test('Build alert fingerprint returns null if sms and email are disabled', () => { - expect( - utils.buildAlertFingerprint(alertRec, { - sms: { active: false, errors: true }, - email: { active: false, errors: false } + sms: { + body: '[Lamassu] All clear' + } }) - ).toBe(null) -}) + }) -test('Build alert fingerprint returns hash if email or [sms] are enabled and there are alerts in alertrec', () => { - expect( - typeof utils.buildAlertFingerprint(alertRec, { - sms: { active: true, errors: true }, - email: { active: false, errors: false } + test('Send no alerts returns object with email prop if only email is enabled', () => { + expect(utils.sendNoAlerts(plugins, false, true)).toEqual({ + email: { + body: 'No errors are reported for your machines.', + subject: '[Lamassu] All clear' + } }) - ).toBe('string') -}) - -test('Build alert fingerprint returns hash if [email] or sms are enabled and there are alerts in alertrec', () => { - expect( - typeof utils.buildAlertFingerprint(alertRec, { - sms: { active: false, errors: false }, - email: { active: true, errors: true } - }) - ).toBe('string') -}) - -test('Send no alerts returns empty object with sms and email disabled', () => { - expect(utils.sendNoAlerts(plugins, false, false)).toEqual({}) -}) - -test('Send no alerts returns object with sms prop with sms only enabled', () => { - expect(utils.sendNoAlerts(plugins, true, false)).toEqual({ - sms: { - body: '[Lamassu] All clear' - } - }) -}) - -test('Send no alerts returns object with sms and email prop with both enabled', () => { - expect(utils.sendNoAlerts(plugins, true, true)).toEqual({ - email: { - body: 'No errors are reported for your machines.', - subject: '[Lamassu] All clear' - }, - sms: { - body: '[Lamassu] All clear' - } - }) -}) - -test('Send no alerts returns object with email prop if only email is enabled', () => { - expect(utils.sendNoAlerts(plugins, false, true)).toEqual({ - email: { - body: 'No errors are reported for your machines.', - subject: '[Lamassu] All clear' - } }) }) diff --git a/lib/notifier/utils.js b/lib/notifier/utils.js index 3f2fc866..15465d7a 100644 --- a/lib/notifier/utils.js +++ b/lib/notifier/utils.js @@ -11,14 +11,26 @@ const { ALERT_SEND_INTERVAL } = require('./codes') -function parseEventNote(event) { +const DETAIL_TEMPLATE = { + deviceId: '', + cryptoCode: '', + code: '', + cassette: '', + age: '', + customerId: '', + cryptoAddress: '', + direction: '', + fiat: '', + fiatCode: '' +} + +function parseEventNote (event) { return _.set('note', JSON.parse(event.note), event) } -function checkPing(device) { - const age = +Date.now() - +new Date(device.lastPing) - if (age > NETWORK_DOWN_TIME) - return [{ code: PING, age, machineName: device.name }] +function checkPing (device) { + const age = Date.now() - (new Date(device.lastPing).getTime()) + if (age > NETWORK_DOWN_TIME) return [{ code: PING, age, machineName: device.name }] return [] } @@ -49,28 +61,7 @@ const shouldNotAlert = currentAlertFingerprint => { ) } -function getAlertTypes(alertRec, config) { - let alerts = [] - if (!isActive(config)) return alerts - - if (config.balance) { - alerts = _.concat(alerts, alertRec.general) - } - - _.keys(alertRec.devices).forEach(function (device) { - if (config.balance) { - alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts) - } - - if (config.errors) { - alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts) - } - }) - - return alerts -} - -function buildAlertFingerprint(alertRec, notifications) { +function buildAlertFingerprint (alertRec, notifications) { const sms = getAlertTypes(alertRec, notifications.sms) const email = getAlertTypes(alertRec, notifications.email) if (sms.length === 0 && email.length === 0) return null @@ -82,7 +73,7 @@ function buildAlertFingerprint(alertRec, notifications) { return crypto.createHash('sha256').update(subject).digest('hex') } -function sendNoAlerts(plugins, smsEnabled, emailEnabled) { +function sendNoAlerts (plugins, smsEnabled, emailEnabled) { const subject = '[Lamassu] All clear' let rec = {} @@ -117,8 +108,8 @@ const buildTransactionMessage = (tx, rec, highValueTx, machineName, customer) => status = !isCashOut ? 'Successful' : !rec.isRedemption - ? 'Successful & awaiting redemption' - : 'Successful & dispensed' + ? 'Successful & awaiting redemption' + : 'Successful & dispensed' } const body = ` @@ -145,7 +136,7 @@ const buildTransactionMessage = (tx, rec, highValueTx, machineName, customer) => }, highValueTx] } -function formatCurrency(num, code) { +function formatCurrency (num, code) { return numeral(num).format('0,0.00') + ' ' + code } @@ -153,6 +144,42 @@ function formatAge (age, settings) { return prettyMs(age, settings) } +function buildDetail (obj) { + // obj validation + const objKeys = _.keys(obj) + const detailKeys = _.keys(DETAIL_TEMPLATE) + if ((_.difference(objKeys, detailKeys)).length > 0) { + return Promise.reject(new Error('Error when building detail object: invalid properties')) + } + return { ...DETAIL_TEMPLATE, ...obj } +} + +function deviceAlerts (config, alertRec, device) { + let alerts = [] + if (config.balance) { + alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts) + } + + if (config.errors) { + alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts) + } + return alerts +} + +function getAlertTypes (alertRec, config) { + let alerts = [] + if (!isActive(config)) return alerts + + if (config.balance) { + alerts = _.concat(alerts, alertRec.general) + } + + _.forEach(device => { + alerts = _.concat(alerts, deviceAlerts(config, alertRec, device)) + }, _.keys(alertRec.devices)) + return alerts +} + module.exports = { codeDisplay, parseEventNote, @@ -167,5 +194,7 @@ module.exports = { sendNoAlerts, buildTransactionMessage, formatCurrency, - formatAge + formatAge, + buildDetail, + deviceAlerts } diff --git a/lib/plugins.js b/lib/plugins.js index a8e3af0d..bacff63d 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -23,7 +23,7 @@ const coinUtils = require('./coin-utils') const commissionMath = require('./commission-math') const promoCodes = require('./promo-codes') -const notifier = require('./notifier/index') +const notifier = require('./notifier') const mapValuesWithKey = _.mapValues.convert({ cap: false @@ -55,8 +55,7 @@ function plugins (settings, deviceId) { ? undefined : BN(1).add(BN(commissions.cashOut).div(100)) - if (Date.now() - rateRec.timestamp > STALE_TICKER) - return logger.warn('Stale rate for ' + cryptoCode) + if (Date.now() - rateRec.timestamp > STALE_TICKER) return logger.warn('Stale rate for ' + cryptoCode) const rate = rateRec.rates withCommission ? rates[cryptoCode] = { @@ -90,10 +89,8 @@ function plugins (settings, deviceId) { cryptoCodes.forEach((cryptoCode, i) => { const balanceRec = balanceRecs[i] - if (!balanceRec) - return logger.warn('No balance for ' + cryptoCode + ' yet') - if (Date.now() - balanceRec.timestamp > STALE_BALANCE) - return logger.warn('Stale balance for ' + cryptoCode) + if (!balanceRec) return logger.warn('No balance for ' + cryptoCode + ' yet') + if (Date.now() - balanceRec.timestamp > STALE_BALANCE) return logger.warn('Stale balance for ' + cryptoCode) balances[cryptoCode] = balanceRec.balance }) @@ -113,13 +110,10 @@ function plugins (settings, deviceId) { const sumTxs = (sum, tx) => { const bills = tx.bills const sameDenominations = a => a[0].denomination === a[1].denomination - const doDenominationsMatch = _.every( - sameDenominations, - _.zip(cassettes, bills) - ) + const doDenominationsMatch = _.every(sameDenominations, _.zip(cassettes, bills)) if (!doDenominationsMatch) { - throw new Error("Denominations don't add up, cassettes were changed.") + throw new Error('Denominations don\'t add up, cassettes were changed.') } return _.map(r => r[0] + r[1].provisioned, _.zip(sum, tx.bills)) @@ -162,41 +156,30 @@ function plugins (settings, deviceId) { ? argv.cassettes.split(',') : rec.counts - return Promise.all([ - dbm.cassetteCounts(deviceId), - cashOutHelper.redeemableTxs(deviceId, excludeTxId) - ]).then(([rec, _redeemableTxs]) => { - const redeemableTxs = _.reject( - _.matchesProperty('id', excludeTxId), - _redeemableTxs - ) + const cassettes = [ + { + denomination: parseInt(denominations[0], 10), + count: parseInt(counts[0], 10) + }, + { + denomination: parseInt(denominations[1], 10), + count: parseInt(counts[1], 10) + } + ] - const counts = argv.cassettes ? argv.cassettes.split(',') : rec.counts - - const cassettes = [ - { - denomination: parseInt(denominations[0], 10), - count: parseInt(counts[0], 10) - }, - { - denomination: parseInt(denominations[1], 10), - count: parseInt(counts[1], 10) + try { + return { + cassettes: computeAvailableCassettes(cassettes, redeemableTxs), + virtualCassettes + } + } catch (err) { + logger.error(err) + return { + cassettes, + virtualCassettes + } } - ] - - try { - return { - cassettes: computeAvailableCassettes(cassettes, redeemableTxs), - virtualCassettes - } - } catch (err) { - logger.error(err) - return { - cassettes, - virtualCassettes - } - } - }) + }) } function fetchCurrentConfigVersion () { @@ -206,23 +189,18 @@ function plugins (settings, deviceId) { order by id desc limit 1` - return db.one(sql, ['config']).then(row => row.id) + return db.one(sql, ['config']) + .then(row => row.id) } function mapCoinSettings (coinParams) { const cryptoCode = coinParams[0] const cryptoNetwork = coinParams[1] - const commissions = configManager.getCommissions( - cryptoCode, - deviceId, - settings.config - ) + const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config) const minimumTx = BN(commissions.minimumTx) const cashInFee = BN(commissions.fixedFee) const cashInCommission = BN(commissions.cashIn) - const cashOutCommission = _.isNumber(commissions.cashOut) - ? BN(commissions.cashOut) - : null + const cashOutCommission = _.isNumber(commissions.cashOut) ? BN(commissions.cashOut) : null const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode) return { @@ -236,25 +214,15 @@ function plugins (settings, deviceId) { } } - function pollQueries ( - serialNumber, - deviceTime, - deviceRec, - machineVersion, - machineModel - ) { + function pollQueries (serialNumber, deviceTime, deviceRec, machineVersion, machineModel) { const localeConfig = configManager.getLocale(deviceId, settings.config) const fiatCode = localeConfig.fiatCurrency const cryptoCodes = localeConfig.cryptoCurrencies - const tickerPromises = cryptoCodes.map(c => - ticker.getRates(settings, fiatCode, c) - ) + const tickerPromises = cryptoCodes.map(c => ticker.getRates(settings, fiatCode, c)) const balancePromises = cryptoCodes.map(c => fiatBalance(fiatCode, c)) - const testnetPromises = cryptoCodes.map(c => - wallet.cryptoNetwork(settings, c) - ) + const testnetPromises = cryptoCodes.map(c => wallet.cryptoNetwork(settings, c)) const pingPromise = recordPing(deviceTime, machineVersion, machineModel) const currentConfigVersionPromise = fetchCurrentConfigVersion() const currentAvailablePromoCodes = promoCodes.getNumberOfAvailablePromoCodes() @@ -289,12 +257,7 @@ function plugins (settings, deviceId) { } function sendCoins (tx) { - return wallet.sendCoins( - settings, - tx.toAddress, - tx.cryptoAtoms, - tx.cryptoCode - ) + return wallet.sendCoins(settings, tx.toAddress, tx.cryptoAtoms, tx.cryptoCode) } function recordPing (deviceTime, version, model) { @@ -305,18 +268,11 @@ function plugins (settings, deviceId) { } return Promise.all([ - db.none( - `insert into machine_pings(device_id, device_time) values($1, $2) - ON CONFLICT (device_id) DO UPDATE SET device_time = $2, updated = now()`, - [deviceId, deviceTime] - ), - db.none( - pgp.helpers.update(devices, null, 'devices') + - 'WHERE device_id = ${deviceId}', - { - deviceId - } - ) + db.none(`insert into machine_pings(device_id, device_time) values($1, $2) + ON CONFLICT (device_id) DO UPDATE SET device_time = $2, updated = now()`, [deviceId, deviceTime]), + db.none(pgp.helpers.update(devices, null, 'devices') + 'WHERE device_id = ${deviceId}', { + deviceId + }) ]) } @@ -348,37 +304,34 @@ function plugins (settings, deviceId) { } function fiatBalance (fiatCode, cryptoCode) { - const commissions = configManager.getCommissions( - cryptoCode, - deviceId, - settings.config - ) + const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config) return Promise.all([ ticker.getRates(settings, fiatCode, cryptoCode), wallet.balance(settings, cryptoCode) - ]).then(([rates, balanceRec]) => { - if (!rates || !balanceRec) return null + ]) + .then(([rates, balanceRec]) => { + if (!rates || !balanceRec) return null - const rawRate = rates.rates.ask - const cashInCommission = BN(1).minus(BN(commissions.cashIn).div(100)) - const balance = balanceRec.balance + const rawRate = rates.rates.ask + const cashInCommission = BN(1).minus(BN(commissions.cashIn).div(100)) + const balance = balanceRec.balance - if (!rawRate || !balance) return null + if (!rawRate || !balance) return null - const rate = rawRate.div(cashInCommission) + const rate = rawRate.div(cashInCommission) - const lowBalanceMargin = BN(1.03) + const lowBalanceMargin = BN(1.03) - const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode) - const unitScale = cryptoRec.unitScale - const shiftedRate = rate.shift(-unitScale) - const fiatTransferBalance = balance.mul(shiftedRate).div(lowBalanceMargin) + const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode) + const unitScale = cryptoRec.unitScale + const shiftedRate = rate.shift(-unitScale) + const fiatTransferBalance = balance.mul(shiftedRate).div(lowBalanceMargin) - return { - timestamp: balanceRec.timestamp, - balance: fiatTransferBalance.truncated().toString() - } - }) + return { + timestamp: balanceRec.timestamp, + balance: fiatTransferBalance.truncated().toString() + } + }) } function notifyConfirmation (tx) { @@ -393,12 +346,13 @@ function plugins (settings, deviceId) { } } - return sms.sendMessage(settings, rec).then(() => { - const sql = 'update cash_out_txs set notified=$1 where id=$2' - const values = [true, tx.id] + return sms.sendMessage(settings, rec) + .then(() => { + const sql = 'update cash_out_txs set notified=$1 where id=$2' + const values = [true, tx.id] - return db.none(sql, values) - }) + return db.none(sql, values) + }) } function notifyOperator (tx, rec) { @@ -407,17 +361,14 @@ function plugins (settings, deviceId) { } function clearOldLogs () { - return logs.clearOldLogs().catch(logger.error) + return logs.clearOldLogs() + .catch(logger.error) } function pong () { - return db - .none( - `UPDATE server_events SET created=now() WHERE event_type=$1; + return db.none(`UPDATE server_events SET created=now() WHERE event_type=$1; INSERT INTO server_events (event_type) SELECT $1 - WHERE NOT EXISTS (SELECT 1 FROM server_events WHERE event_type=$1);`, - ['ping'] - ) + WHERE NOT EXISTS (SELECT 1 FROM server_events WHERE event_type=$1);`, ['ping']) .catch(logger.error) } @@ -458,18 +409,15 @@ function plugins (settings, deviceId) { const marketTradesQueues = tradesQueues[market] if (!marketTradesQueues || marketTradesQueues.length === 0) return null - logger.debug( - '[%s] tradesQueues size: %d', - market, - marketTradesQueues.length - ) + logger.debug('[%s] tradesQueues size: %d', market, marketTradesQueues.length) logger.debug('[%s] tradesQueues head: %j', market, marketTradesQueues[0]) const t1 = Date.now() - const filtered = marketTradesQueues.filter(tradeEntry => { - return t1 - tradeEntry.timestamp < TRADE_TTL - }) + const filtered = marketTradesQueues + .filter(tradeEntry => { + return t1 - tradeEntry.timestamp < TRADE_TTL + }) const filteredCount = marketTradesQueues.length - filtered.length @@ -480,14 +428,10 @@ function plugins (settings, deviceId) { if (filtered.length === 0) return null - const cryptoAtoms = filtered.reduce( - (prev, current) => prev.plus(current.cryptoAtoms), - BN(0) - ) + const cryptoAtoms = filtered + .reduce((prev, current) => prev.plus(current.cryptoAtoms), BN(0)) - const timestamp = filtered - .map(r => r.timestamp) - .reduce((acc, r) => Math.max(acc, r), 0) + const timestamp = filtered.map(r => r.timestamp).reduce((acc, r) => Math.max(acc, r), 0) const consolidatedTrade = { fiatCode, @@ -503,15 +447,11 @@ function plugins (settings, deviceId) { } function executeTrades () { - return machineLoader - .getMachines() + return machineLoader.getMachines() .then(devices => { const deviceIds = devices.map(device => device.deviceId) const lists = deviceIds.map(deviceId => { - const localeConfig = configManager.getLocale( - deviceId, - settings.config - ) + const localeConfig = configManager.getLocale(deviceId, settings.config) const fiatCode = localeConfig.fiatCurrency const cryptoCodes = localeConfig.cryptoCurrencies @@ -521,9 +461,8 @@ function plugins (settings, deviceId) { })) }) - const tradesPromises = _.uniq(_.flatten(lists)).map(r => - executeTradesForMarket(settings, r.fiatCode, r.cryptoCode) - ) + const tradesPromises = _.uniq(_.flatten(lists)) + .map(r => executeTradesForMarket(settings, r.fiatCode, r.cryptoCode)) return Promise.all(tradesPromises) }) @@ -538,43 +477,41 @@ function plugins (settings, deviceId) { if (tradeEntry === null || tradeEntry.cryptoAtoms.eq(0)) return - return executeTradeForType(tradeEntry).catch(err => { - tradesQueues[market].push(tradeEntry) - if (err.name === 'orderTooSmall') return logger.debug(err.message) - logger.error(err) - }) + return executeTradeForType(tradeEntry) + .catch(err => { + tradesQueues[market].push(tradeEntry) + if (err.name === 'orderTooSmall') return logger.debug(err.message) + logger.error(err) + }) } function executeTradeForType (_tradeEntry) { - const expand = te => - _.assign(te, { - cryptoAtoms: te.cryptoAtoms.abs(), - type: te.cryptoAtoms.gte(0) ? 'buy' : 'sell' - }) + const expand = te => _.assign(te, { + cryptoAtoms: te.cryptoAtoms.abs(), + type: te.cryptoAtoms.gte(0) ? 'buy' : 'sell' + }) const tradeEntry = expand(_tradeEntry) const execute = tradeEntry.type === 'buy' ? exchange.buy : exchange.sell - return execute( - settings, - tradeEntry.cryptoAtoms, - tradeEntry.fiatCode, - tradeEntry.cryptoCode - ) + return execute(settings, tradeEntry.cryptoAtoms, tradeEntry.fiatCode, tradeEntry.cryptoCode) .then(() => recordTrade(tradeEntry)) .catch(err => { - return recordTrade(tradeEntry, err).then(() => { - throw err - }) + return recordTrade(tradeEntry, err) + .then(() => { + throw err + }) }) } function convertBigNumFields (obj) { - const convert = (value, key) => - _.includes(key, ['cryptoAtoms', 'fiat']) ? value.toString() : value + const convert = (value, key) => _.includes(key, ['cryptoAtoms', 'fiat']) + ? value.toString() + : value - const convertKey = key => - _.includes(key, ['cryptoAtoms', 'fiat']) ? key + '#' : key + const convertKey = key => _.includes(key, ['cryptoAtoms', 'fiat']) + ? key + '#' + : key return _.mapKeys(convertKey, mapValuesWithKey(convert, obj)) } @@ -604,10 +541,8 @@ function plugins (settings, deviceId) { const notifications = configManager.getGlobalNotifications(settings.config) let promises = [] - if (notifications.email.active && rec.email) - promises.push(email.sendMessage(settings, rec)) - if (notifications.sms.active && rec.sms) - promises.push(sms.sendMessage(settings, rec)) + if (notifications.email.active && rec.email) promises.push(email.sendMessage(settings, rec)) + if (notifications.sms.active && rec.sms) promises.push(sms.sendMessage(settings, rec)) return Promise.all(promises) } @@ -617,64 +552,53 @@ function plugins (settings, deviceId) { } function checkDeviceCashBalances (fiatCode, device) { - const cashOutConfig = configManager.getCashOut( - device.deviceId, - settings.config - ) + const cashOutConfig = configManager.getCashOut(device.deviceId, settings.config) const denomination1 = cashOutConfig.top const denomination2 = cashOutConfig.bottom const cashOutEnabled = cashOutConfig.active - const notifications = configManager.getNotifications( - null, - device.deviceId, - settings.config - ) + const notifications = configManager.getNotifications(null, device.deviceId, settings.config) const machineName = device.name - const cashInAlert = - device.cashbox > notifications.cashInAlertThreshold - ? { - code: 'CASH_BOX_FULL', - machineName, - deviceId: device.deviceId, - notes: device.cashbox - } - : null + const cashInAlert = device.cashbox > notifications.cashInAlertThreshold + ? { + code: 'CASH_BOX_FULL', + machineName, + deviceId: device.deviceId, + notes: device.cashbox + } + : null - const cassette1Alert = - cashOutEnabled && device.cassette1 < notifications.fiatBalanceCassette1 - ? { - code: 'LOW_CASH_OUT', - cassette: 1, - machineName, - deviceId: device.deviceId, - notes: device.cassette1, - denomination: denomination1, - fiatCode - } - : null + const cassette1Alert = cashOutEnabled && device.cassette1 < notifications.fiatBalanceCassette1 + ? { + code: 'LOW_CASH_OUT', + cassette: 1, + machineName, + deviceId: device.deviceId, + notes: device.cassette1, + denomination: denomination1, + fiatCode + } + : null - const cassette2Alert = - cashOutEnabled && device.cassette2 < notifications.fiatBalanceCassette2 - ? { - code: 'LOW_CASH_OUT', - cassette: 2, - machineName, - deviceId: device.deviceId, - notes: device.cassette2, - denomination: denomination2, - fiatCode - } - : null + const cassette2Alert = cashOutEnabled && device.cassette2 < notifications.fiatBalanceCassette2 + ? { + code: 'LOW_CASH_OUT', + cassette: 2, + machineName, + deviceId: device.deviceId, + notes: device.cassette2, + denomination: denomination2, + fiatCode + } + : null return _.compact([cashInAlert, cassette1Alert, cassette2Alert]) } function checkCryptoBalances (fiatCode, devices) { - const fiatBalancePromises = cryptoCodes => - _.map(c => fiatBalance(fiatCode, c), cryptoCodes) + const fiatBalancePromises = cryptoCodes => _.map(c => fiatBalance(fiatCode, c), cryptoCodes) const fetchCryptoCodes = _deviceId => { const localeConfig = configManager.getLocale(_deviceId, settings.config) @@ -685,23 +609,18 @@ function plugins (settings, deviceId) { const cryptoCodes = union(devices) const checkCryptoBalanceWithFiat = _.partial(checkCryptoBalance, [fiatCode]) - return Promise.all(fiatBalancePromises(cryptoCodes)).then(balances => - _.map(checkCryptoBalanceWithFiat, _.zip(cryptoCodes, balances)) - ) + return Promise.all(fiatBalancePromises(cryptoCodes)) + .then(balances => _.map(checkCryptoBalanceWithFiat, _.zip(cryptoCodes, balances))) } function checkCryptoBalance (fiatCode, rec) { const [cryptoCode, fiatBalance] = rec - if (!fiatBalance) return null - const notifications = configManager.getNotifications( - cryptoCode, - null, - settings.config - ) - const lowAlertThreshold = notifications.cryptoLowBalance - const highAlertThreshold = notifications.cryptoHighBalance + const notifications = configManager.getNotifications(cryptoCode, null, settings.config) + const override = _.find(override => override.cryptoCurrency === cryptoCode, settings.config.notifications_cryptoBalanceOverrides) + const lowAlertThreshold = override ? override.lowBalance : notifications.cryptoLowBalance + const highAlertThreshold = override ? override.highBalance : notifications.cryptoHighBalance const req = { cryptoCode, @@ -709,17 +628,13 @@ function plugins (settings, deviceId) { fiatCode } - if ( - _.isFinite(lowAlertThreshold) && - BN(fiatBalance.balance).lt(lowAlertThreshold) - ) + if (_.isFinite(lowAlertThreshold) && BN(fiatBalance.balance).lt(lowAlertThreshold)) { return _.set('code')('LOW_CRYPTO_BALANCE')(req) + } - if ( - _.isFinite(highAlertThreshold) && - BN(fiatBalance.balance).gt(highAlertThreshold) - ) + if (_.isFinite(highAlertThreshold) && BN(fiatBalance.balance).gt(highAlertThreshold)) { return _.set('code')('HIGH_CRYPTO_BALANCE')(req) + } return null } @@ -728,23 +643,24 @@ function plugins (settings, deviceId) { const localeConfig = configManager.getGlobalLocale(settings.config) const fiatCode = localeConfig.fiatCurrency - return machineLoader.getMachines().then(devices => { - return Promise.all([ - checkCryptoBalances(fiatCode, devices), - checkDevicesCashBalances(fiatCode, devices) - ]).then(_.flow(_.flattenDeep, _.compact)) - }) + return machineLoader.getMachines() + .then(devices => { + return Promise.all([ + checkCryptoBalances(fiatCode, devices), + checkDevicesCashBalances(fiatCode, devices) + ]) + .then(_.flow(_.flattenDeep, _.compact)) + }) } function randomCode () { - return BN(crypto.randomBytes(3).toString('hex'), 16) - .shift(-6) - .toFixed(6) - .slice(-6) + return BN(crypto.randomBytes(3).toString('hex'), 16).shift(-6).toFixed(6).slice(-6) } function getPhoneCode (phone) { - const code = argv.mockSms ? '123' : randomCode() + const code = argv.mockSms + ? '123' + : randomCode() const rec = { sms: { @@ -753,14 +669,14 @@ function plugins (settings, deviceId) { } } - return sms.sendMessage(settings, rec).then(() => code) + return sms.sendMessage(settings, rec) + .then(() => code) } function sweepHdRow (row) { const cryptoCode = row.crypto_code - return wallet - .sweep(settings, cryptoCode, row.hd_index) + return wallet.sweep(settings, cryptoCode, row.hd_index) .then(txHash => { if (txHash) { logger.debug('[%s] Swept address with tx: %s', cryptoCode, txHash) @@ -771,17 +687,14 @@ function plugins (settings, deviceId) { return db.none(sql, row.id) } }) - .catch(err => - logger.error('[%s] Sweep error: %s', cryptoCode, err.message) - ) + .catch(err => logger.error('[%s] Sweep error: %s', cryptoCode, err.message)) } function sweepHd () { const sql = `select id, crypto_code, hd_index from cash_out_txs where hd_index is not null and not swept and status in ('confirmed', 'instant')` - return db - .any(sql) + return db.any(sql) .then(rows => Promise.all(rows.map(sweepHdRow))) .catch(err => logger.error(err)) } @@ -795,15 +708,14 @@ function plugins (settings, deviceId) { const fiatCode = localeConfig.fiatCurrency const cryptoCodes = configManager.getAllCryptoCurrencies(settings.config) - const tickerPromises = cryptoCodes.map(c => - ticker.getRates(settings, fiatCode, c) - ) + const tickerPromises = cryptoCodes.map(c => ticker.getRates(settings, fiatCode, c)) return Promise.all(tickerPromises) } function getRates () { - return getRawRates().then(buildRates) + return getRawRates() + .then(buildRates) } return { diff --git a/lib/plugins/sms/mock-sms/mock-sms.js b/lib/plugins/sms/mock-sms/mock-sms.js index 19c43d07..952fca3c 100644 --- a/lib/plugins/sms/mock-sms/mock-sms.js +++ b/lib/plugins/sms/mock-sms/mock-sms.js @@ -2,7 +2,7 @@ const _ = require('lodash/fp') exports.NAME = 'MockSMS' -exports.sendMessage = function sendMessage(account, rec) { +exports.sendMessage = function sendMessage (account, rec) { console.log('Sending SMS: %j', rec) return new Promise((resolve, reject) => { if (_.endsWith('666', _.getOr(false, 'sms.toNumber', rec))) { diff --git a/lib/poller.js b/lib/poller.js index 7c90871b..e03bd689 100644 --- a/lib/poller.js +++ b/lib/poller.js @@ -1,7 +1,7 @@ const _ = require('lodash/fp') const plugins = require('./plugins') -const notifier = require('./notifier/index') +const notifier = require('./notifier') const T = require('./time') const logger = require('./logger') const cashOutTx = require('./cash-out/cash-out-tx') diff --git a/lib/routes.js b/lib/routes.js index f604473f..a2125197 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -28,7 +28,7 @@ const compliance = require('./compliance') const promoCodes = require('./promo-codes') const BN = require('./bn') const commissionMath = require('./commission-math') -const notifier = require('./notifier/index') +const notifier = require('./notifier') const version = require('../package.json').version @@ -324,7 +324,6 @@ function updateCustomer (req, res, next) { } function triggerSanctions (req, res, next) { - console.log("SANCTIONS TRIGGERED") const id = req.params.id customers.getById(id) diff --git a/migrations/1607009558538-create-notifications-table.js b/migrations/1607009558538-create-notifications-table.js index fda66b84..9dd89aa8 100644 --- a/migrations/1607009558538-create-notifications-table.js +++ b/migrations/1607009558538-create-notifications-table.js @@ -1,8 +1,6 @@ var db = require('./db') -function singleQuotify(item) { - return "'" + item + "'" -} +const singleQuotify = (item) => `'${item}'` var types = [ 'highValueTransaction', @@ -18,20 +16,17 @@ exports.up = function (next) { const sql = [ ` CREATE TYPE notification_type AS ENUM ${'(' + types + ')'}; - CREATE TABLE IF NOT EXISTS "notifications" ( + CREATE TABLE "notifications" ( "id" uuid NOT NULL PRIMARY KEY, "type" notification_type NOT NULL, - "detail" TEXT, - "device_id" TEXT, + "detail" JSONB, "message" TEXT NOT NULL, - "created" TIMESTAMP WITH TIME ZONE NOT NULL, + "created" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, "read" BOOLEAN NOT NULL DEFAULT 'false', "valid" BOOLEAN NOT NULL DEFAULT 'true', - CONSTRAINT fk_devices - FOREIGN KEY(device_id) - REFERENCES devices(device_id) - ON DELETE CASCADE + "modified" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); + CREATE INDEX ON notifications (valid); CREATE INDEX ON notifications (read);` ] diff --git a/new-lamassu-admin/src/components/NotificationCenter/NotificationCenter.js b/new-lamassu-admin/src/components/NotificationCenter/NotificationCenter.js new file mode 100644 index 00000000..5795e06d --- /dev/null +++ b/new-lamassu-admin/src/components/NotificationCenter/NotificationCenter.js @@ -0,0 +1,144 @@ +import { useQuery, useMutation } from '@apollo/react-hooks' +import { makeStyles } from '@material-ui/core/styles' +import gql from 'graphql-tag' +import * as R from 'ramda' +import React, { useState } from 'react' + +import ActionButton from 'src/components/buttons/ActionButton' +import { H5 } from 'src/components/typography' +import { ReactComponent as NotificationIconZodiac } from 'src/styling/icons/menu/notification-zodiac.svg' +import { ReactComponent as ClearAllIconInverse } from 'src/styling/icons/stage/spring/empty.svg' +import { ReactComponent as ClearAllIcon } from 'src/styling/icons/stage/zodiac/empty.svg' +import { ReactComponent as ShowUnreadIcon } from 'src/styling/icons/stage/zodiac/full.svg' + +import styles from './NotificationCenter.styles' +import NotificationRow from './NotificationRow' + +const useStyles = makeStyles(styles) + +const GET_NOTIFICATIONS = gql` + query getNotifications { + notifications { + id + deviceName + type + detail + message + created + read + valid + } + hasUnreadNotifications + machines { + deviceId + name + } + } +` + +const CLEAR_NOTIFICATION = gql` + mutation clearNotification($id: ID!) { + clearNotification(id: $id) { + id + } + } +` + +const CLEAR_ALL_NOTIFICATIONS = gql` + mutation clearAllNotifications { + clearAllNotifications { + id + } + } +` + +const NotificationCenter = ({ close, notifyUnread }) => { + const classes = useStyles() + const { data, loading } = useQuery(GET_NOTIFICATIONS) + const [showingUnread, setShowingUnread] = useState(false) + const machines = R.compose( + R.map(R.prop('name')), + R.indexBy(R.prop('deviceId')) + )(data?.machines ?? []) + + const notifications = R.path(['notifications'])(data) ?? [] + const hasUnread = data?.hasUnreadNotifications ?? false + if (!hasUnread) { + notifyUnread && notifyUnread() + } + const [clearNotification] = useMutation(CLEAR_NOTIFICATION, { + onError: () => console.error('Error while clearing notification'), + refetchQueries: () => ['getNotifications'] + }) + const [clearAllNotifications] = useMutation(CLEAR_ALL_NOTIFICATIONS, { + onError: () => console.error('Error while clearing all notifications'), + refetchQueries: () => ['getNotifications'] + }) + + const handleClearNotification = id => { + clearNotification({ variables: { id } }) + } + + const buildNotifications = () => { + const notificationsToShow = + !showingUnread || !hasUnread + ? notifications + : R.filter(R.propEq('read', false))(notifications) + return notificationsToShow.map(n => { + return ( + + ) + }) + } + + return ( + <> +
+
+
Notifications
+ +
+
+ {hasUnread && ( + setShowingUnread(!showingUnread)}> + {showingUnread ? 'Show all' : 'Show unread'} + + )} + + Mark all as read + +
+
+ {!loading && buildNotifications()} +
+
+
+ + ) +} + +export default NotificationCenter diff --git a/new-lamassu-admin/src/components/NotificationCenter/NotificationCenter.styles.js b/new-lamassu-admin/src/components/NotificationCenter/NotificationCenter.styles.js new file mode 100644 index 00000000..e852715a --- /dev/null +++ b/new-lamassu-admin/src/components/NotificationCenter/NotificationCenter.styles.js @@ -0,0 +1,119 @@ +import { + spacer, + white, + zircon, + secondaryColor, + spring3, + comet +} from 'src/styling/variables' + +const styles = { + background: { + position: 'absolute', + width: '100vw', + height: '100vh', + left: 0, + top: 0, + zIndex: -1, + backgroundColor: white, + boxShadow: '0 0 14px 0 rgba(0, 0, 0, 0.24)' + }, + container: { + left: -200, + top: -42, + backgroundColor: white, + height: '110vh' + }, + header: { + display: 'flex', + justifyContent: 'space-between' + }, + headerText: { + marginTop: spacer * 2.5, + marginLeft: spacer * 3 + }, + actionButtons: { + display: 'flex', + marginLeft: spacer * 2 + }, + notificationIcon: { + position: 'absolute', + left: spacer * 33, + top: spacer * 2 + 4, + cursor: 'pointer', + background: 'transparent', + boxShadow: '0px 0px 0px transparent', + border: '0px solid transparent', + textShadow: '0px 0px 0px transparent', + outline: 'none' + }, + clearAllButton: { + marginTop: -spacer * 2, + marginLeft: spacer, + backgroundColor: zircon + }, + notificationsList: { + width: 440, + height: '90vh', + maxHeight: '100vh', + marginTop: 8, + marginLeft: 0, + marginRight: 10, + overflowY: 'auto', + overflowX: 'hidden', + backgroundColor: white, + zIndex: 10 + }, + notificationRow: { + position: 'relative', + marginBottom: spacer / 2, + paddingTop: spacer * 1.5 + }, + unread: { + backgroundColor: spring3 + }, + notificationRowIcon: { + alignSelf: 'center', + '& > *': { + marginLeft: spacer * 3 + } + }, + unreadIcon: { + marginLeft: spacer, + width: '12px', + height: '12px', + backgroundColor: secondaryColor, + borderRadius: '50%', + cursor: 'pointer', + zIndex: 1 + }, + notificationTitle: { + margin: 0, + color: comet + }, + notificationBody: { + margin: 0 + }, + notificationSubtitle: { + margin: 0, + marginBottom: spacer, + color: comet + }, + stripes: { + position: 'absolute', + height: '100%', + top: '0px', + opacity: '60%' + }, + hasUnread: { + position: 'absolute', + top: 0, + left: 16, + width: '9px', + height: '9px', + backgroundColor: secondaryColor, + borderRadius: '50%' + } +} + +export default styles diff --git a/new-lamassu-admin/src/components/NotificationCenter/NotificationRow.js b/new-lamassu-admin/src/components/NotificationCenter/NotificationRow.js new file mode 100644 index 00000000..b01312ce --- /dev/null +++ b/new-lamassu-admin/src/components/NotificationCenter/NotificationRow.js @@ -0,0 +1,82 @@ +import Grid from '@material-ui/core/Grid' +import { makeStyles } from '@material-ui/core/styles' +import classnames from 'classnames' +import prettyMs from 'pretty-ms' +import React from 'react' + +import { Label1, Label2, TL2 } from 'src/components/typography' +import { ReactComponent as Wrench } from 'src/styling/icons/action/wrench/zodiac.svg' +import { ReactComponent as Transaction } from 'src/styling/icons/arrow/transaction.svg' +import { ReactComponent as StripesSvg } from 'src/styling/icons/stripes.svg' +import { ReactComponent as WarningIcon } from 'src/styling/icons/warning-icon/tomato.svg' + +import styles from './NotificationCenter.styles' +const useStyles = makeStyles(styles) + +const types = { + highValueTransaction: { display: 'Transactions', icon: }, + fiatBalance: { display: 'Maintenance', icon: }, + cryptoBalance: { display: 'Maintenance', icon: }, + compliance: { display: 'Compliance', icon: }, + error: { display: 'Error', icon: } +} + +const NotificationRow = ({ + id, + type, + detail, + message, + deviceName, + created, + read, + valid, + onClear +}) => { + const classes = useStyles() + + const buildType = () => { + return types[type].display + } + + const buildAge = () => { + const createdDate = new Date(created) + const interval = +new Date() - createdDate + return prettyMs(interval, { compact: true, verbose: true }) + } + + return ( + + + {types[type].icon} + + + + + {`${buildType()} ${deviceName ? '- ' + deviceName : ''}`} + + + + {message} + + + + {buildAge(created)} + + + + + {!read && ( +
onClear(id)} className={classes.unreadIcon} /> + )} + + {!valid && } + + ) +} + +export default NotificationRow diff --git a/new-lamassu-admin/src/components/NotificationCenter/index.js b/new-lamassu-admin/src/components/NotificationCenter/index.js new file mode 100644 index 00000000..136ffb3b --- /dev/null +++ b/new-lamassu-admin/src/components/NotificationCenter/index.js @@ -0,0 +1,2 @@ +import NotificationCenter from './NotificationCenter' +export default NotificationCenter diff --git a/new-lamassu-admin/src/components/layout/Header.js b/new-lamassu-admin/src/components/layout/Header.js index eb7a2662..3d777648 100644 --- a/new-lamassu-admin/src/components/layout/Header.js +++ b/new-lamassu-admin/src/components/layout/Header.js @@ -1,19 +1,31 @@ +import { useQuery } from '@apollo/react-hooks' +import ClickAwayListener from '@material-ui/core/ClickAwayListener' +import Popper from '@material-ui/core/Popper' import { makeStyles } from '@material-ui/core/styles' import classnames from 'classnames' +import gql from 'graphql-tag' import React, { memo, useState } from 'react' import { NavLink, useHistory } from 'react-router-dom' +import NotificationCenter from 'src/components/NotificationCenter' import ActionButton from 'src/components/buttons/ActionButton' import { H4 } from 'src/components/typography' import AddMachine from 'src/pages/AddMachine' import { ReactComponent as AddIconReverse } from 'src/styling/icons/button/add/white.svg' import { ReactComponent as AddIcon } from 'src/styling/icons/button/add/zodiac.svg' import { ReactComponent as Logo } from 'src/styling/icons/menu/logo.svg' +import { ReactComponent as NotificationIcon } from 'src/styling/icons/menu/notification.svg' import styles from './Header.styles' const useStyles = makeStyles(styles) +const HAS_UNREAD = gql` + query getUnread { + hasUnreadNotifications + } +` + const Subheader = ({ item, classes }) => { const [prev, setPrev] = useState(null) @@ -46,8 +58,10 @@ const Subheader = ({ item, classes }) => { const Header = memo(({ tree }) => { const [open, setOpen] = useState(false) + const [anchorEl, setAnchorEl] = React.useState(null) const [active, setActive] = useState() - + const { data, refetch } = useQuery(HAS_UNREAD) + const hasUnread = data?.hasUnreadNotifications ?? false const history = useHistory() const classes = useStyles() @@ -56,8 +70,26 @@ const Header = memo(({ tree }) => { history.push('/maintenance/machine-status', { id: machine.deviceId }) } + // these inline styles prevent scroll bubbling: when the user reaches the bottom of the notifications list and keeps scrolling, + // the body scrolls, stealing the focus from the notification center, preventing the admin from scrolling the notifications back up + // on the first scroll, needing to move the mouse to recapture the focus on the notification center + // it also disables the scrollbars caused by the notification center's background to the right of the page, but keeps the scrolling on the body enabled + const onClickAway = () => { + setAnchorEl(null) + document.querySelector('#root').classList.remove('root-notifcenter-open') + document.querySelector('body').classList.remove('body-notifcenter-open') + } + + const handleClick = event => { + setAnchorEl(anchorEl ? null : event.currentTarget) + document.querySelector('#root').classList.add('root-notifcenter-open') + document.querySelector('body').classList.add('body-notifcenter-open') + } + + const popperOpen = Boolean(anchorEl) + const id = popperOpen ? 'notifications-popper' : undefined return ( -
+
{ ))} + +
{ onClick={() => setOpen(true)}> Add machine - + +
+ + + + +
+
+
{active && active.children && ( diff --git a/new-lamassu-admin/src/components/layout/Header.styles.js b/new-lamassu-admin/src/components/layout/Header.styles.js index 145c8545..048c3df0 100644 --- a/new-lamassu-admin/src/components/layout/Header.styles.js +++ b/new-lamassu-admin/src/components/layout/Header.styles.js @@ -5,6 +5,7 @@ import { spacer, white, primaryColor, + secondaryColor, placeholderColor, subheaderColor, fontColor @@ -21,6 +22,9 @@ if (version === 8) { } const styles = { + headerContainer: { + position: 'relative' + }, header: { backgroundColor: primaryColor, color: white, @@ -80,27 +84,6 @@ const styles = { border: 'none', color: white, backgroundColor: 'transparent' - // '&:hover': { - // color: white - // }, - // '&:hover::after': { - // width: '50%', - // marginLeft: '-25%' - // }, - // position: 'relative', - // '&:after': { - // content: '""', - // display: 'block', - // background: white, - // width: 0, - // height: 4, - // left: '50%', - // marginLeft: 0, - // bottom: -8, - // position: 'absolute', - // borderRadius: 1000, - // transition: [['all', '0.2s', 'cubic-bezier(0.95, 0.1, 0.45, 0.94)']] - // } }, forceSize: { display: 'inline-block', @@ -167,6 +150,35 @@ const styles = { }, logoLink: { cursor: 'pointer' + }, + actionButtonsContainer: { + zIndex: 1, + position: 'relative', + display: 'flex', + justifyContent: 'space-between', + minWidth: 200, + transform: 'translateZ(0)' + }, + notificationIcon: { + marginTop: spacer / 2, + cursor: 'pointer', + background: 'transparent', + boxShadow: '0px 0px 0px transparent', + border: '0px solid transparent', + textShadow: '0px 0px 0px transparent', + outline: 'none' + }, + hasUnread: { + position: 'absolute', + top: 5, + left: 185, + width: '9px', + height: '9px', + backgroundColor: secondaryColor, + borderRadius: '50%' + }, + popper: { + zIndex: 1 } } diff --git a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js index f8f96a13..03049099 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js @@ -24,7 +24,7 @@ import { TransactionsList, ComplianceDetails } from './components' -import { /* getFormattedPhone, */ getName } from './helper' +import { getFormattedPhone, getName } from './helper' const useStyles = makeStyles(styles) @@ -147,13 +147,12 @@ const CustomerProfile = memo(() => { Customers - {name.length ? name : R.path(['phone'])(customerData)} - {/* {name.length + {name.length ? name : getFormattedPhone( R.path(['phone'])(customerData), locale.country - )} */} + )}
diff --git a/new-lamassu-admin/src/pages/Customers/CustomersList.js b/new-lamassu-admin/src/pages/Customers/CustomersList.js index 8f0a9a21..e65efcf1 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomersList.js +++ b/new-lamassu-admin/src/pages/Customers/CustomersList.js @@ -11,11 +11,7 @@ import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-ou import { ifNotNull } from 'src/utils/nullCheck' import styles from './CustomersList.styles' -import { - getAuthorizedStatus, - getName - /* getFormattedPhone */ -} from './helper' +import { getAuthorizedStatus, getFormattedPhone, getName } from './helper' const useStyles = makeStyles(styles) @@ -26,7 +22,7 @@ const CustomersList = ({ data, locale, onClick, loading }) => { { header: 'Phone', width: 172, - view: it => it.phone // getFormattedPhone(it.phone, locale.country) + view: it => getFormattedPhone(it.phone, locale.country) }, { header: 'Name', diff --git a/new-lamassu-admin/src/pages/Customers/components/CustomerDetails.js b/new-lamassu-admin/src/pages/Customers/components/CustomerDetails.js index 6d94f801..7fd988e7 100644 --- a/new-lamassu-admin/src/pages/Customers/components/CustomerDetails.js +++ b/new-lamassu-admin/src/pages/Customers/components/CustomerDetails.js @@ -9,7 +9,7 @@ import { ReactComponent as LawIconInverse } from 'src/styling/icons/circle butto import { ReactComponent as LawIcon } from 'src/styling/icons/circle buttons/law/zodiac.svg' import mainStyles from '../CustomersList.styles' -import { /* getFormattedPhone, */ getName } from '../helper' +import { getFormattedPhone, getName } from '../helper' import FrontCameraPhoto from './FrontCameraPhoto' @@ -22,7 +22,7 @@ const CustomerDetails = memo(({ customer, locale, setShowCompliance }) => { { header: 'Phone number', size: 172, - value: customer.phone // getFormattedPhone(customer.phone, locale.country) + value: getFormattedPhone(customer.phone, locale.country) }, { header: 'ID number', @@ -47,8 +47,9 @@ const CustomerDetails = memo(({ customer, locale, setShowCompliance }) => {

- {name.length ? name : R.path(['phone'])(customer)} - {/* getFormattedPhone(R.path(['phone'])(customer), locale.country)} */} + {name.length + ? name + : getFormattedPhone(R.path(['phone'])(customer), locale.country)}

: { label: 'Authorized', type: 'success' } const getFormattedPhone = (phone, country) => { - return phone && country - ? parsePhoneNumberFromString(phone, country).formatInternational() - : '' + const phoneNumber = + phone && country ? parsePhoneNumberFromString(phone, country) : null + + return phoneNumber ? phoneNumber.formatInternational() : phone } const getName = it => { diff --git a/new-lamassu-admin/src/pages/Notifications/sections/CryptoBalanceOverrides.js b/new-lamassu-admin/src/pages/Notifications/sections/CryptoBalanceOverrides.js index fe497913..e8673efc 100644 --- a/new-lamassu-admin/src/pages/Notifications/sections/CryptoBalanceOverrides.js +++ b/new-lamassu-admin/src/pages/Notifications/sections/CryptoBalanceOverrides.js @@ -9,8 +9,8 @@ import { transformNumber } from 'src/utils/number' import NotificationsCtx from '../NotificationsContext' -const HIGH_BALANCE_KEY = 'cryptoHighBalance' -const LOW_BALANCE_KEY = 'cryptoLowBalance' +const HIGH_BALANCE_KEY = 'highBalance' +const LOW_BALANCE_KEY = 'lowBalance' const CRYPTOCURRENCY_KEY = 'cryptoCurrency' const NAME = 'cryptoBalanceOverrides' diff --git a/new-lamassu-admin/src/styling/global/index.js b/new-lamassu-admin/src/styling/global/index.js index 2113bb75..1dd3742d 100644 --- a/new-lamassu-admin/src/styling/global/index.js +++ b/new-lamassu-admin/src/styling/global/index.js @@ -11,6 +11,18 @@ export default { width: fill, minHeight: fill }, + '.root-notifcenter-open': { + // for when notification center is open + overflowY: 'scroll', + position: 'absolute', + top: 0, + bottom: 0, + left: 0 + }, + '.body-notifcenter-open': { + // for when notification center is open + overflow: 'hidden' + }, html: { height: fill }, diff --git a/new-lamassu-admin/src/styling/icons/action/wrench/zodiac.svg b/new-lamassu-admin/src/styling/icons/action/wrench/zodiac.svg new file mode 100644 index 00000000..58db5b0d --- /dev/null +++ b/new-lamassu-admin/src/styling/icons/action/wrench/zodiac.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/new-lamassu-admin/src/styling/icons/arrow/transaction.svg b/new-lamassu-admin/src/styling/icons/arrow/transaction.svg new file mode 100644 index 00000000..84501d20 --- /dev/null +++ b/new-lamassu-admin/src/styling/icons/arrow/transaction.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/new-lamassu-admin/src/styling/icons/menu/notification-zodiac.svg b/new-lamassu-admin/src/styling/icons/menu/notification-zodiac.svg new file mode 100644 index 00000000..d35d2028 --- /dev/null +++ b/new-lamassu-admin/src/styling/icons/menu/notification-zodiac.svg @@ -0,0 +1,13 @@ + + + + Created with Sketch. + + + + + + + + + \ No newline at end of file diff --git a/new-lamassu-admin/src/styling/icons/stage/zodiac/full.svg b/new-lamassu-admin/src/styling/icons/stage/zodiac/full.svg new file mode 100644 index 00000000..8e905e5e --- /dev/null +++ b/new-lamassu-admin/src/styling/icons/stage/zodiac/full.svg @@ -0,0 +1,9 @@ + + + + Created with Sketch. + + + + + \ No newline at end of file