diff --git a/lib/custom-sms.js b/lib/custom-sms.js index 3ba6d9c0..f02b0dc8 100644 --- a/lib/custom-sms.js +++ b/lib/custom-sms.js @@ -1,17 +1,50 @@ +const _ = require('lodash/fp') const uuid = require('uuid') const db = require('./db') const getCustomMessages = () => { - const sql = `SELECT * FROM custom_messages` - return db.any(sql) + const sql = `SELECT * FROM custom_messages ORDER BY created` + return db.any(sql).then(res => _.map( + it => ({ + id: it.id, + event: _.camelCase(it.event), + deviceId: it.device_id, + message: it.message + }), res)) } const createCustomMessage = (event, deviceId, message) => { - const sql = `INSERT INTO custom_message (event, device_id, message) VALUES ($2, $3, $4)` - return db.none(sql, [uuid.v4(), event, deviceId, message]) + const machineId = deviceId === 'ALL_MACHINES' ? null : deviceId + const sql = `INSERT INTO custom_messages (id, event, device_id, message) VALUES ($1, $2, $3, $4)` + return db.none(sql, [uuid.v4(), _.snakeCase(event), machineId, message]) +} + +const editCustomMessage = (id, event, deviceId, message) => { + const machineId = deviceId === 'ALL_MACHINES' ? null : deviceId + const sql = `UPDATE custom_messages SET event=$2, device_id=$3, message=$4 WHERE id=$1` + return db.none(sql, [id, _.snakeCase(event), machineId, message]) +} + +const deleteCustomMessage = id => { + const sql = `DELETE FROM custom_messages WHERE id=$1` + return db.none(sql, [id]) +} + +const getCommonCustomMessages = event => { + const sql = `SELECT * FROM custom_messages WHERE event=$1 AND device_id IS NULL LIMIT 1` + return db.oneOrNone(sql, [event]) +} + +const getMachineCustomMessages = (event, deviceId) => { + const sql = `SELECT * FROM custom_messages WHERE event=$1 AND device_id=$2 LIMIT 1` + return db.oneOrNone(sql, [event, deviceId]) } module.exports = { getCustomMessages, - createCustomMessage + createCustomMessage, + editCustomMessage, + deleteCustomMessage, + getCommonCustomMessages, + getMachineCustomMessages } diff --git a/lib/new-admin/graphql/resolvers/sms.resolver.js b/lib/new-admin/graphql/resolvers/sms.resolver.js index 6936dfe8..a9c2ba5e 100644 --- a/lib/new-admin/graphql/resolvers/sms.resolver.js +++ b/lib/new-admin/graphql/resolvers/sms.resolver.js @@ -5,7 +5,9 @@ const resolvers = { customMessages: () => customSms.getCustomMessages() }, Mutation: { - createCustomMessage: (...[, { event, deviceId, message }]) => customSms.createCustomMessage(event, deviceId, message) + createCustomMessage: (...[, { event, deviceId, message }]) => customSms.createCustomMessage(event, deviceId, message), + editCustomMessage: (...[, { id, event, deviceId, message }]) => customSms.editCustomMessage(id, event, deviceId, message), + deleteCustomMessage: (...[, { id }]) => customSms.deleteCustomMessage(id) } } diff --git a/lib/new-admin/graphql/types/sms.type.js b/lib/new-admin/graphql/types/sms.type.js index c6528b54..2eb1c62e 100644 --- a/lib/new-admin/graphql/types/sms.type.js +++ b/lib/new-admin/graphql/types/sms.type.js @@ -19,6 +19,8 @@ const typeDef = gql` type Mutation { createCustomMessage(event: CustomMessageEvent!, deviceId: String, message: String!): CustomMessage @auth + editCustomMessage(id: ID!, event: CustomMessageEvent!, deviceId: String, message: String!): CustomMessage @auth + deleteCustomMessage(id: ID!): CustomMessage @auth } ` diff --git a/lib/plugins.js b/lib/plugins.js index ce133e8c..1c62cf7e 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -361,19 +361,19 @@ function plugins (settings, deviceId) { const phone = tx.phone const timestamp = dateFormat(new Date(), 'UTC:HH:MM Z') - const rec = { - sms: { - toNumber: phone, - body: `Your cash is waiting! Go to the Cryptomat and press Redeem within 24 hours. [${timestamp}]` - } - } + return sms.getCashOutReadySms(deviceId, phone, code) + .then(msg => { + const rec = { + sms: msg + } + + 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) + }) }) } @@ -723,15 +723,15 @@ function plugins (settings, deviceId) { ? '123' : randomCode() - const rec = { - sms: { - toNumber: phone, - body: 'Your cryptomat code: ' + code - } - } - - return sms.sendMessage(settings, rec) - .then(() => code) + return sms.getPhoneCodeSms(deviceId, phone, code) + .then(msg => { + const rec = { + sms: msg + } + + return sms.sendMessage(settings, rec) + .then(() => code) + }) } function sweepHdRow (row) { diff --git a/lib/sms.js b/lib/sms.js index 597f5bd1..c1cbd08c 100644 --- a/lib/sms.js +++ b/lib/sms.js @@ -1,6 +1,67 @@ const ph = require('./plugin-helper') const argv = require('minimist')(process.argv.slice(2)) const { utils: coinUtils } = require('lamassu-coins') +const _ = require('lodash/fp') + +const customSms = require('./custom-sms') + +function getPhoneCodeSms (deviceId, phone, code) { + return Promise.all([ + customSms.getCommonCustomMessages('sms_code'), + customSms.getMachineCustomMessages('sms_code', deviceId) + ]) + .then(([commonMsg, machineMsg]) => { + if (!_.isNil(machineMsg)) { + const messageContent = _.replace('#code', code, machineMsg.message) + return { + toNumber: phone, + body: messageContent + } + } + + if (!_.isNil(commonMsg)) { + const messageContent = _.replace('#code', code, commonMsg.message) + return { + toNumber: phone, + body: messageContent + } + } + + return { + toNumber: phone, + body: `Your cryptomat code: ${code}` + } + }) +} + +function getCashOutReadySms (deviceId, phone, timestamp) { + return Promise.all([ + customSms.getCommonCustomMessages('cash_out_dispense_ready'), + customSms.getMachineCustomMessages('cash_out_dispense_ready', deviceId) + ]) + .then(([commonMsg, machineMsg]) => { + if (!_.isNil(machineMsg)) { + const messageContent = _.replace('#timestamp', timestamp, machineMsg.message) + return { + toNumber: phone, + body: messageContent + } + } + + if (!_.isNil(commonMsg)) { + const messageContent = _.replace('#timestamp', timestamp, commonMsg.message) + return { + toNumber: phone, + body: messageContent + } + } + + return { + toNumber: phone, + body: `Your cash is waiting! Go to the Cryptomat and press Redeem within 24 hours. [${timestamp}]` + } + }) +} function getPlugin (settings) { const pluginCode = argv.mockSms ? 'mock-sms' : 'twilio' @@ -91,4 +152,11 @@ function formatSmsReceipt (data, options) { return request } -module.exports = { sendMessage, formatSmsReceipt, getLookup, toCryptoUnits } +module.exports = { + getPhoneCodeSms, + getCashOutReadySms, + sendMessage, + getLookup, + formatSmsReceipt, + toCryptoUnits +} diff --git a/migrations/1627518944902-custom-sms.js b/migrations/1627518944902-custom-sms.js index ff19c1c5..95395d31 100644 --- a/migrations/1627518944902-custom-sms.js +++ b/migrations/1627518944902-custom-sms.js @@ -7,9 +7,11 @@ exports.up = function (next) { id UUID PRIMARY KEY, event custom_message_event NOT NULL, device_id TEXT REFERENCES devices(device_id), - message TEXT NOT NULL + message TEXT NOT NULL, + created TIMESTAMPTZ NOT NULL DEFAULT now() )`, - `CREATE UNIQUE INDEX uq_custom_message_per_device ON custom_messages (event, device_id)` + `CREATE UNIQUE INDEX uq_custom_message_per_device ON custom_messages (event, device_id) WHERE device_id IS NOT NULL`, + `CREATE UNIQUE INDEX uq_custom_message_all_devices ON custom_messages (event) WHERE device_id IS NULL` ] db.multi(sql, next) diff --git a/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMS.js b/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMS.js index 188e7081..44bcf845 100644 --- a/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMS.js +++ b/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMS.js @@ -1,20 +1,20 @@ -import { useQuery } from '@apollo/react-hooks' +import { useQuery, useMutation } from '@apollo/react-hooks' import { makeStyles, Box } from '@material-ui/core' import gql from 'graphql-tag' import * as R from 'ramda' import React, { useState } from 'react' +import { DeleteDialog } from 'src/components/DeleteDialog' import { Link, IconButton } from 'src/components/buttons' import DataTable from 'src/components/tables/DataTable' import { H4 } from 'src/components/typography' import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg' import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg' -import { global } from '../OperatorInfo.styles' - +import styles from './CustomSMS.styles' import CustomSMSModal from './CustomSMSModal' -const useStyles = makeStyles(global) +const useStyles = makeStyles(styles) const GET_CUSTOM_MESSAGES = gql` query customMessages { @@ -27,6 +27,44 @@ const GET_CUSTOM_MESSAGES = gql` } ` +const CREATE_CUSTOM_MESSAGE = gql` + mutation createCustomMessage( + $event: CustomMessageEvent! + $deviceId: String! + $message: String! + ) { + createCustomMessage(event: $event, deviceId: $deviceId, message: $message) { + id + } + } +` + +const EDIT_CUSTOM_MESSAGE = gql` + mutation editCustomMessage( + $id: ID! + $event: CustomMessageEvent! + $deviceId: String! + $message: String! + ) { + editCustomMessage( + id: $id + event: $event + deviceId: $deviceId + message: $message + ) { + id + } + } +` + +const DELETE_CUSTOM_MESSAGE = gql` + mutation deleteCustomMessage($id: ID!) { + deleteCustomMessage(id: $id) { + id + } + } +` + const GET_MACHINES = gql` { machines { @@ -36,43 +74,91 @@ const GET_MACHINES = gql` } ` +const EVENT_OPTIONS = [ + { code: 'smsCode', display: 'On SMS confirmation code' }, + { code: 'cashOutDispenseReady', display: 'Cash out dispense ready' } +] + const CustomSMS = () => { const classes = useStyles() + const [deleteDialog, setDeleteDialog] = useState(false) const [showModal, setShowModal] = useState(false) + const [selectedSMS, setSelectedSMS] = useState(null) + const [errorMsg, setErrorMsg] = useState('') + const { data: messagesData, loading: messagesLoading } = useQuery( GET_CUSTOM_MESSAGES ) + const { data: machinesData, loading: machinesLoading } = useQuery( GET_MACHINES ) + const [createMessage] = useMutation(CREATE_CUSTOM_MESSAGE, { + onError: ({ msg }) => setErrorMsg(msg), + refetchQueries: () => ['customMessages'] + }) + + const [editMessage] = useMutation(EDIT_CUSTOM_MESSAGE, { + onError: ({ msg }) => setErrorMsg(msg), + refetchQueries: () => ['customMessages'] + }) + + const [deleteMessage] = useMutation(DELETE_CUSTOM_MESSAGE, { + onError: ({ msg }) => setErrorMsg(msg), + refetchQueries: () => ['customMessages'] + }) + const loading = messagesLoading && machinesLoading const machineOptions = - machinesData && - R.map( - it => ({ code: it.deviceId, display: it.name }), - R.path(['machines'])(machinesData) - ) + (machinesData && + R.map( + it => ({ code: it.deviceId, display: it.name }), + R.path(['machines'])(machinesData) + )) ?? + [] + + const handleClose = () => { + setSelectedSMS(null) + setShowModal(false) + setDeleteDialog(false) + } + + const handleOpen = () => { + setErrorMsg('') + setShowModal(true) + } const elements = [ { - header: 'Message name', + header: 'Event', width: 400, size: 'sm', textAlign: 'left', - view: it => it.event + view: it => + R.find(ite => R.propEq('event', ite.code, it), EVENT_OPTIONS).display + }, + { + header: 'Machine', + width: 200, + size: 'sm', + textAlign: 'left', + view: it => + R.find(ite => R.propEq('deviceId', ite.code, it), machineOptions) + ?.display ?? `All Machines` }, { header: 'Edit', - width: 120, + width: 100, size: 'sm', textAlign: 'center', view: it => ( { - console.log('edit') + setSelectedSMS(it) + setShowModal(true) }}> @@ -80,13 +166,14 @@ const CustomSMS = () => { }, { header: 'Delete', - width: 120, + width: 100, size: 'sm', textAlign: 'center', view: it => ( { - console.log('delete') + setSelectedSMS(it) + setDeleteDialog(true) }}> @@ -96,13 +183,10 @@ const CustomSMS = () => { return ( <> -
+

Custom SMS message

- - setShowModal(true)}> + + handleOpen()}> Add custom SMS @@ -110,10 +194,29 @@ const CustomSMS = () => { {showModal && ( setShowModal(false)} + onClose={handleClose} machineOptions={machineOptions} + eventOptions={EVENT_OPTIONS} + sms={selectedSMS} + creationError={errorMsg} + submit={selectedSMS ? editMessage : createMessage} /> )} + { + handleClose() + }} + onConfirmed={() => { + handleClose() + deleteMessage({ + variables: { + id: selectedSMS.id + } + }) + }} + errorMessage={errorMsg} + /> *': { + marginTop: 20 + }, + display: 'flex', + flexDirection: 'column', + height: '100%' + }, + footer: { + display: 'flex', + flexDirection: 'row', + margin: [['auto', 0, spacer * 3, 0]] + }, + submit: { + margin: [['auto', 0, 0, 'auto']] + } +} + +export default styles diff --git a/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMSModal.js b/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMSModal.js index 29bd732d..ca8a4d85 100644 --- a/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMSModal.js +++ b/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMSModal.js @@ -1,40 +1,128 @@ +import { makeStyles } from '@material-ui/core' import { Form, Formik, Field } from 'formik' -import React from 'react' +import * as R from 'ramda' +import React, { useState } from 'react' import * as Yup from 'yup' +import ErrorMessage from 'src/components/ErrorMessage' import Modal from 'src/components/Modal' -import { Autocomplete } from 'src/components/inputs/formik' +import { Button } from 'src/components/buttons' +import { Autocomplete, TextInput } from 'src/components/inputs/formik' -const EVENT_OPTIONS = [ - { code: 'sms_code', display: 'On SMS confirmation code' }, - { code: 'cash_out_dispense_ready', display: 'Cash out dispense ready' } -] +import styles from './CustomSMS.styles' + +const useStyles = makeStyles(styles) + +const ALL_MACHINES = { + code: 'ALL_MACHINES', + display: 'All Machines' +} + +const getErrorMsg = (formikErrors, formikTouched, mutationError) => { + if (!formikErrors || !formikTouched) return null + if (mutationError) return 'Internal server error' + if (formikErrors.event && formikTouched.event) return formikErrors.event + if (formikErrors.message && formikTouched.message) return formikErrors.message + return null +} + +const prefill = { + smsCode: { + tags: [], + validator: Yup.string() + .required('The message content is required!') + .trim() + .test({ + name: 'has-code-tag', + message: 'A #code tag is missing from the message!', + exclusive: false, + test: value => value?.match(/#code/g || [])?.length > 0 + }) + .test({ + name: 'has-single-code-tag', + message: 'There should be a single #code tag!', + exclusive: false, + test: value => value?.match(/#code/g || [])?.length === 1 + }) + }, + cashOutDispenseReady: { + tags: [], + validator: Yup.string() + .required('The message content is required!') + .trim() + .test({ + name: 'has-timestamp-tag', + message: 'A #timestamp tag is missing from the message!', + exclusive: false, + test: value => value?.match(/#timestamp/g || [])?.length > 0 + }) + .test({ + name: 'has-single-timestamp-tag', + message: 'There should be a single #timestamp tag!', + exclusive: false, + test: value => value?.match(/#timestamp/g || [])?.length === 1 + }) + } +} const CustomSMSModal = ({ showModal, onClose, - customMessage, - machineOptions + sms, + machineOptions, + eventOptions, + creationError, + submit }) => { + const classes = useStyles() + + const [selectedEvent, setSelectedEvent] = useState(sms?.event) + const initialValues = { - event: '', - device: '', - message: '' + event: !R.isNil(sms) ? sms.event : '', + device: !R.isNil(sms) + ? !R.isNil(sms.deviceId) + ? sms.deviceId + : 'ALL_MACHINES' + : '', + message: !R.isNil(sms) ? sms.message : '' } - const validationSchema = { + const validationSchema = Yup.object().shape({ event: Yup.string().required('An event is required!'), - device: Yup.string(), - message: Yup.string() - .required('The message content is required!') - .trim() + device: Yup.string().required('A machine is required!'), + message: + prefill[selectedEvent]?.validator ?? + Yup.string() + .required('The message content is required!') + .trim() + }) + + const handleSubmit = values => { + sms + ? submit({ + variables: { + id: sms.id, + event: values.event, + deviceId: values.device, + message: values.message + } + }) + : submit({ + variables: { + event: values.event, + deviceId: values.device, + message: values.message + } + }) + onClose() } return ( <> {showModal && ( -
- - - + validationSchema={validationSchema} + onSubmit={(values, errors, touched) => + handleSubmit(values, errors, touched) + }> + {({ values, errors, touched }) => ( +
+ + + +
+ {getErrorMsg(errors, touched, creationError) && ( + + {getErrorMsg(errors, touched, creationError)} + + )} + +
+ + )}
)} diff --git a/new-lamassu-admin/src/pages/OperatorInfo/OperatorInfo.styles.js b/new-lamassu-admin/src/pages/OperatorInfo/OperatorInfo.styles.js index d69919e0..9bdfe706 100644 --- a/new-lamassu-admin/src/pages/OperatorInfo/OperatorInfo.styles.js +++ b/new-lamassu-admin/src/pages/OperatorInfo/OperatorInfo.styles.js @@ -10,13 +10,6 @@ const global = { position: 'relative', flex: 'wrap' }, - headerWithLink: { - display: 'flex', - position: 'relative', - alignItems: 'center', - justifyContent: 'space-between', - width: 640 - }, section: { marginBottom: 52 },