feat: customer notes migration
feat: customer notes backend operations feat: add customer note mutation feat: add editing capabilities to PropertyCard feat: connect customer notes backend to frontend fix: customer note form and static content styling fix: SQL uppercasing fix: set default value for notes content fix: SQL after dev rebase refactor: move get current user token to separate method
This commit is contained in:
parent
f14674c4f3
commit
dcd3259484
11 changed files with 268 additions and 38 deletions
30
lib/customer-notes.js
Normal file
30
lib/customer-notes.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
const uuid = require('uuid')
|
||||
const _ = require('lodash/fp')
|
||||
|
||||
const db = require('./db')
|
||||
|
||||
const getCustomerNotes = customerId => {
|
||||
const sql = `SELECT * FROM customer_notes WHERE customer_id=$1 LIMIT 1`
|
||||
return db.oneOrNone(sql, [customerId]).then(res => _.mapKeys((_, key) => _.camelize(key), res))
|
||||
}
|
||||
|
||||
const createCustomerNotes = (customerId, userId, content) => {
|
||||
const sql = `INSERT INTO customer_notes (id, customer_id, last_edited_by, last_edited_at, content) VALUES ($1, $2, $3, now(), $4)`
|
||||
return db.none(sql, [uuid.v4(), customerId, userId, content])
|
||||
}
|
||||
|
||||
const updateCustomerNotes = (customerId, userId, content) => {
|
||||
const sql = `UPDATE customer_notes SET last_edited_at=now(), last_edited_by=$1, content=$2 WHERE customer_id=$3 RETURNING *`
|
||||
return db.any(sql, [userId, content, customerId])
|
||||
.then(res => {
|
||||
if (_.isEmpty(res)) {
|
||||
createCustomerNotes(customerId, userId, content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCustomerNotes,
|
||||
createCustomerNotes,
|
||||
updateCustomerNotes
|
||||
}
|
||||
|
|
@ -632,7 +632,7 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
|
|||
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,
|
||||
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, custom_fields
|
||||
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, custom_fields, content AS notes
|
||||
FROM (
|
||||
SELECT c.id, c.authorized_override,
|
||||
greatest(0, date_part('day', c.suspended_until - NOW())) AS days_suspended,
|
||||
|
|
@ -640,7 +640,7 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
|
|||
c.front_camera_path, 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, t.tx_class, t.fiat, t.fiat_code, t.created,
|
||||
c.sanctions_at, c.sanctions_override, t.tx_class, t.fiat, t.fiat_code, t.created, cn.content,
|
||||
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,
|
||||
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
|
||||
|
|
@ -655,6 +655,9 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
|
|||
LEFT OUTER JOIN customer_custom_field_pairs ccfp ON cfd.id = ccfp.custom_field_id
|
||||
) cf GROUP BY cf.customer_id
|
||||
) ccf ON c.id = ccf.customer_id
|
||||
LEFT OUTER JOIN (
|
||||
SELECT customer_id, content FROM customer_notes
|
||||
) cn ON c.id = cn.customer_id
|
||||
WHERE c.id != $2
|
||||
) AS cl WHERE rn = 1
|
||||
AND ($4 IS NULL OR phone = $4)
|
||||
|
|
@ -678,35 +681,38 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
|
|||
*/
|
||||
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_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 (
|
||||
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_at, c.front_camera_override,
|
||||
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,
|
||||
sum(case when error_code is null or error_code not in ($1^) then t.fiat else 0 end) over (partition by c.id) 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
|
||||
select 'cashOut' as tx_class, id, fiat, fiat_code, created, customer_id, error_code
|
||||
from cash_out_txs where confirmed_at is not null) t on c.id = t.customer_id
|
||||
const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, 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,
|
||||
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, content AS notes
|
||||
FROM (
|
||||
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.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, cn.content,
|
||||
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,
|
||||
sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (PARTITION BY c.id) 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
|
||||
SELECT 'cashOut' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
|
||||
FROM cash_out_txs WHERE confirmed_at IS NOT NULL) t ON c.id = t.customer_id
|
||||
LEFT OUTER JOIN (
|
||||
SELECT cf.customer_id, json_agg(json_build_object('id', cf.custom_field_id, 'label', cf.label, 'value', cf.value)) AS custom_fields FROM (
|
||||
SELECT ccfp.custom_field_id, ccfp.customer_id, cfd.label, ccfp.value FROM custom_field_definitions cfd
|
||||
LEFT OUTER JOIN customer_custom_field_pairs ccfp ON cfd.id = ccfp.custom_field_id
|
||||
) cf GROUP BY cf.customer_id
|
||||
) ccf ON c.id = ccf.customer_id
|
||||
where c.id = $2
|
||||
) as cl where rn = 1`
|
||||
LEFT OUTER JOIN (
|
||||
SELECT customer_id, content FROM customer_notes
|
||||
) cn ON c.id = cn.customer_id
|
||||
WHERE c.id = $2
|
||||
) AS cl WHERE rn = 1`
|
||||
return db.oneOrNone(sql, [passableErrorCodes, id])
|
||||
.then(customerData => {
|
||||
return getEditedData(id)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const { ApolloError } = require('apollo-server-express')
|
||||
const { ApolloError, AuthenticationError } = require('apollo-server-express')
|
||||
|
||||
class InvalidCredentialsError extends ApolloError {
|
||||
constructor(message) {
|
||||
|
|
@ -29,6 +29,7 @@ class InvalidUrlError extends ApolloError {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
AuthenticationError,
|
||||
InvalidCredentialsError,
|
||||
UserAlreadyExistsError,
|
||||
InvalidTwoFactorError,
|
||||
|
|
|
|||
|
|
@ -239,6 +239,13 @@ const reset2FA = (token, userID, code, context) => {
|
|||
.then(() => true)
|
||||
}
|
||||
|
||||
const getToken = context => {
|
||||
if (_.isNil(context.req.cookies.lid) || _.isNil(context.req.session.user.id))
|
||||
throw new authErrors.AuthenticationError('Authentication failed')
|
||||
|
||||
return context.req.session.user.id
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticateUser,
|
||||
getUserData,
|
||||
|
|
@ -259,5 +266,6 @@ module.exports = {
|
|||
createRegisterToken,
|
||||
register,
|
||||
resetPassword,
|
||||
reset2FA
|
||||
reset2FA,
|
||||
getToken
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
const authentication = require('../modules/authentication')
|
||||
const anonymous = require('../../../constants').anonymousCustomer
|
||||
const customers = require('../../../customers')
|
||||
const filters = require('../../filters')
|
||||
const customerNotes = require('../../../customer-notes')
|
||||
|
||||
const resolvers = {
|
||||
|
||||
|
|
@ -14,8 +16,7 @@ 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
|
||||
const token = authentication.getToken(context)
|
||||
if (customerId === anonymous.uuid) return customers.getCustomerById(customerId)
|
||||
return customers.updateCustomer(customerId, customerInput, token)
|
||||
},
|
||||
|
|
@ -23,14 +24,12 @@ const resolvers = {
|
|||
saveCustomField: (...[, { customerId, fieldId, newValue }]) => customers.saveCustomField(customerId, fieldId, newValue),
|
||||
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 token = authentication.getToken(context)
|
||||
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 token = authentication.getToken(context)
|
||||
const photo = await newPhoto
|
||||
if (!photo) return customers.getCustomerById(customerId)
|
||||
return customers.updateEditedPhoto(customerId, photo, photoType)
|
||||
|
|
@ -39,6 +38,10 @@ const resolvers = {
|
|||
deleteEditedData: (root, { customerId, customerEdit }) => {
|
||||
// TODO: NOT IMPLEMENTING THIS FEATURE FOR THE CURRENT VERSION
|
||||
return customers.getCustomerById(customerId)
|
||||
},
|
||||
setCustomerNotes: (...[, { customerId, newContent }, context]) => {
|
||||
const token = authentication.getToken(context)
|
||||
return customerNotes.updateCustomerNotes(customerId, token, newContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const typeDef = gql`
|
|||
subscriberInfo: JSONObject
|
||||
customFields: [CustomerCustomField]
|
||||
customInfoRequests: [CustomRequestData]
|
||||
notes: String
|
||||
}
|
||||
|
||||
input CustomerInput {
|
||||
|
|
@ -89,6 +90,7 @@ const typeDef = gql`
|
|||
editCustomer(customerId: ID!, customerEdit: CustomerEdit): Customer @auth
|
||||
deleteEditedData(customerId: ID!, customerEdit: CustomerEdit): Customer @auth
|
||||
replacePhoto(customerId: ID!, photoType: String, newPhoto: UploadGQL): Customer @auth
|
||||
setCustomerNotes(customerId: ID!, newContent: String!): Customer @auth
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
20
migrations/1627868356883-customer-custom-notes.js
Normal file
20
migrations/1627868356883-customer-custom-notes.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
var db = require('./db')
|
||||
|
||||
exports.up = function (next) {
|
||||
var sql = [
|
||||
`CREATE TABLE customer_notes (
|
||||
id UUID PRIMARY KEY,
|
||||
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||
created TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_edited_at TIMESTAMPTZ,
|
||||
last_edited_by UUID REFERENCES users(id),
|
||||
content TEXT NOT NULL DEFAULT ''
|
||||
)`
|
||||
]
|
||||
|
||||
db.multi(sql, next)
|
||||
}
|
||||
|
||||
exports.down = function (next) {
|
||||
next()
|
||||
}
|
||||
|
|
@ -69,6 +69,7 @@ const GET_CUSTOMER = gql`
|
|||
label
|
||||
value
|
||||
}
|
||||
notes
|
||||
transactions {
|
||||
txClass
|
||||
id
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { useMutation } from '@apollo/react-hooks'
|
||||
import { makeStyles } from '@material-ui/core'
|
||||
import { Formik, Form, Field } from 'formik'
|
||||
import gql from 'graphql-tag'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { TextInput } from 'src/components/inputs/formik'
|
||||
import { Info3 } from 'src/components/typography'
|
||||
|
||||
import styles from './CustomerNotes.styles'
|
||||
import { PropertyCard } from './propertyCard'
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
notes: Yup.string()
|
||||
})
|
||||
|
||||
const SAVE_CUSTOMER_NOTES = gql`
|
||||
mutation setCustomerNotes($customerId: ID!, $newContent: String!) {
|
||||
setCustomerNotes(customerId: $customerId, newContent: $newContent) {
|
||||
notes
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const CustomerNotes = ({ customer }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [setNotes] = useMutation(SAVE_CUSTOMER_NOTES, {
|
||||
refetchQueries: () => ['customer']
|
||||
})
|
||||
|
||||
const initialValues = {
|
||||
notes: R.path(['notes'])(customer) ?? ''
|
||||
}
|
||||
|
||||
const handleConfirm = values => {
|
||||
setNotes({
|
||||
variables: {
|
||||
customerId: customer.id,
|
||||
newContent: values.notes
|
||||
}
|
||||
})
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
const getFormattedNotes = content => {
|
||||
const fragments = R.split(/\n/)(content ?? '')
|
||||
return R.map((it, idx) => {
|
||||
if (idx === fragments.length) return <>{it}</>
|
||||
return (
|
||||
<>
|
||||
{it}
|
||||
<br />
|
||||
</>
|
||||
)
|
||||
}, fragments)
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertyCard
|
||||
title={'Notes'}
|
||||
edit={() => setEditing(true)}
|
||||
confirm={true}
|
||||
isEditing={editing}
|
||||
formName="notes-form"
|
||||
className={classes.root}
|
||||
contentClassName={classes.content}>
|
||||
{!editing && (
|
||||
<Info3>{getFormattedNotes(R.path(['notes'])(customer))}</Info3>
|
||||
)}
|
||||
{editing && (
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={values => handleConfirm(values)}>
|
||||
<Form id="notes-form" className={classes.form}>
|
||||
<Field
|
||||
name="notes"
|
||||
fullWidth
|
||||
multiline={true}
|
||||
rows={6}
|
||||
component={TextInput}
|
||||
/>
|
||||
</Form>
|
||||
</Formik>
|
||||
)}
|
||||
</PropertyCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomerNotes
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
const styles = {
|
||||
root: {
|
||||
height: 'auto'
|
||||
},
|
||||
content: {
|
||||
height: 'auto'
|
||||
},
|
||||
form: {
|
||||
display: 'flex',
|
||||
flexBasis: '90%',
|
||||
flexShrink: 1,
|
||||
marginTop: 8
|
||||
}
|
||||
}
|
||||
|
||||
export default styles
|
||||
|
|
@ -10,6 +10,8 @@ import { ReactComponent as AuthorizeReversedIcon } from 'src/styling/icons/butto
|
|||
import { ReactComponent as AuthorizeIcon } from 'src/styling/icons/button/authorize/zodiac.svg'
|
||||
import { ReactComponent as RejectReversedIcon } from 'src/styling/icons/button/cancel/white.svg'
|
||||
import { ReactComponent as RejectIcon } from 'src/styling/icons/button/cancel/zodiac.svg'
|
||||
import { ReactComponent as EditReversedIcon } from 'src/styling/icons/button/edit/white.svg'
|
||||
import { ReactComponent as EditIcon } from 'src/styling/icons/button/edit/zodiac.svg'
|
||||
|
||||
import { propertyCardStyles } from './PropertyCard.styles'
|
||||
|
||||
|
|
@ -20,7 +22,19 @@ const OVERRIDE_AUTHORIZED = 'verified'
|
|||
const OVERRIDE_REJECTED = 'blocked'
|
||||
|
||||
const PropertyCard = memo(
|
||||
({ className, title, state, authorize, reject, children }) => {
|
||||
({
|
||||
className,
|
||||
contentClassName,
|
||||
title,
|
||||
state,
|
||||
authorize,
|
||||
reject,
|
||||
edit,
|
||||
confirm,
|
||||
isEditing,
|
||||
formName,
|
||||
children
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
|
||||
const label1ClassNames = {
|
||||
|
|
@ -52,6 +66,29 @@ const PropertyCard = memo(
|
|||
</ActionButton>
|
||||
)
|
||||
|
||||
const EditButton = () => (
|
||||
<ActionButton
|
||||
className={classes.cardActionButton}
|
||||
color="secondary"
|
||||
Icon={EditIcon}
|
||||
InverseIcon={EditReversedIcon}
|
||||
onClick={() => edit()}>
|
||||
Edit
|
||||
</ActionButton>
|
||||
)
|
||||
|
||||
const ConfirmButton = () => (
|
||||
<ActionButton
|
||||
className={classes.cardActionButton}
|
||||
type="submit"
|
||||
form={formName}
|
||||
color="secondary"
|
||||
Icon={AuthorizeIcon}
|
||||
InverseIcon={AuthorizeReversedIcon}>
|
||||
Confirm
|
||||
</ActionButton>
|
||||
)
|
||||
|
||||
const authorized =
|
||||
state === OVERRIDE_PENDING
|
||||
? { label: 'Pending', type: 'neutral' }
|
||||
|
|
@ -64,14 +101,22 @@ const PropertyCard = memo(
|
|||
className={classnames(classes.propertyCard, className)}
|
||||
elevation={0}>
|
||||
<H3 className={classes.propertyCardTopRow}>{title}</H3>
|
||||
<div className={classes.propertyCardBottomRow}>
|
||||
<div className={classnames(label1ClassNames)}>
|
||||
<MainStatus statuses={[authorized]} />
|
||||
</div>
|
||||
<div
|
||||
className={classnames(
|
||||
classes.propertyCardBottomRow,
|
||||
contentClassName
|
||||
)}>
|
||||
{state && (
|
||||
<div className={classnames(label1ClassNames)}>
|
||||
<MainStatus statuses={[authorized]} />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
<div className={classes.buttonsWrapper}>
|
||||
{authorize && state !== OVERRIDE_AUTHORIZED && AuthorizeButton()}
|
||||
{reject && state !== OVERRIDE_REJECTED && RejectButton()}
|
||||
{edit && !isEditing && EditButton()}
|
||||
{confirm && isEditing && ConfirmButton()}
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue