feat: scripting-like tags on custom sms content

feat: add created column to custom_messages
feat: custom message dynamic validators and testing
feat: delete custom sms
feat: employ custom sms to existing events
This commit is contained in:
Sérgio Salgado 2021-07-30 05:28:03 +01:00
parent 3480bbf8f7
commit 54b73b95b4
10 changed files with 444 additions and 95 deletions

View file

@ -1,17 +1,50 @@
const _ = require('lodash/fp')
const uuid = require('uuid') const uuid = require('uuid')
const db = require('./db') const db = require('./db')
const getCustomMessages = () => { const getCustomMessages = () => {
const sql = `SELECT * FROM custom_messages` const sql = `SELECT * FROM custom_messages ORDER BY created`
return db.any(sql) 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 createCustomMessage = (event, deviceId, message) => {
const sql = `INSERT INTO custom_message (event, device_id, message) VALUES ($2, $3, $4)` const machineId = deviceId === 'ALL_MACHINES' ? null : deviceId
return db.none(sql, [uuid.v4(), event, deviceId, message]) 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 = { module.exports = {
getCustomMessages, getCustomMessages,
createCustomMessage createCustomMessage,
editCustomMessage,
deleteCustomMessage,
getCommonCustomMessages,
getMachineCustomMessages
} }

View file

@ -5,7 +5,9 @@ const resolvers = {
customMessages: () => customSms.getCustomMessages() customMessages: () => customSms.getCustomMessages()
}, },
Mutation: { 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)
} }
} }

View file

@ -19,6 +19,8 @@ const typeDef = gql`
type Mutation { type Mutation {
createCustomMessage(event: CustomMessageEvent!, deviceId: String, message: String!): CustomMessage @auth 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
} }
` `

View file

@ -361,11 +361,10 @@ function plugins (settings, deviceId) {
const phone = tx.phone const phone = tx.phone
const timestamp = dateFormat(new Date(), 'UTC:HH:MM Z') const timestamp = dateFormat(new Date(), 'UTC:HH:MM Z')
return sms.getCashOutReadySms(deviceId, phone, code)
.then(msg => {
const rec = { const rec = {
sms: { sms: msg
toNumber: phone,
body: `Your cash is waiting! Go to the Cryptomat and press Redeem within 24 hours. [${timestamp}]`
}
} }
return sms.sendMessage(settings, rec) return sms.sendMessage(settings, rec)
@ -375,6 +374,7 @@ function plugins (settings, deviceId) {
return db.none(sql, values) return db.none(sql, values)
}) })
})
} }
function notifyOperator (tx, rec) { function notifyOperator (tx, rec) {
@ -723,15 +723,15 @@ function plugins (settings, deviceId) {
? '123' ? '123'
: randomCode() : randomCode()
return sms.getPhoneCodeSms(deviceId, phone, code)
.then(msg => {
const rec = { const rec = {
sms: { sms: msg
toNumber: phone,
body: 'Your cryptomat code: ' + code
}
} }
return sms.sendMessage(settings, rec) return sms.sendMessage(settings, rec)
.then(() => code) .then(() => code)
})
} }
function sweepHdRow (row) { function sweepHdRow (row) {

View file

@ -1,6 +1,67 @@
const ph = require('./plugin-helper') const ph = require('./plugin-helper')
const argv = require('minimist')(process.argv.slice(2)) const argv = require('minimist')(process.argv.slice(2))
const { utils: coinUtils } = require('lamassu-coins') 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) { function getPlugin (settings) {
const pluginCode = argv.mockSms ? 'mock-sms' : 'twilio' const pluginCode = argv.mockSms ? 'mock-sms' : 'twilio'
@ -91,4 +152,11 @@ function formatSmsReceipt (data, options) {
return request return request
} }
module.exports = { sendMessage, formatSmsReceipt, getLookup, toCryptoUnits } module.exports = {
getPhoneCodeSms,
getCashOutReadySms,
sendMessage,
getLookup,
formatSmsReceipt,
toCryptoUnits
}

View file

@ -7,9 +7,11 @@ exports.up = function (next) {
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
event custom_message_event NOT NULL, event custom_message_event NOT NULL,
device_id TEXT REFERENCES devices(device_id), 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) db.multi(sql, next)

View file

@ -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 { makeStyles, Box } from '@material-ui/core'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import * as R from 'ramda' import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import { DeleteDialog } from 'src/components/DeleteDialog'
import { Link, IconButton } from 'src/components/buttons' import { Link, IconButton } from 'src/components/buttons'
import DataTable from 'src/components/tables/DataTable' import DataTable from 'src/components/tables/DataTable'
import { H4 } from 'src/components/typography' import { H4 } from 'src/components/typography'
import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg' 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 { 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' import CustomSMSModal from './CustomSMSModal'
const useStyles = makeStyles(global) const useStyles = makeStyles(styles)
const GET_CUSTOM_MESSAGES = gql` const GET_CUSTOM_MESSAGES = gql`
query customMessages { 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` const GET_MACHINES = gql`
{ {
machines { 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 CustomSMS = () => {
const classes = useStyles() const classes = useStyles()
const [deleteDialog, setDeleteDialog] = useState(false)
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [selectedSMS, setSelectedSMS] = useState(null)
const [errorMsg, setErrorMsg] = useState('')
const { data: messagesData, loading: messagesLoading } = useQuery( const { data: messagesData, loading: messagesLoading } = useQuery(
GET_CUSTOM_MESSAGES GET_CUSTOM_MESSAGES
) )
const { data: machinesData, loading: machinesLoading } = useQuery( const { data: machinesData, loading: machinesLoading } = useQuery(
GET_MACHINES 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 loading = messagesLoading && machinesLoading
const machineOptions = const machineOptions =
machinesData && (machinesData &&
R.map( R.map(
it => ({ code: it.deviceId, display: it.name }), it => ({ code: it.deviceId, display: it.name }),
R.path(['machines'])(machinesData) R.path(['machines'])(machinesData)
) )) ??
[]
const handleClose = () => {
setSelectedSMS(null)
setShowModal(false)
setDeleteDialog(false)
}
const handleOpen = () => {
setErrorMsg('')
setShowModal(true)
}
const elements = [ const elements = [
{ {
header: 'Message name', header: 'Event',
width: 400, width: 400,
size: 'sm', size: 'sm',
textAlign: 'left', 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', header: 'Edit',
width: 120, width: 100,
size: 'sm', size: 'sm',
textAlign: 'center', textAlign: 'center',
view: it => ( view: it => (
<IconButton <IconButton
onClick={() => { onClick={() => {
console.log('edit') setSelectedSMS(it)
setShowModal(true)
}}> }}>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
@ -80,13 +166,14 @@ const CustomSMS = () => {
}, },
{ {
header: 'Delete', header: 'Delete',
width: 120, width: 100,
size: 'sm', size: 'sm',
textAlign: 'center', textAlign: 'center',
view: it => ( view: it => (
<IconButton <IconButton
onClick={() => { onClick={() => {
console.log('delete') setSelectedSMS(it)
setDeleteDialog(true)
}}> }}>
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
@ -96,13 +183,10 @@ const CustomSMS = () => {
return ( return (
<> <>
<div className={classes.headerWithLink}> <div className={classes.header}>
<H4>Custom SMS message</H4> <H4>Custom SMS message</H4>
<Box <Box display="flex" justifyContent="flex-end">
className={classes.tableWidth} <Link color="primary" onClick={() => handleOpen()}>
display="flex"
justifyContent="flex-end">
<Link color="primary" onClick={() => setShowModal(true)}>
Add custom SMS Add custom SMS
</Link> </Link>
</Box> </Box>
@ -110,10 +194,29 @@ const CustomSMS = () => {
{showModal && ( {showModal && (
<CustomSMSModal <CustomSMSModal
showModal={showModal} showModal={showModal}
onClose={() => setShowModal(false)} onClose={handleClose}
machineOptions={machineOptions} machineOptions={machineOptions}
eventOptions={EVENT_OPTIONS}
sms={selectedSMS}
creationError={errorMsg}
submit={selectedSMS ? editMessage : createMessage}
/> />
)} )}
<DeleteDialog
open={deleteDialog}
onDismissed={() => {
handleClose()
}}
onConfirmed={() => {
handleClose()
deleteMessage({
variables: {
id: selectedSMS.id
}
})
}}
errorMessage={errorMsg}
/>
<DataTable <DataTable
emptyText="No custom SMS so far" emptyText="No custom SMS so far"
elements={elements} elements={elements}

View file

@ -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

View file

@ -1,40 +1,128 @@
import { makeStyles } from '@material-ui/core'
import { Form, Formik, Field } from 'formik' 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 * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal' 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 = [ import styles from './CustomSMS.styles'
{ code: 'sms_code', display: 'On SMS confirmation code' },
{ code: 'cash_out_dispense_ready', display: 'Cash out dispense ready' } 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 = ({ const CustomSMSModal = ({
showModal, showModal,
onClose, onClose,
customMessage, sms,
machineOptions machineOptions,
eventOptions,
creationError,
submit
}) => { }) => {
const classes = useStyles()
const [selectedEvent, setSelectedEvent] = useState(sms?.event)
const initialValues = { const initialValues = {
event: '', event: !R.isNil(sms) ? sms.event : '',
device: '', device: !R.isNil(sms)
message: '' ? !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!'), event: Yup.string().required('An event is required!'),
device: Yup.string(), device: Yup.string().required('A machine is required!'),
message: Yup.string() message:
prefill[selectedEvent]?.validator ??
Yup.string()
.required('The message content is required!') .required('The message content is required!')
.trim() .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 ( return (
<> <>
{showModal && ( {showModal && (
<Modal <Modal
title="Add custom SMS" title={!R.isNil(sms) ? `Edit custom SMS` : `Add custom SMS`}
closeOnBackdropClick={true} closeOnBackdropClick={true}
width={600} width={600}
height={500} height={500}
@ -44,25 +132,54 @@ const CustomSMSModal = ({
validateOnBlur={false} validateOnBlur={false}
validateOnChange={false} validateOnChange={false}
initialValues={initialValues} initialValues={initialValues}
validationSchema={validationSchema}> validationSchema={validationSchema}
<Form id="custom-sms"> onSubmit={(values, errors, touched) =>
handleSubmit(values, errors, touched)
}>
{({ values, errors, touched }) => (
<Form id="custom-sms" className={classes.form}>
<Field <Field
name="event" name="event"
label="Event"
fullWidth fullWidth
options={EVENT_OPTIONS} onChange={setSelectedEvent(values.event)}
options={eventOptions}
labelProp="display" labelProp="display"
valueProp="code" valueProp="code"
component={Autocomplete} component={Autocomplete}
/> />
<Field <Field
name="device" name="device"
label="Machine"
fullWidth fullWidth
options={machineOptions} options={[ALL_MACHINES].concat(machineOptions)}
labelProp="display" labelProp="display"
valueProp="code" valueProp="code"
component={Autocomplete} component={Autocomplete}
/> />
<Field
name="message"
label="Message content"
fullWidth
multiline={true}
rows={6}
component={TextInput}
/>
<div className={classes.footer}>
{getErrorMsg(errors, touched, creationError) && (
<ErrorMessage>
{getErrorMsg(errors, touched, creationError)}
</ErrorMessage>
)}
<Button
type="submit"
form="custom-sms"
className={classes.submit}>
{!R.isNil(sms) ? `Confirm` : `Create SMS`}
</Button>
</div>
</Form> </Form>
)}
</Formik> </Formik>
</Modal> </Modal>
)} )}

View file

@ -10,13 +10,6 @@ const global = {
position: 'relative', position: 'relative',
flex: 'wrap' flex: 'wrap'
}, },
headerWithLink: {
display: 'flex',
position: 'relative',
alignItems: 'center',
justifyContent: 'space-between',
width: 640
},
section: { section: {
marginBottom: 52 marginBottom: 52
}, },