Merge pull request #813 from chaotixkilla/feat-custom-sms

Custom SMS
This commit is contained in:
Rafael Taranto 2021-11-24 17:42:27 +00:00 committed by GitHub
commit e57bccf750
14 changed files with 565 additions and 24 deletions

View file

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

41
lib/custom-sms.js Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.getSms(CONFIRMATION_CODE, phone, { code })
.then(smsObj => {
const rec = {
sms: smsObj
}
return sms.sendMessage(settings, rec)
.then(() => code)
return sms.sendMessage(settings, rec)
.then(() => code)
})
}
function sweepHdRow (row) {

View file

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

View file

@ -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()
}

View file

@ -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 => (
<IconButton
onClick={() => {
setSelectedSMS(it)
setShowModal(true)
}}>
<EditIcon />
</IconButton>
)
},
{
header: 'Delete',
width: 100,
size: 'sm',
textAlign: 'center',
view: it => (
<IconButton
onClick={() => {
setSelectedSMS(it)
setDeleteDialog(true)
}}>
<DeleteIcon />
</IconButton>
)
}
]
return (
<>
<div className={classes.header}>
<H4>Custom SMS message</H4>
<Box display="flex" justifyContent="flex-end">
<Link color="primary" onClick={() => handleOpen()}>
Add custom SMS
</Link>
</Box>
</div>
{showModal && (
<CustomSMSModal
showModal={showModal}
onClose={handleClose}
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
emptyText="No custom SMS so far"
elements={elements}
loading={loading}
data={R.path(['customMessages'])(messagesData)}
/>
</>
)
}
export default CustomSMS

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

@ -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 && (
<Modal
title={!R.isNil(sms) ? `Edit custom SMS` : `Add custom SMS`}
closeOnBackdropClick={true}
width={600}
height={500}
open={true}
handleClose={onClose}>
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values, errors, touched) =>
handleSubmit(values, errors, touched)
}>
{({ values, errors, touched }) => (
<Form id="custom-sms" className={classes.form}>
<Field
name="event"
label="Event"
fullWidth
onChange={setSelectedEvent(values.event)}
options={eventOptions}
labelProp="display"
valueProp="code"
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>
)}
</Formik>
</Modal>
)}
</>
)
}
export default CustomSMSModal

View file

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

View file

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