diff --git a/lib/machine-loader.js b/lib/machine-loader.js
index ec1f48a2b..126407efd 100644
--- a/lib/machine-loader.js
+++ b/lib/machine-loader.js
@@ -32,7 +32,8 @@ function toMachineObject (r) {
pairedAt: new Date(r.created),
lastPing: new Date(r.last_online),
name: r.name,
- paired: r.paired
+ paired: r.paired,
+ location: r.machine_location
// TODO: we shall start using this JSON field at some point
// location: r.location,
}
@@ -85,13 +86,21 @@ function addName (pings, events, config) {
}
function getMachineNames (config) {
- return Promise.all([getMachines(), getConfig(config), getNetworkHeartbeat(), getNetworkPerformance()])
- .then(([rawMachines, config, heartbeat, performance]) => Promise.all(
- [rawMachines, checkPings(rawMachines), dbm.machineEvents(), config, heartbeat, performance]
+ return Promise.all([getMachines(), getConfig(config), getNetworkHeartbeat(), getNetworkPerformance(), getMachineLocations()])
+ .then(([rawMachines, config, heartbeat, performance, locations]) => Promise.all(
+ [rawMachines, checkPings(rawMachines), dbm.machineEvents(), config, heartbeat, performance, locations]
))
- .then(([rawMachines, pings, events, config, heartbeat, performance]) => {
+ .then(([rawMachines, pings, events, config, heartbeat, performance, locations]) => {
const mergeByDeviceId = (x, y) => _.values(_.merge(_.keyBy('deviceId', x), _.keyBy('deviceId', y)))
- const machines = mergeByDeviceId(mergeByDeviceId(rawMachines, heartbeat), performance)
+ const mergeByLocationId = (x, y) => _.map(it => {
+ if (_.isNil(it.location)) return it
+ return { ...it, location: _.find(ite => it.location === ite.id, y) }
+ }, x)
+ const machines = _.flow([
+ x => mergeByDeviceId(x, heartbeat),
+ x => mergeByDeviceId(x, performance),
+ x => mergeByLocationId(x, locations)
+ ])(rawMachines)
return machines.map(addName(pings, events, config))
})
@@ -121,13 +130,18 @@ function getMachine (machineId, config) {
})
return Promise.all([queryMachine, dbm.machineEvents(), config, getNetworkHeartbeatByDevice(machineId), getNetworkPerformanceByDevice(machineId)])
- .then(([machine, events, config, heartbeat, performance]) => {
+ .then(([machine, events, config, heartbeat, performance]) => Promise.all([machine, events, config, heartbeat, performance, getMachineLocation(machine.location)]))
+ .then(([machine, events, config, heartbeat, performance, location]) => {
const pings = checkPings([machine])
const mergedMachine = {
...machine,
responseTime: _.get('responseTime', heartbeat),
packetLoss: _.get('packetLoss', heartbeat),
- downloadSpeed: _.get('downloadSpeed', performance),
+ downloadSpeed: _.get('downloadSpeed', performance)
+ }
+
+ if (!_.isNil(location) && !_.isEmpty(location)) {
+ mergedMachine.location = location
}
return addName(pings, events, config)(mergedMachine)
@@ -193,8 +207,10 @@ function restartServices (rec) {
)])
}
-function setMachine (rec, operatorId) {
+function setMachine (rec, operatorId, userId) {
rec.operatorId = operatorId
+ rec.userId = userId
+ logMachineAction(rec)
switch (rec.action) {
case 'rename': return renameMachine(rec)
case 'emptyCashInBills': return emptyCashInBills(rec)
@@ -204,10 +220,17 @@ function setMachine (rec, operatorId) {
case 'reboot': return reboot(rec)
case 'shutdown': return shutdown(rec)
case 'restartServices': return restartServices(rec)
+ case 'editLocation': return editMachineLocation(rec.location, rec.deviceId)
+ case 'deleteLocation': return deleteMachineLocation(rec.location.id)
+ case 'createLocation': return createMachineLocation(rec.location, rec.deviceId)
default: throw new Error('No such action: ' + rec.action)
}
}
+function testLocation (rec) {
+ console.log(rec)
+}
+
function updateNetworkPerformance (deviceId, data) {
if (_.isEmpty(data)) return Promise.resolve(true)
const downloadSpeed = _.head(data)
@@ -266,6 +289,58 @@ function getNetworkHeartbeatByDevice (deviceId) {
.then(res => _.mapKeys(_.camelCase, _.find(it => it.device_id === deviceId, res)))
}
+function getMachineLocations () {
+ const sql = `SELECT * FROM machine_locations`
+ return db.any(sql)
+ .then(_.map(_.mapKeys(_.camelCase)))
+}
+
+function getMachineLocation (locationId) {
+ const sql = `SELECT * FROM machine_locations WHERE id = $1`
+ return db.oneOrNone(sql, [locationId])
+ .then(_.mapKeys(_.camelCase))
+}
+
+function editMachineLocation ({ id, label, addressLine1, addressLine2, zipCode, country }, deviceId) {
+ const sql = `UPDATE machine_locations SET label = $1, address_line_1 = $2, address_line_2 = $3, zip_code = $4, country = $5 WHERE id = $6`
+ const sql2 = `UPDATE devices SET machine_location = $1 WHERE device_id = $2`
+ return db.none(sql, [label, addressLine1, addressLine2, zipCode, country, id])
+ .then(() => _.isNil(deviceId) ? Promise.resolve() : db.none(sql2, [id, deviceId]))
+}
+
+function createMachineLocation ({ id, label, addressLine1, addressLine2, zipCode, country }, deviceId) {
+ const _id = _.isEmpty(id) ? uuid.v4() : id
+ const sql = `INSERT INTO machine_locations (id, label, address_line_1, address_line_2, zip_code, country) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING`
+ const sql2 = `UPDATE devices SET machine_location = $1 WHERE device_id = $2`
+ return db.none(sql, [_id, label, addressLine1, addressLine2, zipCode, country])
+ .then(() => _.isNil(deviceId) ? Promise.resolve() : db.none(sql2, [_id, deviceId]))
+}
+
+function deleteMachineLocation (id) {
+ return db.tx(t => {
+ const q1 = t.none(`UPDATE devices SET machine_location = NULL WHERE machine_location = $1`, [id])
+ const q2 = t.none(`UPDATE pairing_tokens SET machine_location = NULL WHERE machine_location = $1`, [id])
+ const q3 = t.none(`DELETE FROM machine_locations WHERE id = $1`, [id])
+
+ return t.batch([q1, q2, q3])
+ })
+}
+
+function assignLocation (machineId, locationId) {
+ const sql = `UPDATE devices SET machine_location = $1 WHERE device_id = $2`
+ return db.none(sql, [locationId, machineId])
+}
+
+function logMachineAction (rec) {
+ const userId = rec.userId
+ const deviceId = rec.deviceId
+ const action = rec.action
+ const values = _.omit(['userId', 'operatorId', 'deviceId', 'action'], rec)
+ const sql = `INSERT INTO machine_action_logs (id, device_id, action, values, performed_by) VALUES ($1, $2, $3, $4, $5)`
+ // console.log([uuid.v4(), deviceId, _.kebabCase(action), values, userId])
+ return db.none(sql, [uuid.v4(), deviceId, _.kebabCase(action), values, userId])
+}
+
module.exports = {
getMachineName,
getMachines,
@@ -277,5 +352,11 @@ module.exports = {
updateNetworkHeartbeat,
getNetworkPerformance,
getNetworkHeartbeat,
- getConfig
+ getConfig,
+ getMachineLocations,
+ getMachineLocation,
+ editMachineLocation,
+ createMachineLocation,
+ deleteMachineLocation,
+ assignLocation
}
diff --git a/lib/new-admin/graphql/resolvers/machine.resolver.js b/lib/new-admin/graphql/resolvers/machine.resolver.js
index 8c0cbe43b..2ddf6af92 100644
--- a/lib/new-admin/graphql/resolvers/machine.resolver.js
+++ b/lib/new-admin/graphql/resolvers/machine.resolver.js
@@ -16,11 +16,12 @@ const resolvers = {
Query: {
machines: () => machineLoader.getMachineNames(),
machine: (...[, { deviceId }]) => machineLoader.getMachine(deviceId),
- unpairedMachines: () => machineLoader.getUnpairedMachines()
+ unpairedMachines: () => machineLoader.getUnpairedMachines(),
+ machineLocations: () => machineLoader.getMachineLocations()
},
Mutation: {
- machineAction: (...[, { deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName }, context]) =>
- machineAction({ deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName }, context)
+ machineAction: (...[, { deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName, location }, context]) =>
+ machineAction({ deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName, location }, context)
}
}
diff --git a/lib/new-admin/graphql/resolvers/pairing.resolver.js b/lib/new-admin/graphql/resolvers/pairing.resolver.js
index 510a6ef12..8946c9695 100644
--- a/lib/new-admin/graphql/resolvers/pairing.resolver.js
+++ b/lib/new-admin/graphql/resolvers/pairing.resolver.js
@@ -1,8 +1,10 @@
const pairing = require('../../services/pairing')
+const machine = require('../../../machine-loader')
const resolvers = {
Mutation: {
- createPairingTotem: (...[, { name }]) => pairing.totem(name)
+ createPairingTotem: (...[, { name, location }]) => machine.createMachineLocation(location)
+ .then(() => pairing.totem(name, location))
}
}
diff --git a/lib/new-admin/graphql/types/machine.type.js b/lib/new-admin/graphql/types/machine.type.js
index da409d7b8..e1afeb3c9 100644
--- a/lib/new-admin/graphql/types/machine.type.js
+++ b/lib/new-admin/graphql/types/machine.type.js
@@ -25,6 +25,16 @@ const typeDef = gql`
downloadSpeed: String
responseTime: String
packetLoss: String
+ location: MachineLocation
+ }
+
+ type MachineLocation {
+ id: ID
+ label: String
+ addressLine1: String
+ addressLine2: String
+ zipCode: String
+ country: String
}
type UnpairedMachine {
@@ -55,16 +65,20 @@ const typeDef = gql`
reboot
shutdown
restartServices
+ editLocation
+ deleteLocation
+ createLocation
}
type Query {
machines: [Machine] @auth
machine(deviceId: ID!): Machine @auth
unpairedMachines: [UnpairedMachine!]! @auth
+ machineLocations: [MachineLocation] @auth
}
type Mutation {
- machineAction(deviceId:ID!, action: MachineAction!, cashbox: Int, cassette1: Int, cassette2: Int, cassette3: Int, cassette4: Int, newName: String): Machine @auth
+ machineAction(deviceId:ID!, action: MachineAction!, cashbox: Int, cassette1: Int, cassette2: Int, cassette3: Int, cassette4: Int, newName: String, location: JSONObject): Machine @auth
}
`
diff --git a/lib/new-admin/graphql/types/pairing.type.js b/lib/new-admin/graphql/types/pairing.type.js
index c08c79b0c..82d65855c 100644
--- a/lib/new-admin/graphql/types/pairing.type.js
+++ b/lib/new-admin/graphql/types/pairing.type.js
@@ -2,7 +2,7 @@ const { gql } = require('apollo-server-express')
const typeDef = gql`
type Mutation {
- createPairingTotem(name: String!): String @auth
+ createPairingTotem(name: String!, location: JSONObject!): String @auth
}
`
diff --git a/lib/new-admin/services/machines.js b/lib/new-admin/services/machines.js
index 6f060d0e7..67c204300 100644
--- a/lib/new-admin/services/machines.js
+++ b/lib/new-admin/services/machines.js
@@ -6,14 +6,15 @@ function getMachine (machineId) {
.then(machines => machines.find(({ deviceId }) => deviceId === machineId))
}
-function machineAction ({ deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName }, context) {
+function machineAction ({ deviceId, action, cashbox, cassette1, cassette2, cassette3, cassette4, newName, location }, context) {
const operatorId = context.res.locals.operatorId
+ const userId = context.req.session.user.id
return getMachine(deviceId)
.then(machine => {
if (!machine) throw new UserInputError(`machine:${deviceId} not found`, { deviceId })
return machine
})
- .then(machineLoader.setMachine({ deviceId, action, cashbox, cassettes: [cassette1, cassette2, cassette3, cassette4], newName }, operatorId))
+ .then(machineLoader.setMachine({ deviceId, action, cashbox, cassettes: [cassette1, cassette2, cassette3, cassette4], newName, location }, operatorId, userId))
.then(getMachine(deviceId))
}
diff --git a/lib/new-admin/services/pairing.js b/lib/new-admin/services/pairing.js
index d267c1a92..80ce67070 100644
--- a/lib/new-admin/services/pairing.js
+++ b/lib/new-admin/services/pairing.js
@@ -16,7 +16,7 @@ const HOSTNAME = process.env.HOSTNAME
const unpair = pairing.unpair
-function totem (name) {
+function totem (name, location) {
return readFile(CA_PATH)
.then(data => {
const caHash = crypto.createHash('sha256').update(data).digest()
@@ -24,9 +24,9 @@ function totem (name) {
const hexToken = token.toString('hex')
const caHexToken = crypto.createHash('sha256').update(hexToken).digest('hex')
const buf = Buffer.concat([caHash, token, Buffer.from(HOSTNAME)])
- const sql = 'insert into pairing_tokens (token, name) values ($1, $3), ($2, $3)'
+ const sql = 'insert into pairing_tokens (token, name, machine_location) values ($1, $3, $4), ($2, $3, $4)'
- return db.none(sql, [hexToken, caHexToken, name])
+ return db.none(sql, [hexToken, caHexToken, name, location.id])
.then(() => bsAlpha.encode(buf))
})
}
diff --git a/lib/pairing.js b/lib/pairing.js
index f6942591b..3d65775ef 100644
--- a/lib/pairing.js
+++ b/lib/pairing.js
@@ -14,7 +14,7 @@ const DEFAULT_NUMBER_OF_CASSETTES = 2
function pullToken (token) {
const sql = `delete from pairing_tokens
where token=$1
- returning name, created < now() - interval '1 hour' as expired`
+ returning name, created < now() - interval '1 hour' as expired, machine_location`
return db.one(sql, [token])
}
@@ -41,11 +41,11 @@ function pair (token, deviceId, machineModel, numOfCassettes = DEFAULT_NUMBER_OF
.then(r => {
if (r.expired) return false
- const insertSql = `insert into devices (device_id, name, number_of_cassettes) values ($1, $2, $3)
+ const insertSql = `insert into devices (device_id, name, number_of_cassettes, machine_location) values ($1, $2, $3, $4)
on conflict (device_id)
do update set paired=TRUE, display=TRUE`
- return db.none(insertSql, [deviceId, r.name, numOfCassettes])
+ return db.none(insertSql, [deviceId, r.name, numOfCassettes, r.machine_location])
.then(() => true)
})
.catch(err => {
diff --git a/migrations/1664411947220-machine-location.js b/migrations/1664411947220-machine-location.js
new file mode 100644
index 000000000..ed82ba6e4
--- /dev/null
+++ b/migrations/1664411947220-machine-location.js
@@ -0,0 +1,23 @@
+var db = require('./db')
+
+exports.up = function (next) {
+ var sql = [
+ `CREATE TABLE machine_locations (
+ id UUID PRIMARY KEY,
+ label TEXT NOT NULL,
+ address_line_1 TEXT NOT NULL,
+ address_line_2 TEXT,
+ zip_code TEXT NOT NULL,
+ country TEXT NOT NULL,
+ created TIMESTAMPTZ NOT NULL DEFAULT now()
+ )`,
+ `ALTER TABLE devices ADD COLUMN machine_location UUID REFERENCES machine_locations(id)`,
+ `ALTER TABLE pairing_tokens ADD COLUMN machine_location UUID REFERENCES machine_locations(id)`
+ ]
+
+ db.multi(sql, next)
+}
+
+exports.down = function (next) {
+ next()
+}
diff --git a/migrations/1664748434695-machine-actions-auditing.js b/migrations/1664748434695-machine-actions-auditing.js
new file mode 100644
index 000000000..dcfec3491
--- /dev/null
+++ b/migrations/1664748434695-machine-actions-auditing.js
@@ -0,0 +1,35 @@
+var db = require('./db')
+
+exports.up = function (next) {
+ var sql = [
+ `CREATE TYPE machine_action AS ENUM (
+ 'rename',
+ 'empty-cash-in-bills',
+ 'reset-cash-out-bills',
+ 'set-cassette-bills',
+ 'unpair',
+ 'reboot',
+ 'shutdown',
+ 'restart-services',
+ 'edit-location',
+ 'delete-location',
+ 'create-location',
+ 'disable',
+ 'enable'
+ )`,
+ `CREATE TABLE machine_action_logs (
+ id UUID PRIMARY KEY,
+ device_id TEXT NOT NULL REFERENCES devices(device_id),
+ action machine_action NOT NULL,
+ values JSONB NOT NULL,
+ performed_by UUID NOT NULL REFERENCES users(id),
+ performed_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ )`
+ ]
+
+ db.multi(sql, next)
+}
+
+exports.down = function (next) {
+ next()
+}
diff --git a/new-lamassu-admin/src/components/machineActions/EditLocationModal.js b/new-lamassu-admin/src/components/machineActions/EditLocationModal.js
new file mode 100644
index 000000000..529e47173
--- /dev/null
+++ b/new-lamassu-admin/src/components/machineActions/EditLocationModal.js
@@ -0,0 +1,274 @@
+import { useQuery } from '@apollo/react-hooks'
+import { makeStyles } from '@material-ui/core/styles'
+import { Form, Formik, FastField } from 'formik'
+import gql from 'graphql-tag'
+import * as R from 'ramda'
+import React, { useState } from 'react'
+import * as Yup from 'yup'
+
+import ErrorMessage from 'src/components/ErrorMessage'
+import { Button } from 'src/components/buttons'
+import { Autocomplete } from 'src/components/inputs'
+import {
+ TextInput,
+ Autocomplete as FormikAutocomplete
+} from 'src/components/inputs/formik'
+import { spacer } from 'src/styling/variables'
+import { fromNamespace, namespaces } from 'src/utils/config'
+
+import Modal from '../Modal'
+
+const GET_COUNTRIES = gql`
+ {
+ machineLocations {
+ id
+ label
+ addressLine1
+ addressLine2
+ zipCode
+ country
+ }
+ config
+ countries {
+ code
+ display
+ }
+ }
+`
+
+const styles = {
+ form: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ '& > *': {
+ marginTop: 20
+ },
+ '& > *:last-child': {
+ marginTop: 'auto'
+ }
+ },
+ submit: {
+ display: 'flex',
+ flexDirection: 'row',
+ margin: [['auto', 0, 0, 'auto']],
+ '& > *': {
+ marginRight: 10
+ },
+ '& > *:last-child': {
+ marginRight: 0
+ }
+ },
+ footer: {
+ display: 'flex',
+ flexDirection: 'row',
+ margin: [['auto', 0, spacer * 3, 0]]
+ },
+ existingLocation: {
+ marginBottom: 50
+ }
+}
+
+const useStyles = makeStyles(styles)
+
+const EditLocationModal = ({
+ machine,
+ handleClose,
+ editAction,
+ deleteAction,
+ createAction
+}) => {
+ const classes = useStyles()
+ const { data, loading, refetch } = useQuery(GET_COUNTRIES, {
+ onCompleted: () =>
+ setPreset(
+ R.find(it => it.value?.id === machine.location?.id, locationOptions) ??
+ locationOptions[0]
+ )
+ })
+
+ const machineLocations = data?.machineLocations ?? []
+ const countries = data?.countries ?? []
+ const locationOptions = [
+ { label: 'New location' },
+ ...R.map(it => ({ label: it.label, value: it }), machineLocations)
+ ]
+ const localeCountry = R.find(
+ it => it.code === fromNamespace(namespaces.LOCALE)(data?.config).country,
+ countries
+ )
+ const [preset, setPreset] = useState(null)
+
+ const initialValues = {
+ location: {
+ id: machine.location?.id ?? '',
+ label: machine.location?.label ?? '',
+ addressLine1: machine.location?.addressLine1 ?? '',
+ addressLine2: machine.location?.addressLine2 ?? '',
+ zipCode: machine.location?.zipCode ?? '',
+ country: machine.location?.country ?? localeCountry?.display ?? ''
+ }
+ }
+
+ const newLocationValues = {
+ location: {
+ id: '',
+ label: '',
+ addressLine1: '',
+ addressLine2: '',
+ zipCode: '',
+ country: localeCountry?.display ?? ''
+ }
+ }
+
+ const validationSchema = Yup.object().shape({
+ location: Yup.object().shape({
+ label: Yup.string()
+ .required('A label is required.')
+ .max(50),
+ addressLine1: Yup.string()
+ .required('An address is required.')
+ .max(75),
+ addressLine2: Yup.string().max(75),
+ zipCode: Yup.string()
+ .required('A zip code is required.')
+ .max(20),
+ country: Yup.string()
+ .required('A country is required.')
+ .max(50)
+ })
+ })
+
+ const newLocationOption = R.find(it => !it.value, locationOptions)
+
+ const isNewLocation = it => R.equals(it, newLocationOption)
+
+ const createLocation = location => {
+ return createAction(location)
+ }
+
+ const editLocation = location => {
+ return editAction(location)
+ }
+
+ const deleteLocation = (location, onActionSuccess) => {
+ return deleteAction(location, onActionSuccess)
+ }
+
+ return (
+ !loading && (
+
{errors.message}
}+ {`${data.location.addressLine1}${ + data.location.addressLine2 + ? `, ${data.location.addressLine2}` + : `` + }, ${data.location.zipCode}, ${data.location.country}`} +
+