From e729de141088507d5d968b063276126c97971c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Oliveira?= Date: Sun, 1 Aug 2021 02:46:13 +0100 Subject: [PATCH] feat: handle T&C photos and UI photos roll --- lib/customers.js | 57 ++++++- lib/new-admin/admin-server.js | 2 + lib/new-admin/graphql/types/customer.type.js | 1 + .../graphql/types/transaction.type.js | 2 + lib/new-admin/services/transactions.js | 17 +- lib/routes/customerRoutes.js | 18 +++ ...627563019030-add-customer-tc-photo-path.js | 13 ++ .../src/components/InformativeDialog.js | 67 ++++++++ .../src/pages/Customers/CustomerProfile.js | 4 + .../Customers/components/CustomerDetails.js | 146 +++++++++--------- .../pages/Customers/components/PhotosCard.js | 106 +++++++++++++ .../Customers/components/PhotosCard.styles.js | 68 ++++++++ 12 files changed, 428 insertions(+), 73 deletions(-) create mode 100644 migrations/1627563019030-add-customer-tc-photo-path.js create mode 100644 new-lamassu-admin/src/components/InformativeDialog.js create mode 100644 new-lamassu-admin/src/pages/Customers/components/PhotosCard.js create mode 100644 new-lamassu-admin/src/pages/Customers/components/PhotosCard.styles.js diff --git a/lib/customers.js b/lib/customers.js index ae76e032..ce3d7efe 100644 --- a/lib/customers.js +++ b/lib/customers.js @@ -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} 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. ..///customersphotos/24/0e/85 + const dirname = path.join(directory, rpath) + + // create the directory tree if needed + _.attempt(() => makeDir.sync(dirname)) + + // i.e. ..///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 } diff --git a/lib/new-admin/admin-server.js b/lib/new-admin/admin-server.js index c2538924..8ebb8faa 100644 --- a/lib/new-admin/admin-server.js +++ b/lib/new-admin/admin-server.js @@ -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'))) diff --git a/lib/new-admin/graphql/types/customer.type.js b/lib/new-admin/graphql/types/customer.type.js index 6b933489..c6f5f7a7 100644 --- a/lib/new-admin/graphql/types/customer.type.js +++ b/lib/new-admin/graphql/types/customer.type.js @@ -7,6 +7,7 @@ const typeDef = gql` daysSuspended: Int isSuspended: Boolean frontCameraPath: String + frontCameraAt: Date frontCameraOverride: String phone: String isAnonymous: Boolean diff --git a/lib/new-admin/graphql/types/transaction.type.js b/lib/new-admin/graphql/types/transaction.type.js index a50f0494..82d1c16c 100644 --- a/lib/new-admin/graphql/types/transaction.type.js +++ b/lib/new-admin/graphql/types/transaction.type.js @@ -43,6 +43,8 @@ const typeDef = gql` expired: Boolean machineName: String discount: Int + txCustomerPhotoPath: String + txCustomerPhotoAt: Date } type Filter { diff --git a/lib/new-admin/services/transactions.js b/lib/new-admin/services/transactions.js index 84630d33..482068ff 100644 --- a/lib/new-admin/services/transactions.js +++ b/lib/new-admin/services/transactions.js @@ -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 } diff --git a/lib/routes/customerRoutes.js b/lib/routes/customerRoutes.js index 510a6882..0fe6915e 100644 --- a/lib/routes/customerRoutes.js +++ b/lib/routes/customerRoutes.js @@ -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 diff --git a/migrations/1627563019030-add-customer-tc-photo-path.js b/migrations/1627563019030-add-customer-tc-photo-path.js new file mode 100644 index 00000000..73b3378a --- /dev/null +++ b/migrations/1627563019030-add-customer-tc-photo-path.js @@ -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() +} diff --git a/new-lamassu-admin/src/components/InformativeDialog.js b/new-lamassu-admin/src/components/InformativeDialog.js new file mode 100644 index 00000000..c3c54c65 --- /dev/null +++ b/new-lamassu-admin/src/components/InformativeDialog.js @@ -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 ( +
+ {children} + {onClose && ( + + + + )} +
+ ) +} + +export const InformativeDialog = memo( + ({ title = '', open, onDissmised, disabled = false, data, ...props }) => { + const classes = useStyles() + + const innerOnClose = () => { + onDissmised() + } + + return ( + + +

{title}

+
+ + {data &&

{data}

} +
+
+ ) + } +) diff --git a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js index 6649acd8..361ae26e 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js @@ -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"> setShowCompliance(!showCompliance)} /> diff --git a/new-lamassu-admin/src/pages/Customers/components/CustomerDetails.js b/new-lamassu-admin/src/pages/Customers/components/CustomerDetails.js index 8d215c21..e0939993 100644 --- a/new-lamassu-admin/src/pages/Customers/components/CustomerDetails.js +++ b/new-lamassu-admin/src/pages/Customers/components/CustomerDetails.js @@ -11,86 +11,92 @@ 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 classes = useStyles() +const CustomerDetails = memo( + ({ txData, customer, locale, setShowCompliance }) => { + const classes = useStyles() - const idNumber = R.path(['idCardData', 'documentNumber'])(customer) - const usSsn = R.path(['usSsn'])(customer) + const idNumber = R.path(['idCardData', 'documentNumber'])(customer) + const usSsn = R.path(['usSsn'])(customer) - const elements = [ - { - header: 'Phone number', - size: 172, - value: getFormattedPhone(customer.phone, locale.country) - } - ] + const elements = [ + { + header: 'Phone number', + size: 172, + value: getFormattedPhone(customer.phone, locale.country) + } + ] - if (idNumber) - elements.push({ - header: 'ID number', - size: 172, - value: idNumber - }) + if (idNumber) + elements.push({ + header: 'ID number', + size: 172, + value: idNumber + }) - if (usSsn) - elements.push({ - header: 'US SSN', - size: 127, - value: usSsn - }) + if (usSsn) + elements.push({ + header: 'US SSN', + size: 127, + value: usSsn + }) - const name = getName(customer) + const name = getName(customer) - return ( - - - -
- -

- {name.length - ? name - : getFormattedPhone(R.path(['phone'])(customer), locale.country)} -

- - Compliance details - -
- - {elements.map(({ size, header }, idx) => ( - - {header} - - ))} - - - {elements.map(({ size, value }, idx) => ( -

- {value} -

- ))} + return ( + + + +
+ +

+ {name.length + ? name + : getFormattedPhone( + R.path(['phone'])(customer), + locale.country + )} +

+ + Compliance details + +
+ + {elements.map(({ size, header }, idx) => ( + + {header} + + ))} + + + {elements.map(({ size, value }, idx) => ( +

+ {value} +

+ ))} +
-
- ) -}) + ) + } +) export default CustomerDetails diff --git a/new-lamassu-admin/src/pages/Customers/components/PhotosCard.js b/new-lamassu-admin/src/pages/Customers/components/PhotosCard.js new file mode 100644 index 00000000..4e6a4eb9 --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/components/PhotosCard.js @@ -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 {children} +} + +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 ( + <> + + { + setPhotosDialog(true) + }}> + {photo ? ( +
+ + +
+ {txsWithCustomerPhoto.length} +
+
+
+ ) : ( + + )} +
+
+ } + onDissmised={() => { + setPhotosDialog(false) + }} + /> + + ) +}) + +export const PhotosCarousel = memo(({ txData }) => { + const classes = useStyles() + + return ( + <> +
+
+
+ + {txData && R.head(txData)?.id} +
+
+
+
+
+
+ +
{txData && R.head(txData)?.txCustomerPhotoAt}
+
+
+
+ +
{'Acceptance of T&C'}
+
+
+ + ) +}) + +export default PhotosCard diff --git a/new-lamassu-admin/src/pages/Customers/components/PhotosCard.styles.js b/new-lamassu-admin/src/pages/Customers/components/PhotosCard.styles.js new file mode 100644 index 00000000..251e461f --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/components/PhotosCard.styles.js @@ -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 + } + } + } +}