diff --git a/lib/constants.js b/lib/constants.js index 9e526ce6..94bb3138 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -16,6 +16,9 @@ const USER_SESSIONS_CLEAR_INTERVAL = 1 * T.hour const AUTOMATIC = 'automatic' const MANUAL = 'manual' +const CASH_OUT_DISPENSE_READY = 'cash_out_dispense_ready' +const CONFIRMATION_CODE = 'sms_code' + module.exports = { anonymousCustomer, cassetteMaxCapacity, @@ -25,5 +28,7 @@ module.exports = { AUTOMATIC, MANUAL, USER_SESSIONS_TABLE_NAME, - USER_SESSIONS_CLEAR_INTERVAL + USER_SESSIONS_CLEAR_INTERVAL, + CASH_OUT_DISPENSE_READY, + CONFIRMATION_CODE } diff --git a/lib/custom-sms.js b/lib/custom-sms.js new file mode 100644 index 00000000..8cb8546a --- /dev/null +++ b/lib/custom-sms.js @@ -0,0 +1,41 @@ +const _ = require('lodash/fp') +const uuid = require('uuid') +const db = require('./db') + +const getCustomMessages = () => { + const sql = `SELECT * FROM custom_messages ORDER BY created` + return db.any(sql).then(res => _.map( + it => ({ + id: it.id, + event: _.camelCase(it.event), + message: it.message + }), res)) +} + +const createCustomMessage = (event, message) => { + const sql = `INSERT INTO custom_messages (id, event, message) VALUES ($1, $2, $3)` + return db.none(sql, [uuid.v4(), _.snakeCase(event), message]) +} + +const editCustomMessage = (id, event, message) => { + const sql = `UPDATE custom_messages SET event=$2, message=$3 WHERE id=$1` + return db.none(sql, [id, _.snakeCase(event), message]) +} + +const deleteCustomMessage = id => { + const sql = `DELETE FROM custom_messages WHERE id=$1` + return db.none(sql, [id]) +} + +const getCustomMessage = event => { + const sql = `SELECT * FROM custom_messages WHERE event=$1 LIMIT 1` + return db.oneOrNone(sql, [event]) +} + +module.exports = { + getCustomMessages, + createCustomMessage, + editCustomMessage, + deleteCustomMessage, + getCustomMessage +} diff --git a/lib/new-admin/graphql/resolvers/index.js b/lib/new-admin/graphql/resolvers/index.js index 033593d7..f93c2bc5 100644 --- a/lib/new-admin/graphql/resolvers/index.js +++ b/lib/new-admin/graphql/resolvers/index.js @@ -15,6 +15,7 @@ const pairing = require('./pairing.resolver') const rates = require('./rates.resolver') const scalar = require('./scalar.resolver') const settings = require('./settings.resolver') +const sms = require('./sms.resolver') const status = require('./status.resolver') const transaction = require('./transaction.resolver') const user = require('./users.resolver') @@ -36,6 +37,7 @@ const resolvers = [ rates, scalar, settings, + sms, status, transaction, user, diff --git a/lib/new-admin/graphql/resolvers/sms.resolver.js b/lib/new-admin/graphql/resolvers/sms.resolver.js new file mode 100644 index 00000000..f159c8c4 --- /dev/null +++ b/lib/new-admin/graphql/resolvers/sms.resolver.js @@ -0,0 +1,14 @@ +const customSms = require('../../../custom-sms') + +const resolvers = { + Query: { + customMessages: () => customSms.getCustomMessages() + }, + Mutation: { + createCustomMessage: (...[, { event, message }]) => customSms.createCustomMessage(event, message), + editCustomMessage: (...[, { id, event, message }]) => customSms.editCustomMessage(id, event, message), + deleteCustomMessage: (...[, { id }]) => customSms.deleteCustomMessage(id) + } +} + +module.exports = resolvers diff --git a/lib/new-admin/graphql/types/index.js b/lib/new-admin/graphql/types/index.js index 8745bdb3..e24322ef 100644 --- a/lib/new-admin/graphql/types/index.js +++ b/lib/new-admin/graphql/types/index.js @@ -15,6 +15,7 @@ const pairing = require('./pairing.type') const rates = require('./rates.type') const scalar = require('./scalar.type') const settings = require('./settings.type') +const sms = require('./sms.type') const status = require('./status.type') const transaction = require('./transaction.type') const user = require('./users.type') @@ -36,6 +37,7 @@ const types = [ rates, scalar, settings, + sms, status, transaction, user, diff --git a/lib/new-admin/graphql/types/sms.type.js b/lib/new-admin/graphql/types/sms.type.js new file mode 100644 index 00000000..a86947b7 --- /dev/null +++ b/lib/new-admin/graphql/types/sms.type.js @@ -0,0 +1,26 @@ +const { gql } = require('apollo-server-express') + +const typeDef = gql` + type CustomMessage { + id: ID! + event: CustomMessageEvent! + message: String! + } + + enum CustomMessageEvent { + smsCode + cashOutDispenseReady + } + + type Query { + customMessages: [CustomMessage] @auth + } + + type Mutation { + createCustomMessage(event: CustomMessageEvent!, message: String!): CustomMessage @auth + editCustomMessage(id: ID!, event: CustomMessageEvent!, message: String!): CustomMessage @auth + deleteCustomMessage(id: ID!): CustomMessage @auth + } +` + +module.exports = typeDef diff --git a/lib/plugins.js b/lib/plugins.js index d6cb778e..a0ae0b8a 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -23,7 +23,7 @@ const customers = require('./customers') const commissionMath = require('./commission-math') const loyalty = require('./loyalty') -const { cassetteMaxCapacity } = require('./constants') +const { cassetteMaxCapacity, CASH_OUT_DISPENSE_READY, CONFIRMATION_CODE } = require('./constants') const notifier = require('./notifier') @@ -366,19 +366,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.getSms(CASH_OUT_DISPENSE_READY, phone, { timestamp }) + .then(smsObj => { + const rec = { + sms: smsObj + } + + 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) + }) }) } @@ -754,15 +754,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.getSms(CONFIRMATION_CODE, phone, { code }) + .then(smsObj => { + const rec = { + sms: smsObj + } + + return sms.sendMessage(settings, rec) + .then(() => code) + }) } function sweepHdRow (row) { diff --git a/lib/sms.js b/lib/sms.js index 597f5bd1..71b691c2 100644 --- a/lib/sms.js +++ b/lib/sms.js @@ -1,6 +1,34 @@ 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') + +const getDefaultMessageContent = content => ({ + smsCode: `Your cryptomat code: ${content.code}`, + cashOutDispenseReady: `Your cash is waiting! Go to the Cryptomat and press Redeem within 24 hours. [${content.timestamp}]` +}) + +function getSms (event, phone, content) { + return customSms.getCustomMessage(event) + .then(msg => { + if (!_.isNil(msg)) { + var accMsg = msg.message + const contentKeys = _.keys(content) + const messageContent = _.reduce((acc, it) => _.replace(`#${it}`, content[it], acc), accMsg, contentKeys) + return { + toNumber: phone, + body: messageContent + } + } + + return { + toNumber: phone, + body: getDefaultMessageContent(content)[_.camelCase(event)] + } + }) +} function getPlugin (settings) { const pluginCode = argv.mockSms ? 'mock-sms' : 'twilio' @@ -91,4 +119,10 @@ function formatSmsReceipt (data, options) { return request } -module.exports = { sendMessage, formatSmsReceipt, getLookup, toCryptoUnits } +module.exports = { + getSms, + sendMessage, + getLookup, + formatSmsReceipt, + toCryptoUnits +} diff --git a/migrations/1627518944902-custom-sms.js b/migrations/1627518944902-custom-sms.js new file mode 100644 index 00000000..74b82999 --- /dev/null +++ b/migrations/1627518944902-custom-sms.js @@ -0,0 +1,19 @@ +var db = require('./db') + +exports.up = function (next) { + var sql = [ + `CREATE TYPE custom_message_event AS ENUM('sms_code', 'cash_out_dispense_ready')`, + `CREATE TABLE custom_messages ( + id UUID PRIMARY KEY, + event custom_message_event UNIQUE NOT NULL, + message TEXT NOT NULL, + created TIMESTAMPTZ NOT NULL DEFAULT now() + )` + ] + + db.multi(sql, next) +} + +exports.down = function (next) { + next() +} diff --git a/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMS.js b/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMS.js new file mode 100644 index 00000000..b1d3dac0 --- /dev/null +++ b/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMS.js @@ -0,0 +1,188 @@ +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 styles from './CustomSMS.styles' +import CustomSMSModal from './CustomSMSModal' + +const useStyles = makeStyles(styles) + +const GET_CUSTOM_MESSAGES = gql` + query customMessages { + customMessages { + id + event + message + } + } +` + +const CREATE_CUSTOM_MESSAGE = gql` + mutation createCustomMessage($event: CustomMessageEvent!, $message: String!) { + createCustomMessage(event: $event, message: $message) { + id + } + } +` + +const EDIT_CUSTOM_MESSAGE = gql` + mutation editCustomMessage( + $id: ID! + $event: CustomMessageEvent! + $message: String! + ) { + editCustomMessage(id: $id, event: $event, message: $message) { + id + } + } +` + +const DELETE_CUSTOM_MESSAGE = gql` + mutation deleteCustomMessage($id: ID!) { + deleteCustomMessage(id: $id) { + id + } + } +` + +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 [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 + + const handleClose = () => { + setSelectedSMS(null) + setShowModal(false) + setDeleteDialog(false) + } + + const handleOpen = () => { + setErrorMsg('') + setShowModal(true) + } + + const elements = [ + { + header: 'Event', + width: 600, + size: 'sm', + textAlign: 'left', + view: it => + R.find(ite => R.propEq('event', ite.code, it), EVENT_OPTIONS).display + }, + { + header: 'Edit', + width: 100, + size: 'sm', + textAlign: 'center', + view: it => ( + { + setSelectedSMS(it) + setShowModal(true) + }}> + + + ) + }, + { + header: 'Delete', + width: 100, + size: 'sm', + textAlign: 'center', + view: it => ( + { + setSelectedSMS(it) + setDeleteDialog(true) + }}> + + + ) + } + ] + + return ( + <> +
+

Custom SMS message

+ + handleOpen()}> + Add custom SMS + + +
+ {showModal && ( + + )} + { + handleClose() + }} + onConfirmed={() => { + handleClose() + deleteMessage({ + variables: { + id: selectedSMS.id + } + }) + }} + errorMessage={errorMsg} + /> + + + ) +} + +export default CustomSMS diff --git a/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMS.styles.js b/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMS.styles.js new file mode 100644 index 00000000..0ef30603 --- /dev/null +++ b/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMS.styles.js @@ -0,0 +1,29 @@ +import { spacer } from 'src/styling/variables' + +const styles = { + header: { + display: 'flex', + position: 'relative', + alignItems: 'center', + justifyContent: 'space-between', + width: 800 + }, + form: { + '& > *': { + 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 new file mode 100644 index 00000000..7b993021 --- /dev/null +++ b/new-lamassu-admin/src/pages/OperatorInfo/CustomSMS/CustomSMSModal.js @@ -0,0 +1,165 @@ +import { makeStyles } from '@material-ui/core' +import { Form, Formik, Field } from 'formik' +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 { Button } from 'src/components/buttons' +import { Autocomplete, TextInput } from 'src/components/inputs/formik' + +import styles from './CustomSMS.styles' + +const useStyles = makeStyles(styles) + +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: { + 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: { + 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, + sms, + eventOptions, + creationError, + submit +}) => { + const classes = useStyles() + + const [selectedEvent, setSelectedEvent] = useState(sms?.event) + + const initialValues = { + event: !R.isNil(sms) ? sms.event : '', + message: !R.isNil(sms) ? sms.message : '' + } + + const validationSchema = Yup.object().shape({ + event: Yup.string().required('An event 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, + message: values.message + } + }) + : submit({ + variables: { + event: values.event, + message: values.message + } + }) + onClose() + } + + return ( + <> + {showModal && ( + + + handleSubmit(values, errors, touched) + }> + {({ values, errors, touched }) => ( +
+ + +
+ {getErrorMsg(errors, touched, creationError) && ( + + {getErrorMsg(errors, touched, creationError)} + + )} + +
+ + )} +
+
+ )} + + ) +} + +export default CustomSMSModal diff --git a/new-lamassu-admin/src/routing/lamassu.routes.js b/new-lamassu-admin/src/routing/lamassu.routes.js index 7394adf8..2626e30b 100644 --- a/new-lamassu-admin/src/routing/lamassu.routes.js +++ b/new-lamassu-admin/src/routing/lamassu.routes.js @@ -16,6 +16,7 @@ import MachineStatus from 'src/pages/Maintenance/MachineStatus' import Notifications from 'src/pages/Notifications/Notifications' import CoinAtmRadar from 'src/pages/OperatorInfo/CoinATMRadar' import ContactInfo from 'src/pages/OperatorInfo/ContactInfo' +import CustomSMS from 'src/pages/OperatorInfo/CustomSMS/CustomSMS' import ReceiptPrinting from 'src/pages/OperatorInfo/ReceiptPrinting' import TermsConditions from 'src/pages/OperatorInfo/TermsConditions' import ServerLogs from 'src/pages/ServerLogs' @@ -172,6 +173,13 @@ const getLamassuRoutes = () => [ allowedRoles: [ROLES.USER, ROLES.SUPERUSER], component: ReceiptPrinting }, + { + key: 'custom-sms', + label: 'Custom SMS', + route: '/settings/operator-info/custom-sms', + allowedRoles: [ROLES.USER, ROLES.SUPERUSER], + component: CustomSMS + }, { key: 'coin-atm-radar', label: 'Coin ATM Radar', diff --git a/new-lamassu-admin/src/routing/pazuz.routes.js b/new-lamassu-admin/src/routing/pazuz.routes.js index a0d45648..eaed555c 100644 --- a/new-lamassu-admin/src/routing/pazuz.routes.js +++ b/new-lamassu-admin/src/routing/pazuz.routes.js @@ -19,6 +19,7 @@ import MachineStatus from 'src/pages/Maintenance/MachineStatus' import Notifications from 'src/pages/Notifications/Notifications' import CoinAtmRadar from 'src/pages/OperatorInfo/CoinATMRadar' import ContactInfo from 'src/pages/OperatorInfo/ContactInfo' +import CustomSMS from 'src/pages/OperatorInfo/CustomSMS/CustomSMS' import ReceiptPrinting from 'src/pages/OperatorInfo/ReceiptPrinting' import TermsConditions from 'src/pages/OperatorInfo/TermsConditions' import ServerLogs from 'src/pages/ServerLogs' @@ -167,6 +168,13 @@ const getPazuzRoutes = () => [ allowedRoles: [ROLES.USER, ROLES.SUPERUSER], component: ReceiptPrinting }, + { + key: 'custom-sms', + label: 'Custom SMS', + route: '/settings/operator-info/custom-sms', + allowedRoles: [ROLES.USER, ROLES.SUPERUSER], + component: CustomSMS + }, { key: 'coin-atm-radar', label: 'Coin ATM Radar',