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:
Sérgio Salgado 2021-08-02 04:15:11 +01:00
parent f14674c4f3
commit dcd3259484
11 changed files with 268 additions and 38 deletions

30
lib/customer-notes.js Normal file
View 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
}

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -69,6 +69,7 @@ const GET_CUSTOMER = gql`
label
value
}
notes
transactions {
txClass
id

View file

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

View file

@ -0,0 +1,16 @@
const styles = {
root: {
height: 'auto'
},
content: {
height: 'auto'
},
form: {
display: 'flex',
flexBasis: '90%',
flexShrink: 1,
marginTop: 8
}
}
export default styles

View file

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