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 => (
+