feat: handle T&C photos and UI photos roll
This commit is contained in:
parent
ff474ee507
commit
e729de1410
12 changed files with 428 additions and 73 deletions
|
|
@ -528,7 +528,7 @@ function getCustomerById (id) {
|
|||
select c.id, c.authorized_override,
|
||||
greatest(0, date_part('day', c.suspended_until - now())) as days_suspended,
|
||||
c.suspended_until > now() as is_suspended,
|
||||
c.front_camera_path, c.front_camera_override,
|
||||
c.front_camera_path, c.front_camera_at, c.front_camera_override,
|
||||
c.phone, c.sms_override, c.id_card_data, c.id_card_data_override, c.id_card_data_expiration,
|
||||
c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions,
|
||||
c.sanctions_at, c.sanctions_override, c.subscriber_info, t.tx_class, t.fiat, t.fiat_code, t.created,
|
||||
|
|
@ -654,6 +654,59 @@ function updateIdCardData (patch, id) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} customerId customer id
|
||||
* @param {String} txId customer id
|
||||
* @param {Object} patch customer t&c photo data
|
||||
* @returns {Promise<Object>} new patch to be applied
|
||||
*/
|
||||
function updateTxCustomerPhoto (imageData) {
|
||||
return Promise.resolve(imageData)
|
||||
.then(imageData => {
|
||||
const newPatch = {}
|
||||
const directory = `${operatorDataDir}/customersphotos`
|
||||
|
||||
if (_.isEmpty(imageData)) {
|
||||
return
|
||||
}
|
||||
|
||||
// decode the base64 string to binary data
|
||||
const decodedImageData = Buffer.from(imageData, 'base64')
|
||||
|
||||
// workout the image hash
|
||||
// i.e. 240e85ff2e4bb931f235985dd0134e459239496d2b5af6c5665168d38ef89b50
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(imageData)
|
||||
.digest('hex')
|
||||
|
||||
// workout the image folder
|
||||
// i.e. 24/0e/85
|
||||
const rpath = _.join(path.sep, _.map(_.wrap(_.join, ''), _.take(3, _.chunk(2, _.split('', hash)))))
|
||||
|
||||
// i.e. ../<lamassu-server-home>/<operator-dir>/customersphotos/24/0e/85
|
||||
const dirname = path.join(directory, rpath)
|
||||
|
||||
// create the directory tree if needed
|
||||
_.attempt(() => makeDir.sync(dirname))
|
||||
|
||||
// i.e. ../<lamassu-server-home>/<operator-dir>/customersphotos/24/0e/85/240e85ff2e4bb931f235985dd01....jpg
|
||||
const filename = path.join(dirname, hash + '.jpg')
|
||||
|
||||
// update db record patch
|
||||
// i.e. {
|
||||
// "idCustomerTxPhoto": "24/0e/85/240e85ff2e4bb931f235985dd01....jpg",
|
||||
// "idCustomerTxPhotoAt": "now()"
|
||||
// }
|
||||
newPatch.txCustomerPhotoPath = path.join(rpath, hash + '.jpg')
|
||||
newPatch.txCustomerPhotoAt = 'now()'
|
||||
|
||||
// write image file
|
||||
return writeFile(filename, decodedImageData)
|
||||
.then(() => newPatch)
|
||||
})
|
||||
}
|
||||
|
||||
function updateFrontCamera (id, patch) {
|
||||
return Promise.resolve(patch)
|
||||
.then(patch => {
|
||||
|
|
@ -704,4 +757,4 @@ function updateFrontCamera (id, patch) {
|
|||
})
|
||||
}
|
||||
|
||||
module.exports = { add, get, batch, getCustomersList, getCustomerById, getById, update, updateCustomer, updatePhotoCard, updateFrontCamera, updateIdCardData }
|
||||
module.exports = { add, get, batch, getCustomersList, getCustomerById, getById, update, updateCustomer, updatePhotoCard, updateFrontCamera, updateIdCardData, updateTxCustomerPhoto }
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const { typeDefs, resolvers } = require('./graphql/schema')
|
|||
const devMode = require('minimist')(process.argv.slice(2)).dev
|
||||
const idPhotoCardBasedir = _.get('idPhotoCardDir', options)
|
||||
const frontCameraBasedir = _.get('frontCameraDir', options)
|
||||
const operatorDataBasedir = _.get('operatorDataDir', options)
|
||||
|
||||
const hostname = options.hostname
|
||||
if (!hostname) {
|
||||
|
|
@ -87,6 +88,7 @@ app.use(cors({ credentials: true, origin: devMode && 'https://localhost:3001' })
|
|||
|
||||
app.use('/id-card-photo', serveStatic(idPhotoCardBasedir, { index: false }))
|
||||
app.use('/front-camera-photo', serveStatic(frontCameraBasedir, { index: false }))
|
||||
app.use('/operator-data', serveStatic(operatorDataBasedir, { index: false }))
|
||||
|
||||
// Everything not on graphql or api/register is redirected to the front-end
|
||||
app.get('*', (req, res) => res.sendFile(path.resolve(__dirname, '..', '..', 'public', 'index.html')))
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const typeDef = gql`
|
|||
daysSuspended: Int
|
||||
isSuspended: Boolean
|
||||
frontCameraPath: String
|
||||
frontCameraAt: Date
|
||||
frontCameraOverride: String
|
||||
phone: String
|
||||
isAnonymous: Boolean
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ const typeDef = gql`
|
|||
expired: Boolean
|
||||
machineName: String
|
||||
discount: Int
|
||||
txCustomerPhotoPath: String
|
||||
txCustomerPhotoAt: Date
|
||||
}
|
||||
|
||||
type Filter {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ function batch (
|
|||
concat(c.id_card_data::json->>'firstName', ' ', c.id_card_data::json->>'lastName') AS customer_name,
|
||||
c.front_camera_path AS customer_front_camera_path,
|
||||
c.id_card_photo_path AS customer_id_card_photo_path,
|
||||
txs.tx_customer_photo_at AS tx_customer_photo_at,
|
||||
txs.tx_customer_photo_path AS tx_customer_photo_path,
|
||||
((NOT txs.send_confirmed) AND (txs.created <= now() - interval $1)) AS expired
|
||||
FROM (SELECT *, ${cashInTx.TRANSACTION_STATES} AS txStatus FROM cash_in_txs) AS txs
|
||||
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
|
||||
|
|
@ -76,6 +78,8 @@ function batch (
|
|||
concat(c.id_card_data::json->>'firstName', ' ', c.id_card_data::json->>'lastName') AS customer_name,
|
||||
c.front_camera_path AS customer_front_camera_path,
|
||||
c.id_card_photo_path AS customer_id_card_photo_path,
|
||||
txs.tx_customer_photo_at AS tx_customer_photo_at,
|
||||
txs.tx_customer_photo_path AS tx_customer_photo_path,
|
||||
(extract(epoch FROM (now() - greatest(txs.created, txs.confirmed_at))) * 1000) >= $1 AS expired
|
||||
FROM (SELECT *, ${CASH_OUT_TRANSACTION_STATES} AS txStatus FROM cash_out_txs) txs
|
||||
INNER JOIN cash_out_actions actions ON txs.id = actions.tx_id
|
||||
|
|
@ -270,4 +274,15 @@ function getTxAssociatedData (txId, txClass) {
|
|||
: db.manyOrNone(actionsSql, [txId])
|
||||
}
|
||||
|
||||
module.exports = { batch, single, cancel, getCustomerTransactionsBatch, getTx, getTxAssociatedData }
|
||||
function updateTxCustomerPhoto (customerId, txId, direction, data) {
|
||||
const formattedData = _.mapKeys(_.snakeCase, data)
|
||||
const cashInSql = 'UPDATE cash_in_txs SET tx_customer_photo_at = $1, tx_customer_photo_path = $2 WHERE customer_id=$3 AND id=$4'
|
||||
|
||||
const cashOutSql = 'UPDATE cash_out_txs SET tx_customer_photo_at = $1, tx_customer_photo_path = $2 WHERE customer_id=$3 AND id=$4'
|
||||
|
||||
return direction === 'cashIn'
|
||||
? db.oneOrNone(cashInSql, [formattedData.tx_customer_photo_at, formattedData.tx_customer_photo_path, customerId, txId])
|
||||
: db.oneOrNone(cashOutSql, [formattedData.tx_customer_photo_at, formattedData.tx_customer_photo_path, customerId, txId])
|
||||
}
|
||||
|
||||
module.exports = { batch, single, cancel, getCustomerTransactionsBatch, getTx, getTxAssociatedData, updateTxCustomerPhoto }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const compliance = require('../compliance')
|
|||
const complianceTriggers = require('../compliance-triggers')
|
||||
const configManager = require('../new-config-manager')
|
||||
const customers = require('../customers')
|
||||
const txs = require('../new-admin/services/transactions')
|
||||
const httpError = require('../route-helpers').httpError
|
||||
const notifier = require('../notifier')
|
||||
const respond = require('../respond')
|
||||
|
|
@ -99,10 +100,27 @@ function triggerSuspend (req, res, next) {
|
|||
.catch(next)
|
||||
}
|
||||
|
||||
function updateTxCustomerPhoto (req, res, next) {
|
||||
const customerId = req.params.id
|
||||
const txId = req.params.txId
|
||||
const tcPhotoData = req.body.tcPhotoData
|
||||
const direction = req.body.direction
|
||||
|
||||
Promise.all([customers.getById(customerId), txs.getTx(txId, direction)])
|
||||
.then(([customer, tx]) => {
|
||||
if (!customer || !tx) { throw httpError('Not Found', 404) }
|
||||
return customers.updateTxCustomerPhoto(tcPhotoData)
|
||||
.then(newPatch => txs.updateTxCustomerPhoto(customerId, txId, direction, newPatch))
|
||||
})
|
||||
.then(() => respond(req, res, {}))
|
||||
.catch(next)
|
||||
}
|
||||
|
||||
router.patch('/:id', updateCustomer)
|
||||
router.patch('/:id/sanctions', triggerSanctions)
|
||||
router.patch('/:id/block', triggerBlock)
|
||||
router.patch('/:id/suspend', triggerSuspend)
|
||||
router.patch('/:id/photos/idcarddata', updateIdCardData)
|
||||
router.patch('/:id/:txId/photos/customerphoto', updateTxCustomerPhoto)
|
||||
|
||||
module.exports = router
|
||||
|
|
|
|||
13
migrations/1627563019030-add-customer-tc-photo-path.js
Normal file
13
migrations/1627563019030-add-customer-tc-photo-path.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
const db = require('./db')
|
||||
|
||||
exports.up = function (next) {
|
||||
const sql = [
|
||||
'ALTER TABLE cash_in_txs ADD COLUMN tx_customer_photo_at timestamptz, ADD COLUMN tx_customer_photo_path text',
|
||||
'ALTER TABLE cash_out_txs ADD COLUMN tx_customer_photo_at timestamptz, ADD COLUMN tx_customer_photo_path text'
|
||||
]
|
||||
db.multi(sql, next)
|
||||
}
|
||||
|
||||
exports.down = function (next) {
|
||||
next()
|
||||
}
|
||||
67
new-lamassu-admin/src/components/InformativeDialog.js
Normal file
67
new-lamassu-admin/src/components/InformativeDialog.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { Dialog, DialogContent, makeStyles } from '@material-ui/core'
|
||||
import React, { memo } from 'react'
|
||||
|
||||
import { IconButton } from 'src/components/buttons'
|
||||
import { H4, P } from 'src/components/typography'
|
||||
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
|
||||
import { spacer } from 'src/styling/variables'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
dialogContent: {
|
||||
width: 434,
|
||||
padding: spacer * 2,
|
||||
paddingRight: spacer * 3.5
|
||||
},
|
||||
dialogTitle: {
|
||||
padding: spacer * 2,
|
||||
paddingRight: spacer * 1.5,
|
||||
display: 'flex',
|
||||
'justify-content': 'space-between',
|
||||
'& > h4': {
|
||||
margin: 0
|
||||
},
|
||||
'& > button': {
|
||||
padding: 0,
|
||||
marginTop: -(spacer / 2)
|
||||
}
|
||||
},
|
||||
dialogActions: {
|
||||
padding: spacer * 4,
|
||||
paddingTop: spacer * 2
|
||||
}
|
||||
})
|
||||
|
||||
export const DialogTitle = ({ children, onClose }) => {
|
||||
const classes = useStyles()
|
||||
return (
|
||||
<div className={classes.dialogTitle}>
|
||||
{children}
|
||||
{onClose && (
|
||||
<IconButton size={16} aria-label="close" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const InformativeDialog = memo(
|
||||
({ title = '', open, onDissmised, disabled = false, data, ...props }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
const innerOnClose = () => {
|
||||
onDissmised()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} aria-labelledby="form-dialog-title" {...props}>
|
||||
<DialogTitle id="customized-dialog-title" onClose={innerOnClose}>
|
||||
<H4>{title}</H4>
|
||||
</DialogTitle>
|
||||
<DialogContent className={classes.dialogContent}>
|
||||
{data && <P>{data}</P>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -35,6 +35,7 @@ const GET_CUSTOMER = gql`
|
|||
id
|
||||
authorizedOverride
|
||||
frontCameraPath
|
||||
frontCameraAt
|
||||
frontCameraOverride
|
||||
phone
|
||||
isAnonymous
|
||||
|
|
@ -67,6 +68,8 @@ const GET_CUSTOMER = gql`
|
|||
created
|
||||
errorMessage: error
|
||||
error: errorCode
|
||||
txCustomerPhotoAt
|
||||
txCustomerPhotoPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -168,6 +171,7 @@ const CustomerProfile = memo(() => {
|
|||
justifyContent="space-between">
|
||||
<CustomerDetails
|
||||
customer={customerData}
|
||||
txData={sortedTransactions}
|
||||
locale={locale}
|
||||
setShowCompliance={() => setShowCompliance(!showCompliance)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@ import { ReactComponent as LawIcon } from 'src/styling/icons/circle buttons/law/
|
|||
import mainStyles from '../CustomersList.styles'
|
||||
import { getFormattedPhone, getName } from '../helper'
|
||||
|
||||
import FrontCameraPhoto from './FrontCameraPhoto'
|
||||
import PhotosCard from './PhotosCard'
|
||||
|
||||
const useStyles = makeStyles(mainStyles)
|
||||
|
||||
const CustomerDetails = memo(({ customer, locale, setShowCompliance }) => {
|
||||
const CustomerDetails = memo(
|
||||
({ txData, customer, locale, setShowCompliance }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
const idNumber = R.path(['idCardData', 'documentNumber'])(customer)
|
||||
|
|
@ -47,8 +48,9 @@ const CustomerDetails = memo(({ customer, locale, setShowCompliance }) => {
|
|||
|
||||
return (
|
||||
<Box display="flex">
|
||||
<FrontCameraPhoto
|
||||
<PhotosCard
|
||||
frontCameraPath={R.path(['frontCameraPath'])(customer)}
|
||||
txData={txData}
|
||||
/>
|
||||
<Box display="flex" flexDirection="column">
|
||||
<div className={classes.name}>
|
||||
|
|
@ -56,7 +58,10 @@ const CustomerDetails = memo(({ customer, locale, setShowCompliance }) => {
|
|||
<H2 noMargin>
|
||||
{name.length
|
||||
? name
|
||||
: getFormattedPhone(R.path(['phone'])(customer), locale.country)}
|
||||
: getFormattedPhone(
|
||||
R.path(['phone'])(customer),
|
||||
locale.country
|
||||
)}
|
||||
</H2>
|
||||
<SubpageButton
|
||||
className={classes.subpageButton}
|
||||
|
|
@ -91,6 +96,7 @@ const CustomerDetails = memo(({ customer, locale, setShowCompliance }) => {
|
|||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export default CustomerDetails
|
||||
|
|
|
|||
106
new-lamassu-admin/src/pages/Customers/components/PhotosCard.js
Normal file
106
new-lamassu-admin/src/pages/Customers/components/PhotosCard.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import ButtonBase from '@material-ui/core/ButtonBase'
|
||||
import Paper from '@material-ui/core/Card'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo, useState } from 'react'
|
||||
|
||||
import { InformativeDialog } from 'src/components/InformativeDialog'
|
||||
import { Info2, Label1 } from 'src/components/typography'
|
||||
import { ReactComponent as CrossedCameraIcon } from 'src/styling/icons/ID/photo/crossed-camera.svg'
|
||||
import { URI } from 'src/utils/apollo'
|
||||
|
||||
import CopyToClipboard from '../../Transactions/CopyToClipboard'
|
||||
|
||||
import styles from './PhotosCard.styles'
|
||||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const Label = ({ children }) => {
|
||||
const classes = useStyles()
|
||||
return <Label1 className={classes.label}>{children}</Label1>
|
||||
}
|
||||
|
||||
const PhotosCard = memo(({ frontCameraPath, txData }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
const [photosDialog, setPhotosDialog] = useState(false)
|
||||
|
||||
const txsWithCustomerPhoto = R.filter(
|
||||
tx => !R.isNil(tx.txCustomerPhotoAt) && !R.isNil(tx.txCustomerPhotoPath)
|
||||
)(txData)
|
||||
|
||||
const photoDir = frontCameraPath
|
||||
? 'front-camera-photo'
|
||||
: 'operator-data/customersphotos'
|
||||
|
||||
const photo =
|
||||
frontCameraPath ?? R.head(txsWithCustomerPhoto)?.txCustomerPhotoPath
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper className={classes.photo} elevation={0}>
|
||||
<ButtonBase
|
||||
className={classes.button}
|
||||
onClick={() => {
|
||||
setPhotosDialog(true)
|
||||
}}>
|
||||
{photo ? (
|
||||
<div className={classes.container}>
|
||||
<img
|
||||
className={classes.img}
|
||||
src={`${URI}/${photoDir}/${photo}`}
|
||||
alt=""
|
||||
/>
|
||||
<circle className={classes.circle}>
|
||||
<div>
|
||||
<Info2>{txsWithCustomerPhoto.length}</Info2>
|
||||
</div>
|
||||
</circle>
|
||||
</div>
|
||||
) : (
|
||||
<CrossedCameraIcon />
|
||||
)}
|
||||
</ButtonBase>
|
||||
</Paper>
|
||||
<InformativeDialog
|
||||
open={photosDialog}
|
||||
title={`Photos roll`}
|
||||
data={<PhotosCarousel txData={txData}></PhotosCarousel>}
|
||||
onDissmised={() => {
|
||||
setPhotosDialog(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export const PhotosCarousel = memo(({ txData }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.firstRow}>
|
||||
<div>
|
||||
<div>
|
||||
<Label>Session ID</Label>
|
||||
<CopyToClipboard>{txData && R.head(txData)?.id}</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.secondRow}>
|
||||
<div>
|
||||
<div>
|
||||
<Label>Date</Label>
|
||||
<div>{txData && R.head(txData)?.txCustomerPhotoAt}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Taken by</Label>
|
||||
<div>{'Acceptance of T&C'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default PhotosCard
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import typographyStyles from 'src/components/typography/styles'
|
||||
import { zircon, backgroundColor, offColor } from 'src/styling/variables'
|
||||
|
||||
const { p } = typographyStyles
|
||||
|
||||
export default {
|
||||
photo: {
|
||||
width: 135,
|
||||
height: 135,
|
||||
borderRadius: 8,
|
||||
backgroundColor: zircon,
|
||||
margin: [[0, 28, 0, 0]],
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: 'flex'
|
||||
},
|
||||
img: {
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center',
|
||||
width: 135,
|
||||
height: 135
|
||||
},
|
||||
container: {
|
||||
position: 'relative',
|
||||
'& > img': {
|
||||
display: 'block'
|
||||
},
|
||||
'& > circle': {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
right: '0',
|
||||
marginRight: 5,
|
||||
marginTop: 5
|
||||
}
|
||||
},
|
||||
circle: {
|
||||
background: backgroundColor,
|
||||
borderRadius: '50%',
|
||||
width: 25,
|
||||
height: 25,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: 'flex'
|
||||
},
|
||||
label: {
|
||||
color: offColor,
|
||||
margin: [[0, 0, 6, 0]]
|
||||
},
|
||||
firstRow: {
|
||||
padding: [[8]],
|
||||
marginBottom: 10
|
||||
},
|
||||
secondRow: {
|
||||
extend: p,
|
||||
display: 'flex',
|
||||
padding: [[8]],
|
||||
'& > div': {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& > div': {
|
||||
width: 144,
|
||||
height: 37,
|
||||
marginBottom: 15,
|
||||
marginRight: 55
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue