Merge pull request #909 from josepfo/feat/edit-customer-data

Feat: edit customer data
This commit is contained in:
Rafael Taranto 2021-12-07 16:41:48 +00:00 committed by GitHub
commit 7087781cfc
23 changed files with 789 additions and 210 deletions

View file

@ -138,6 +138,154 @@ async function updateCustomer (id, data, userToken) {
return getCustomerById(id)
}
/**
* Update all customer record
*
* @name save
* @function
*
* @param {string} id Customer's id
* @param {object} data Fields to update
*
* @returns {Promise} Newly updated Customer
*/
function edit (id, data, userToken) {
const defaults = [
'front_camera',
'id_card_data',
'id_card_photo',
'us_ssn',
'subscriber_info',
'name'
]
const filteredData = _.pick(defaults, _.mapKeys(_.snakeCase, _.omitBy(_.isNil, data)))
if (_.isEmpty(filteredData)) return getCustomerById(id)
const formattedData = enhanceEditedPhotos(enhanceEditedFields(filteredData, userToken))
const defaultDbData = {
customer_id: id,
created: new Date(),
...formattedData
}
const cs = new Pgp.helpers.ColumnSet(_.keys(defaultDbData),
{ table: 'edited_customer_data' })
const onConflict = ' ON CONFLICT (customer_id) DO UPDATE SET ' +
cs.assignColumns({ from: 'EXCLUDED', skip: ['customer_id', 'created'] })
const upsert = Pgp.helpers.insert(defaultDbData, cs) + onConflict
return db.none(upsert)
.then(getCustomerById(id))
}
/**
* Add *edited_by and *edited_at fields with acting user's token
* and date of override respectively before saving to db.
*
* @name enhanceEditedFields
* @function
*
* @param {object} fields Fields to be enhanced
* @param {string} userToken Acting user's token
* @returns {object} fields enhanced with *_by and *_at fields
*/
function enhanceEditedFields (fields, userToken) {
if (!userToken) return fields
_.mapKeys((field) => {
fields[field + '_by'] = userToken
fields[field + '_at'] = 'now()^'
}, fields)
return fields
}
/**
* Add *_path to edited photos fields
*
* @name enhanceEditedFields
* @function
*
* @param {object} fields Fields to be enhanced
* @returns {object} fields enhanced with *_path
*/
function enhanceEditedPhotos (fields) {
return _.mapKeys((field) => {
if (_.includes(field, ['front_camera', 'id_card_photo'])) {
return field + '_path'
}
return field
}, fields)
}
/**
* Remove the edited data from the db record
*
* @name enhanceOverrideFields
* @function
*
* @param {string} id Customer's id
* @param {object} data Fields to be deleted
*
* @returns {Promise} Newly updated Customer
*
*/
function deleteEditedData (id, data) {
// TODO: NOT IMPLEMENTING THIS FEATURE FOR THE CURRENT VERSION
const defaults = [
'front_camera',
'id_card_data',
'id_card_photo',
'us_ssn',
'subcriber_info',
'name'
]
const filteredData = _.pick(defaults, _.mapKeys(_.snakeCase, data))
if (_.isEmpty(filteredData)) return getCustomerById(id)
const cs = new Pgp.helpers.ColumnSet(_.keys(filteredData),
{ table: 'edited_customer_data' })
const update = Pgp.helpers.update(filteredData, cs)
db.none(update)
return getCustomerById(id)
}
/**
* Replace customer's compliance photos
*
* @name save
* @function
*
* @param {string} id Customer's id
* @param {File} photo New photo data
* @param {string} photoType Photo's compliance type
*
* @returns {object} path New photo path
*
*/
async function updateEditedPhoto (id, photo, photoType) {
const newPatch = {}
const baseDir = photoType === 'frontCamera' ? frontCameraBaseDir : idPhotoCardBasedir
const { createReadStream, filename } = photo
const stream = createReadStream()
const randomString = uuid.v4().toString() + '/'
// i.e. ..62ed29c5-f37e-4fb7-95bb-c52d4a3738f7/filename.jpg
const rpath = path.join(randomString, filename)
// create the directory tree if needed
_.attempt(() => makeDir.sync(path.join(baseDir, randomString)))
// i.e. ../<lamassu-server-home>/idphotocard/62ed29c5-f37e-4fb7-95bb-c52d4a3738f7/filename.jpg
const pathName = path.join(baseDir, rpath)
await stream.pipe(fs.createWriteStream(pathName))
newPatch[photoType] = rpath
return newPatch
}
const invalidateCustomerNotifications = (id, data) => {
if (data.authorized_override !== 'verified') return Promise.resolve()
@ -489,9 +637,8 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
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, t.tx_class, t.fiat, t.fiat_code, t.created,
row_number() OVER (partition by c.id order by t.created desc) AS rn,
coalesce(sum(case when error_code is null or error_code not in ($1^) then t.fiat else 0 end) over (partition by c.id), 0) as total_spent,
sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (partition by c.id) AS total_txs,
ccf.custom_fields
coalesce(sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (partition by c.id), 0) AS total_spent, ccf.custom_fields
FROM customers c LEFT OUTER JOIN (
SELECT 'cashIn' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_in_txs WHERE send_confirmed = true UNION
@ -518,17 +665,17 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
}
/**
* Query all customers, ordered by last activity
* Query a specific customer, ordered by last activity
* and with aggregate columns based on their
* transactions
*
* @returns {array} Array of customers with it's transactions aggregations
* @returns {array} A single customer instance with non edited
*/
function getCustomerById (id) {
const passableErrorCodes = _.map(Pgp.as.text, TX_PASSTHROUGH_ERROR_CODES).join(',')
const sql = `select id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_at, front_camera_override,
phone, sms_override, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at,
const sql = `select id, authorized_override, days_suspended, is_suspended, front_camera_at, front_camera_path, front_camera_at, front_camera_override,
phone, sms_override, id_card_data_at, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_at, id_card_photo_path, id_card_photo_override, us_ssn_at, us_ssn, us_ssn_override, sanctions, sanctions_at,
sanctions_override, total_txs, total_spent, created as last_active, fiat as last_tx_fiat,
fiat_code as last_tx_fiat_code, tx_class as last_tx_class, subscriber_info, custom_fields
from (
@ -536,8 +683,8 @@ function getCustomerById (id) {
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_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.phone, c.sms_override, c.id_card_data, c.id_card_data_at, c.id_card_data_override, c.id_card_data_expiration,
c.id_card_photo_path, c.id_card_photo_at, c.id_card_photo_override, c.us_ssn, c.us_ssn_at, 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,
row_number() over (partition by c.id order by t.created desc) as rn,
sum(case when t.id is not null then 1 else 0 end) over (partition by c.id) as total_txs,
@ -556,10 +703,52 @@ function getCustomerById (id) {
where c.id = $2
) as cl where rn = 1`
return db.oneOrNone(sql, [passableErrorCodes, id])
.then(customerData => {
return getEditedData(id)
.then(customerEditedData => selectLatestData(customerData, customerEditedData))
})
.then(populateOverrideUsernames)
.then(camelize)
}
/**
* Query the specific customer manually edited data
*
* @param {String} id customer id
*
* @returns {array} A single customer instance with the most recent edited data
*/
function getEditedData (id) {
const sql = `SELECT * FROM edited_customer_data WHERE customer_id = $1`
return db.oneOrNone(sql, [id])
.then(_.omitBy(_.isNil))
}
function selectLatestData (customerData, customerEditedData) {
const defaults = [
'front_camera',
'id_card_data',
'id_card_photo',
'us_ssn',
'subscriber_info',
'name'
]
_.map(field => {
let fieldName = field
if (_.includes(field, ['front_camera', 'id_card_photo'])) fieldName = fieldName + '_path'
const atField = field + '_at'
const byField = field + '_by'
if (!_.has(fieldName, customerData) || !_.has(fieldName, customerEditedData)) return
if (customerData[atField] < customerEditedData[atField]) {
customerData[fieldName] = customerEditedData[fieldName]
customerData[atField] = customerEditedData[atField]
customerData[byField] = customerEditedData[byField]
}
}
, defaults)
return customerData
}
/**
* @param {String} id customer id
* @param {Object} patch customer update record
@ -827,5 +1016,8 @@ module.exports = {
addCustomField,
saveCustomField,
removeCustomField,
edit,
deleteEditedData,
updateEditedPhoto,
updateTxCustomerPhoto
}

View file

@ -9,6 +9,7 @@ const helmet = require('helmet')
const nocache = require('nocache')
const cookieParser = require('cookie-parser')
const { ApolloServer, AuthenticationError } = require('apollo-server-express')
const { graphqlUploadExpress } = require('graphql-upload')
const _ = require('lodash/fp')
const { asyncLocalStorage, defaultStore } = require('../async-storage')
@ -47,10 +48,12 @@ app.use(cleanUserSessions(USER_SESSIONS_CLEAR_INTERVAL))
app.use(computeSchema)
app.use(findOperatorId)
app.use(session)
app.use(graphqlUploadExpress())
const apolloServer = new ApolloServer({
typeDefs,
resolvers,
uploads: false,
schemaDirectives: {
auth: AuthDirective
},

View file

@ -3,6 +3,7 @@ const customers = require('../../../customers')
const filters = require('../../filters')
const resolvers = {
Customer: {
isAnonymous: parent => (parent.customerId === anonymous.uuid)
},
@ -13,13 +14,32 @@ const resolvers = {
},
Mutation: {
setCustomer: (root, { customerId, customerInput }, context, info) => {
// TODO: To be replaced by function that fetchs the token
const token = !!context.req.cookies.lamassu_sid && context.req.session.user.id
if (customerId === anonymous.uuid) return customers.getCustomerById(customerId)
return customers.updateCustomer(customerId, customerInput, token)
},
addCustomField: (...[, { customerId, label, value }]) => customers.addCustomField(customerId, label, value),
saveCustomField: (...[, { customerId, fieldId, newValue }]) => customers.saveCustomField(customerId, fieldId, newValue),
removeCustomField: (...[, [ { customerId, fieldId } ]]) => customers.removeCustomField(customerId, fieldId)
removeCustomField: (...[, [ { customerId, fieldId } ]]) => customers.removeCustomField(customerId, fieldId),
editCustomer: async (root, { customerId, customerEdit }, context) => {
// TODO: To be replaced by function that fetchs the token
const token = !!context.req.cookies.lid && context.req.session.user.id
const editedData = await customerEdit
return customers.edit(customerId, editedData, token)
},
replacePhoto: async (root, { customerId, photoType, newPhoto }, context) => {
// TODO: To be replaced by function that fetchs the token
const token = !!context.req.cookies.lid && context.req.session.user.id
const photo = await newPhoto
if (!photo) return customers.getCustomerById(customerId)
return customers.updateEditedPhoto(customerId, photo, photoType)
.then(newPatch => customers.edit(customerId, newPatch, token))
},
deleteEditedData: (root, { customerId, customerEdit }) => {
// TODO: NOT IMPLEMENTING THIS FEATURE FOR THE CURRENT VERSION
return customers.getCustomerById(customerId)
}
}
}

View file

@ -1,12 +1,13 @@
const { GraphQLDateTime } = require('graphql-iso-date')
const { GraphQLJSON, GraphQLJSONObject } = require('graphql-type-json')
const { GraphQLUpload } = require('graphql-upload')
GraphQLDateTime.name = 'Date'
const resolvers = {
JSON: GraphQLJSON,
JSONObject: GraphQLJSONObject,
Date: GraphQLDateTime
Date: GraphQLDateTime,
UploadGQL: GraphQLUpload
}
module.exports = resolvers

View file

@ -12,6 +12,8 @@ const typeDef = gql`
authorizedOverride: String
daysSuspended: Int
isSuspended: Boolean
newPhoto: UploadGQL
photoType: String
frontCameraPath: String
frontCameraAt: Date
frontCameraOverride: String
@ -21,6 +23,7 @@ const typeDef = gql`
idCardData: JSONObject
idCardDataOverride: String
idCardDataExpiration: Date
idCardPhoto: UploadGQL
idCardPhotoPath: String
idCardPhotoOverride: String
usSsn: String
@ -65,6 +68,12 @@ const typeDef = gql`
subscriberInfo: Boolean
}
input CustomerEdit {
idCardData: JSONObject
idCardPhoto: UploadGQL
usSsn: String
}
type Query {
customers(phone: String, name: String, address: String, id: String): [Customer] @auth
customer(customerId: ID!): Customer @auth
@ -76,6 +85,9 @@ const typeDef = gql`
addCustomField(customerId: ID!, label: String!, value: String!): CustomerCustomField @auth
saveCustomField(customerId: ID!, fieldId: ID!, value: String!): CustomerCustomField @auth
removeCustomField(customerId: ID!, fieldId: ID!): CustomerCustomField @auth
editCustomer(customerId: ID!, customerEdit: CustomerEdit): Customer @auth
deleteEditedData(customerId: ID!, customerEdit: CustomerEdit): Customer @auth
replacePhoto(customerId: ID!, photoType: String, newPhoto: UploadGQL): Customer @auth
}
`

View file

@ -4,6 +4,7 @@ const typeDef = gql`
scalar JSON
scalar JSONObject
scalar Date
scalar UploadGQL
`
module.exports = typeDef

View file

@ -0,0 +1,33 @@
const db = require('./db')
exports.up = function (next) {
var sql = [
`CREATE TABLE edited_customer_data (
customer_id uuid PRIMARY KEY REFERENCES customers(id),
id_card_data JSON,
id_card_data_at TIMESTAMPTZ,
id_card_data_by UUID REFERENCES users(id),
front_camera_path TEXT,
front_camera_at TIMESTAMPTZ,
front_camera_by UUID REFERENCES users(id),
id_card_photo_path TEXT,
id_card_photo_at TIMESTAMPTZ,
id_card_photo_by UUID REFERENCES users(id),
subscriber_info JSON,
subscriber_info_at TIMESTAMPTZ,
subscriber_info_by UUID REFERENCES users(id),
name TEXT,
name_at TIMESTAMPTZ,
name_by UUID REFERENCES users(id),
us_ssn TEXT,
us_ssn_at TIMESTAMPTZ,
us_ssn_by UUID REFERENCES users(id),
created TIMESTAMPTZ NOT NULL DEFAULT now() )`
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -6888,6 +6888,14 @@
"tslib": "^1.9.3"
}
},
"apollo-upload-client": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/apollo-upload-client/-/apollo-upload-client-16.0.0.tgz",
"integrity": "sha512-aLhYucyA0T8aBEQ5g+p13qnR9RUyL8xqb8FSZ7e/Kw2KUOsotLUlFluLobqaE7JSUFwc6sKfXIcwB7y4yEjbZg==",
"requires": {
"extract-files": "^11.0.0"
}
},
"apollo-utilities": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/apollo-utilities/-/apollo-utilities-1.3.4.tgz",
@ -12608,6 +12616,11 @@
}
}
},
"extract-files": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/extract-files/-/extract-files-11.0.0.tgz",
"integrity": "sha512-FuoE1qtbJ4bBVvv94CC7s0oTnKUGvQs+Rjf1L2SJFfS+HTVVjhPFtehPdQ0JiGPqVNfSSZvL5yzHHQq2Z4WNhQ=="
},
"extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",

View file

@ -14,6 +14,7 @@
"apollo-link": "^1.2.14",
"apollo-link-error": "^1.1.13",
"apollo-link-http": "^1.5.17",
"apollo-upload-client": "^16.0.0",
"axios": "0.21.1",
"base-64": "^1.0.0",
"bignumber.js": "9.0.0",

View file

@ -12,7 +12,9 @@ const ActionButton = memo(
const classNames = {
[classes.actionButton]: true,
[classes.primary]: color === 'primary',
[classes.secondary]: color === 'secondary'
[classes.secondary]: color === 'secondary',
[classes.spring]: color === 'spring',
[classes.tomato]: color === 'tomato'
}
return (

View file

@ -1,11 +1,17 @@
import typographyStyles from 'src/components/typography/styles'
import {
white,
fontColor,
subheaderColor,
subheaderDarkColor,
offColor,
offDarkColor
offDarkColor,
offDarkerColor,
secondaryColor,
secondaryColorDark,
secondaryColorDarker,
errorColor,
errorColorDark,
errorColorDarker
} from 'src/styling/variables'
const { p } = typographyStyles
@ -50,10 +56,45 @@ export default {
}
},
secondary: {
extend: colors(offColor, offDarkColor, white),
extend: colors(offColor, offDarkColor, offDarkerColor),
color: white,
'&:active': {
'& $actionButtonIcon': {
display: 'flex'
},
'& $actionButtonIconActive': {
display: 'none'
}
},
'& $actionButtonIcon': {
display: 'none'
},
'& $actionButtonIconActive': {
display: 'flex'
}
},
spring: {
extend: colors(secondaryColorDark, secondaryColor, secondaryColorDarker),
color: white,
'&:active': {
'& $actionButtonIcon': {
display: 'flex'
},
'& $actionButtonIconActive': {
display: 'none'
}
},
'& $actionButtonIcon': {
display: 'none'
},
'& $actionButtonIconActive': {
display: 'flex'
}
},
tomato: {
extend: colors(errorColorDark, errorColor, errorColorDarker),
color: white,
'&:active': {
color: fontColor,
'& $actionButtonIcon': {
display: 'flex'
},

View file

@ -31,7 +31,7 @@ import { getName } from './helper.js'
const useStyles = makeStyles(styles)
const IMAGE_WIDTH = 165
const IMAGE_HEIGHT = 45
const IMAGE_HEIGHT = 32
const POPUP_IMAGE_WIDTH = 360
const POPUP_IMAGE_HEIGHT = 240
@ -57,7 +57,12 @@ const Photo = ({ show, src }) => {
)
}
const CustomerData = ({ customer, updateCustomer }) => {
const CustomerData = ({
customer,
updateCustomer,
editCustomer,
deleteEditedData
}) => {
const classes = useStyles()
const [listView, setListView] = useState(false)
@ -75,11 +80,14 @@ const CustomerData = ({ customer, updateCustomer }) => {
: 'Failed'
const customEntries = null // get customer custom entries
const customRequirements = null // get customer custom requirements
const isEven = elem => elem % 2 === 0
const getVisibleCards = _.filter(
elem => !_.isEmpty(elem.data) || !_.isNil(elem.children)
elem =>
!_.isEmpty(elem.fields) ||
(!_.isNil(elem.children) && !_.isNil(elem.state))
)
const getAvailableFields = _.filter(({ value }) => value !== '')
@ -96,6 +104,12 @@ const CustomerData = ({ customer, updateCustomer }) => {
}),
usSsn: Yup.object().shape({
usSsn: Yup.string()
}),
idCardPhoto: Yup.object().shape({
idCardPhoto: Yup.mixed()
}),
frontCamera: Yup.object().shape({
frontCamera: Yup.mixed()
})
}
@ -103,96 +117,98 @@ const CustomerData = ({ customer, updateCustomer }) => {
{
name: 'name',
label: 'Name',
value: `${getName(customer)}`,
component: TextInput
},
{
name: 'idNumber',
label: 'ID number',
value: R.path(['documentNumber'])(idData) ?? '',
component: TextInput
},
{
name: 'birthDate',
label: 'Birth Date',
value:
(rawDob &&
format('yyyy-MM-dd')(parse(new Date(), 'yyyyMMdd', rawDob))) ??
'',
component: TextInput
},
{
name: 'age',
label: 'Age',
value:
(rawDob &&
differenceInYears(
parse(new Date(), 'yyyyMMdd', rawDob),
new Date()
)) ??
'',
component: TextInput
},
{
name: 'gender',
label: 'Gender',
value: R.path(['gender'])(idData) ?? '',
component: TextInput
},
{
name: 'state',
label: country === 'Canada' ? 'Province' : 'State',
value: R.path(['state'])(idData) ?? '',
component: TextInput
},
{
name: 'expirationDate',
label: 'Expiration Date',
value:
(rawExpirationDate &&
format('yyyy-MM-dd')(
parse(new Date(), 'yyyyMMdd', rawExpirationDate)
)) ??
'',
component: TextInput
}
]
const usSsnElements = [
{
name: 'us ssn',
name: 'usSsn',
label: 'US SSN',
value: `${customer.usSsn ?? ''}`,
component: TextInput,
size: 190
}
]
const idCardPhotoElements = [{ name: 'idCardPhoto' }]
const frontCameraElements = [{ name: 'frontCamera' }]
const initialValues = {
idScan: {
name: '',
idNumber: '',
birthDate: '',
age: '',
gender: '',
state: '',
expirationDate: ''
name: getName(customer) ?? '',
idNumber: R.path(['documentNumber'])(idData) ?? '',
birthDate:
(rawDob &&
format('yyyy-MM-dd')(parse(new Date(), 'yyyyMMdd', rawDob))) ??
'',
age:
(rawDob &&
differenceInYears(
parse(new Date(), 'yyyyMMdd', rawDob),
new Date()
)) ??
'',
gender: R.path(['gender'])(idData) ?? '',
state: R.path(['state'])(idData) ?? '',
expirationDate:
(rawExpirationDate &&
format('yyyy-MM-dd')(
parse(new Date(), 'yyyyMMdd', rawExpirationDate)
)) ??
''
},
usSsn: {
usSsn: ''
usSsn: customer.usSsn ?? ''
},
frontCamera: {
frontCamera: null
},
idCardPhoto: {
idCardPhoto: null
}
}
const cards = [
{
data: getAvailableFields(idScanElements),
fields: getAvailableFields(idScanElements),
title: 'ID Scan',
titleIcon: <PhoneIcon className={classes.cardIcon} />,
state: R.path(['idCardDataOverride'])(customer),
authorize: () =>
updateCustomer({ idCardDataOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ idCardDataOverride: OVERRIDE_REJECTED }),
save: values => console.log(values),
deleteEditedData: () => deleteEditedData({ idCardData: null }),
save: values => editCustomer({ idCardData: values }),
validationSchema: schemas.idScan,
initialValues: initialValues.idScan
},
@ -217,17 +233,18 @@ const CustomerData = ({ customer, updateCustomer }) => {
authorize: () =>
updateCustomer({ sanctionsOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ sanctionsOverride: OVERRIDE_REJECTED }),
save: () => {},
children: <Info3>{sanctionsDisplay}</Info3>
},
{
fields: getAvailableFields(frontCameraElements),
title: 'Front facing camera',
titleIcon: <EditIcon className={classes.editIcon} />,
state: R.path(['frontCameraOverride'])(customer),
authorize: () =>
updateCustomer({ frontCameraOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ frontCameraOverride: OVERRIDE_REJECTED }),
save: () => {},
save: values => editCustomer({ frontCamera: values.frontCamera }),
deleteEditedData: () => deleteEditedData({ frontCamera: null }),
children: customer.frontCameraPath ? (
<Photo
show={customer.frontCameraPath}
@ -235,31 +252,40 @@ const CustomerData = ({ customer, updateCustomer }) => {
customer
)}`}
/>
) : null
) : null,
hasImage: true,
validationSchema: schemas.frontCamera,
initialValues: initialValues.frontCamera
},
{
fields: getAvailableFields(idCardPhotoElements),
title: 'ID card image',
titleIcon: <EditIcon className={classes.editIcon} />,
state: R.path(['idCardPhotoOverride'])(customer),
authorize: () =>
updateCustomer({ idCardPhotoOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ idCardPhotoOverride: OVERRIDE_REJECTED }),
save: () => {},
save: values => editCustomer({ idCardPhoto: values.idCardPhoto }),
deleteEditedData: () => deleteEditedData({ idCardPhoto: null }),
children: customer.idCardPhotoPath ? (
<Photo
show={customer.idCardPhotoPath}
src={`${URI}/id-card-photo/${R.path(['idCardPhotoPath'])(customer)}`}
/>
) : null
) : null,
hasImage: true,
validationSchema: schemas.idCardPhoto,
initialValues: initialValues.idCardPhoto
},
{
data: getAvailableFields(usSsnElements),
fields: getAvailableFields(usSsnElements),
title: 'US SSN',
titleIcon: <CardIcon className={classes.cardIcon} />,
state: R.path(['usSsnOverride'])(customer),
authorize: () => updateCustomer({ usSsnOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ usSsnOverride: OVERRIDE_REJECTED }),
save: () => {},
save: values => editCustomer({ usSsn: values.usSsn }),
deleteEditedData: () => deleteEditedData({ usSsn: null }),
validationSchema: schemas.usSsn,
initialValues: initialValues.usSsn
}
@ -272,11 +298,13 @@ const CustomerData = ({ customer, updateCustomer }) => {
reject,
state,
titleIcon,
data,
fields,
save,
deleteEditedData,
children,
validationSchema,
initialValues
initialValues,
hasImage
},
idx
) => {
@ -288,11 +316,13 @@ const CustomerData = ({ customer, updateCustomer }) => {
reject={reject}
state={state}
titleIcon={titleIcon}
data={data}
hasImage={hasImage}
fields={fields}
children={children}
validationSchema={validationSchema}
initialValues={initialValues}
save={save}></EditableCard>
save={save}
deleteEditedData={deleteEditedData}></EditableCard>
)
}
@ -317,7 +347,7 @@ const CustomerData = ({ customer, updateCustomer }) => {
onClick={() => setListView(true)}></FeatureButton>
</div>
<div>
{!listView && (
{!listView && customer && (
<Grid container>
<Grid container direction="column" item xs={6}>
{visibleCards.map((elem, idx) => {
@ -333,7 +363,12 @@ const CustomerData = ({ customer, updateCustomer }) => {
)}
{customEntries && (
<div className={classes.wrapper}>
<div className={classes.separator}>{'Custom data entry'}</div>
<span className={classes.separator}>Custom data entry</span>
</div>
)}
{customRequirements && (
<div className={classes.wrapper}>
<span className={classes.separator}>Custom requirements</span>
</div>
)}
</div>

View file

@ -20,22 +20,30 @@ export default {
marginRight: 12
},
wrapper: {
display: 'flex'
display: 'block',
overflow: 'hidden',
whiteSpace: 'nowrap'
},
separator: {
display: 'flex',
flexBasis: '100%',
justifyContent: 'center',
color: offColor,
margin: [[8, 0, 8, 0]],
'&::before, &::after': {
content: '',
flexGrow: 1,
margin: [[8, 0, 8, 150]],
position: 'relative',
display: 'inline-block',
'&:before, &:after': {
content: '""',
position: 'absolute',
background: offColor,
height: 1,
fontSize: 1,
lineHeight: 0,
margin: [[0, 8, 0, 8]]
top: '50%',
width: 1000,
height: 1
},
'&:before': {
right: '100%',
marginRight: 15
},
'&:after': {
left: '100%',
marginLeft: 15
}
}
}

View file

@ -116,6 +116,45 @@ const SET_CUSTOMER = gql`
}
}
`
const EDIT_CUSTOMER = gql`
mutation editCustomer($customerId: ID!, $customerEdit: CustomerEdit) {
editCustomer(customerId: $customerId, customerEdit: $customerEdit) {
id
idCardData
usSsn
}
}
`
const REPLACE_CUSTOMER_PHOTO = gql`
mutation replacePhoto(
$customerId: ID!
$photoType: String
$newPhoto: Upload
) {
replacePhoto(
customerId: $customerId
photoType: $photoType
newPhoto: $newPhoto
) {
id
newPhoto
photoType
}
}
`
const DELETE_EDITED_CUSTOMER = gql`
mutation deleteEditedData($customerId: ID!, $customerEdit: CustomerEdit) {
deleteEditedData(customerId: $customerId, customerEdit: $customerEdit) {
id
frontCameraPath
idCardData
idCardPhotoPath
usSsn
}
}
`
const CustomerProfile = memo(() => {
const history = useHistory()
@ -133,6 +172,18 @@ const CustomerProfile = memo(() => {
}
)
const [replaceCustomerPhoto] = useMutation(REPLACE_CUSTOMER_PHOTO, {
onCompleted: () => getCustomer()
})
const [editCustomerData] = useMutation(EDIT_CUSTOMER, {
onCompleted: () => getCustomer()
})
const [deleteCustomerEditedData] = useMutation(DELETE_EDITED_CUSTOMER, {
onCompleted: () => getCustomer()
})
const [setCustomer] = useMutation(SET_CUSTOMER, {
onCompleted: () => getCustomer()
})
@ -145,6 +196,31 @@ const CustomerProfile = memo(() => {
}
})
const replacePhoto = it =>
replaceCustomerPhoto({
variables: {
customerId,
newPhoto: it.newPhoto,
photoType: it.photoType
}
})
const editCustomer = it =>
editCustomerData({
variables: {
customerId,
customerEdit: it
}
})
const deleteEditedData = it =>
deleteCustomerEditedData({
variables: {
customerId,
customerEdit: it
}
})
const onClickSidebarItem = code => setClickedItem(code)
const configData = R.path(['config'])(customerResponse) ?? []
@ -248,6 +324,7 @@ const CustomerProfile = memo(() => {
</ActionButton>
<ActionButton
color="primary"
className={classes.retrieveInformation}
Icon={blocked ? AuthorizeIcon : BlockIcon}
InverseIcon={
blocked ? AuthorizeReversedIcon : BlockReversedIcon
@ -295,7 +372,10 @@ const CustomerProfile = memo(() => {
<div>
<CustomerData
customer={customerData}
updateCustomer={updateCustomer}></CustomerData>
updateCustomer={updateCustomer}
replacePhoto={replacePhoto}
editCustomer={editCustomer}
deleteEditedData={deleteEditedData}></CustomerData>
</div>
)}
</div>

View file

@ -33,6 +33,12 @@ export default {
margin: [[8, 0, 4, 0]],
padding: [[0, 40.5, 0]]
},
retrieveInformation: {
display: 'flex',
flexDirection: 'row',
margin: [[0, 0, 4, 0]],
padding: [[0, 32.5, 0]]
},
panels: {
display: 'flex'
},

View file

@ -30,10 +30,10 @@ export default {
color: white,
backgroundColor: offDarkColor,
'&:first-child': {
borderRadius: [5, 5, 0, 0]
borderRadius: [[5, 5, 0, 0]]
},
'&:last-child': {
borderRadius: [0, 0, 5, 5]
borderRadius: [[0, 0, 5, 5]]
}
},
icon: {

View file

@ -2,6 +2,7 @@ import { CardContent, Card, Grid } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import classnames from 'classnames'
import { Form, Formik, Field as FormikField } from 'formik'
import * as R from 'ramda'
import { useState, React } from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
@ -9,20 +10,21 @@ import PromptWhenDirty from 'src/components/PromptWhenDirty'
import { MainStatus } from 'src/components/Status'
import { Tooltip } from 'src/components/Tooltip'
import { ActionButton } from 'src/components/buttons'
import { Label1, Info3, H3 } from 'src/components/typography'
import { Label1, P, H3 } from 'src/components/typography'
import {
OVERRIDE_AUTHORIZED,
OVERRIDE_REJECTED,
OVERRIDE_PENDING
} from 'src/pages/Customers/components/propertyCard'
import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg'
import { ReactComponent as DeleteReversedIcon } from 'src/styling/icons/action/delete/white.svg'
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
import { ReactComponent as EditReversedIcon } from 'src/styling/icons/action/edit/white.svg'
import { ReactComponent as AuthorizeReversedIcon } from 'src/styling/icons/button/authorize/white.svg'
import { ReactComponent as AuthorizeIcon } from 'src/styling/icons/button/authorize/zodiac.svg'
import { ReactComponent as AuthorizeIcon } from 'src/styling/icons/button/authorize/white.svg'
import { ReactComponent as BlockIcon } from 'src/styling/icons/button/block/white.svg'
import { ReactComponent as CancelReversedIcon } from 'src/styling/icons/button/cancel/white.svg'
import { ReactComponent as CancelIcon } from 'src/styling/icons/button/cancel/zodiac.svg'
import { ReactComponent as ReplaceReversedIcon } from 'src/styling/icons/button/replace/white.svg'
import { ReactComponent as SaveReversedIcon } from 'src/styling/icons/circle buttons/save/white.svg'
import { ReactComponent as SaveIcon } from 'src/styling/icons/circle buttons/save/zodiac.svg'
import { comet } from 'src/styling/variables'
import styles from './EditableCard.styles.js'
@ -34,7 +36,8 @@ const fieldStyles = {
position: 'relative',
width: 280,
height: 48,
padding: [[0, 4, 4, 0]]
padding: [[0, 4, 4, 0]],
marginTop: 2
},
label: {
color: comet,
@ -60,7 +63,8 @@ const fieldStyles = {
editing: {
'& > div': {
'& > input': {
padding: 0
padding: 0,
fontSize: 14
}
}
}
@ -68,9 +72,8 @@ const fieldStyles = {
const fieldUseStyles = makeStyles(fieldStyles)
const EditableField = ({ editing, field, size, ...props }) => {
const EditableField = ({ editing, field, value, size, ...props }) => {
const classes = fieldUseStyles()
const classNames = {
[classes.field]: true,
[classes.notEditing]: !editing
@ -81,7 +84,7 @@ const EditableField = ({ editing, field, size, ...props }) => {
{!editing && (
<>
<Label1 className={classes.label}>{field.label}</Label1>
<Info3>{field.value}</Info3>
<P>{value}</P>
</>
)}
{editing && (
@ -90,8 +93,8 @@ const EditableField = ({ editing, field, size, ...props }) => {
<FormikField
className={classes.editing}
id={field.name}
name={field.name}
component={field.component}
value={field.value}
type={field.type}
width={size}
{...props}
@ -103,27 +106,33 @@ const EditableField = ({ editing, field, size, ...props }) => {
}
const EditableCard = ({
data,
fields,
save,
authorize,
hasImage,
reject,
state,
title,
titleIcon,
children
children,
validationSchema,
initialValues,
deleteEditedData
}) => {
const classes = useStyles()
const [editing, setEditing] = useState(false)
const [input, setInput] = useState(null)
const [error, setError] = useState(null)
const triggerInput = () => input.click()
const label1ClassNames = {
[classes.label1]: true,
[classes.label1Pending]: state === OVERRIDE_PENDING,
[classes.label1Rejected]: state === OVERRIDE_REJECTED,
[classes.label1Accepted]: state === OVERRIDE_AUTHORIZED
}
const authorized =
state === OVERRIDE_PENDING
? { label: 'Pending', type: 'neutral' }
@ -131,51 +140,84 @@ const EditableCard = ({
? { label: 'Rejected', type: 'error' }
: { label: 'Accepted', type: 'success' }
const editableField = field => {
return <EditableField field={field} editing={editing} size={180} />
}
return (
<div>
<Card className={classes.card}>
<CardContent>
<div className={classes.headerWrapper}>
<div className={classes.cardHeader}>
{titleIcon}
<H3 className={classes.cardTitle}>{title}</H3>
<Tooltip width={304}></Tooltip>
</div>
{state && (
<div className={classnames(label1ClassNames)}>
<MainStatus statuses={[authorized]} />
</div>
)}
</div>
{children}
<Formik
validateOnBlur={false}
validateOnChange={false}
enableReinitialize
onSubmit={values => save(values)}
validationSchema={validationSchema}
initialValues={initialValues}
onSubmit={values => {
save(values)
setEditing(false)
}}
onReset={() => {
setEditing(false)
setError(false)
}}>
{({ values, touched, errors, setFieldValue }) => (
<Form>
<PromptWhenDirty />
<div className={classes.row}>
<Grid container>
<Grid container direction="column" item xs={6}>
{data?.map((field, idx) => {
return idx >= 0 && idx < 4 ? editableField(field) : null
{!hasImage &&
fields?.map((field, idx) => {
return idx >= 0 && idx < 4 ? (
<EditableField
field={field}
value={initialValues[field.name]}
editing={editing}
size={180}
/>
) : null
})}
</Grid>
<Grid container direction="column" item xs={6}>
{data?.map((field, idx) => {
return idx >= 4 ? editableField(field) : null
{!hasImage &&
fields?.map((field, idx) => {
return idx >= 4 ? (
<EditableField
field={field}
value={initialValues[field.name]}
editing={editing}
size={180}
/>
) : null
})}
</Grid>
</Grid>
</div>
{children}
<div className={classes.edit}>
{!editing && (
<div className={classes.editButton}>
<div className={classes.deleteButton}>
<ActionButton
color="primary"
type="button"
Icon={DeleteIcon}
InverseIcon={DeleteReversedIcon}
onClick={() => deleteEditedData()}>
{`Delete`}
</ActionButton>
</div>
<ActionButton
color="primary"
Icon={EditIcon}
@ -186,12 +228,42 @@ const EditableCard = ({
</div>
)}
{editing && (
<div className={classes.editingWrapper}>
<div className={classes.replace}>
{hasImage && (
<ActionButton
color="secondary"
type="button"
Icon={ReplaceReversedIcon}
InverseIcon={ReplaceReversedIcon}
onClick={() => triggerInput()}>
{
<div>
<input
type="file"
alt=""
accept="image/*"
className={classes.input}
ref={fileInput => setInput(fileInput)}
onChange={event => {
// need to store it locally if we want to display it even after saving to db
const file = R.head(event.target.files)
if (!file) return
setFieldValue(R.head(fields).name, file)
}}
/>
Replace
</div>
}
</ActionButton>
)}
</div>
<div className={classes.editingButtons}>
{data && (
{fields && (
<div className={classes.button}>
<ActionButton
color="secondary"
Icon={SaveIcon}
Icon={SaveReversedIcon}
InverseIcon={SaveReversedIcon}
type="submit">
Save
@ -201,7 +273,7 @@ const EditableCard = ({
<div className={classes.button}>
<ActionButton
color="secondary"
Icon={CancelIcon}
Icon={CancelReversedIcon}
InverseIcon={CancelReversedIcon}
type="reset">
Cancel
@ -210,32 +282,34 @@ const EditableCard = ({
{authorized.label !== 'Accepted' && (
<div className={classes.button}>
<ActionButton
color="secondary"
color="spring"
type="button"
Icon={AuthorizeIcon}
InverseIcon={AuthorizeReversedIcon}
type="submit"
InverseIcon={AuthorizeIcon}
onClick={() => authorize()}>
{'Authorize'}
Authorize
</ActionButton>
</div>
)}
{authorized.label !== 'Rejected' && (
<ActionButton
color="secondary"
Icon={CancelIcon}
InverseIcon={CancelReversedIcon}
type="submit"
color="tomato"
type="button"
Icon={BlockIcon}
InverseIcon={BlockIcon}
onClick={() => reject()}>
{'Reject'}
Reject
</ActionButton>
)}
{error && (
<ErrorMessage>Failed to save changes</ErrorMessage>
)}
</div>
</div>
)}
</div>
</Form>
)}
</Formik>
</CardContent>
</Card>

View file

@ -16,15 +16,35 @@ export default {
color: spring4
},
editButton: {
marginTop: 30,
marginTop: 20,
display: 'flex',
justifyContent: 'right'
},
button: {
deleteButton: {
marginRight: 8
},
headerWrapper: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
height: 40
},
editingWrapper: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 20
},
replace: {
marginRight: 5
},
input: {
display: 'none'
},
button: {
marginRight: 5
},
editingButtons: {
marginTop: 30,
display: 'flex',
justifyContent: 'right'
},

View file

@ -75,7 +75,6 @@ const TransactionsList = ({ customer, data, loading, locale }) => {
view: R.path(['machineName'])
},
{
header: 'Direction',
width: 125,
view: it => (
<>

View file

@ -7,6 +7,7 @@ const spring = '#48f694'
// Secondary
const comet = '#5f668a'
const comet2 = '#72799d'
const comet3 = '#525772'
const spring2 = '#44e188'
const spring3 = '#ecfbef'
const spring4 = '#3fd07e'
@ -25,6 +26,8 @@ const white = '#ffffff'
// Error
const tomato = '#ff584a'
const tomato1 = '#E45043'
const tomato2 = '#CE463A'
const mistyRose = '#ffeceb'
const pumpkin = '#ff7311'
const linen = '#fbf3ec'
@ -45,8 +48,11 @@ const disabledColor2 = concrete
const fontColor = primaryColor
const offColor = comet
const offDarkColor = comet2
const offDarkerColor = comet3
const placeholderColor = comet
const errorColor = tomato
const errorColorDark = tomato1
const errorColorDarker = tomato2
const offErrorColor = mistyRose
const inputBorderColor = primaryColor
@ -142,12 +148,15 @@ export {
placeholderColor,
offColor,
offDarkColor,
offDarkerColor,
fontColor,
disabledColor,
disabledColor2,
linkPrimaryColor,
linkSecondaryColor,
errorColor,
errorColorDarker,
errorColorDark,
offErrorColor,
inputBorderColor,
// font sizes

View file

@ -3,7 +3,7 @@ import { InMemoryCache } from 'apollo-cache-inmemory'
import { ApolloClient } from 'apollo-client'
import { ApolloLink } from 'apollo-link'
import { onError } from 'apollo-link-error'
import { HttpLink } from 'apollo-link-http'
import { createUploadLink } from 'apollo-upload-client'
import React, { useContext } from 'react'
import { useHistory, useLocation } from 'react-router-dom'
@ -15,6 +15,16 @@ const URI =
const ALT_URI =
process.env.NODE_ENV === 'development' ? 'http://localhost:4001' : ''
const uploadLink = createUploadLink({
credentials: 'include',
uri: `${URI}/graphql`
})
const uploadLinkALT = createUploadLink({
credentials: 'include',
uri: `${ALT_URI}/graphql`
})
const getClient = (history, location, getUserData, setUserData, setRole) =>
new ApolloClient({
link: ApolloLink.from([
@ -48,14 +58,8 @@ const getClient = (history, location, getUserData, setUserData, setRole) =>
}),
ApolloLink.split(
operation => operation.getContext().clientName === 'pazuz',
new HttpLink({
credentials: 'include',
uri: `${ALT_URI}/graphql`
}),
new HttpLink({
credentials: 'include',
uri: `${URI}/graphql`
})
uploadLinkALT,
uploadLink
)
]),
cache: new InMemoryCache(),

24
package-lock.json generated
View file

@ -11479,6 +11479,30 @@
"resolved": "https://registry.npmjs.org/graphql-type-json/-/graphql-type-json-0.3.2.tgz",
"integrity": "sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg=="
},
"graphql-upload": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/graphql-upload/-/graphql-upload-12.0.0.tgz",
"integrity": "sha512-ovZ3Q7sZ17Bmn8tYl22MfrpNR7nYM/DUszXWgkue7SFIlI9jtqszHAli8id8ZcnGBc9GF0gUTNSskYWW+5aNNQ==",
"requires": {
"busboy": "^0.3.1",
"fs-capacitor": "^6.2.0",
"http-errors": "^1.8.0",
"isobject": "^4.0.0",
"object-path": "^0.11.5"
},
"dependencies": {
"fs-capacitor": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/fs-capacitor/-/fs-capacitor-6.2.0.tgz",
"integrity": "sha512-nKcE1UduoSKX27NSZlg879LdQc94OtbOsEmKMN2MBNudXREvijRKx2GEBsTMTfws+BrbkJoEuynbGSVRSpauvw=="
},
"isobject": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz",
"integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA=="
}
}
},
"graphql-ws": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-4.1.0.tgz",

View file

@ -41,6 +41,7 @@
"graphql-iso-date": "^3.6.1",
"graphql-tools": "^7.0.2",
"graphql-type-json": "^0.3.1",
"graphql-upload": "12.0.0",
"helmet": "^3.8.1",
"inquirer": "^5.2.0",
"json2csv": "^5.0.3",