From dcd3259484f1c73bd08498527a7cb80c3a232611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Salgado?= Date: Mon, 2 Aug 2021 04:15:11 +0100 Subject: [PATCH] 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 --- lib/customer-notes.js | 30 ++++++ lib/customers.js | 56 ++++++----- .../graphql/errors/authentication.js | 3 +- .../graphql/modules/userManagement.js | 10 +- .../graphql/resolvers/customer.resolver.js | 15 +-- lib/new-admin/graphql/types/customer.type.js | 2 + .../1627868356883-customer-custom-notes.js | 20 ++++ .../src/pages/Customers/CustomerProfile.js | 1 + .../Customers/components/CustomerNotes.js | 98 +++++++++++++++++++ .../components/CustomerNotes.styles.js | 16 +++ .../components/propertyCard/PropertyCard.js | 55 ++++++++++- 11 files changed, 268 insertions(+), 38 deletions(-) create mode 100644 lib/customer-notes.js create mode 100644 migrations/1627868356883-customer-custom-notes.js create mode 100644 new-lamassu-admin/src/pages/Customers/components/CustomerNotes.js create mode 100644 new-lamassu-admin/src/pages/Customers/components/CustomerNotes.styles.js diff --git a/lib/customer-notes.js b/lib/customer-notes.js new file mode 100644 index 00000000..e89c457d --- /dev/null +++ b/lib/customer-notes.js @@ -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 +} diff --git a/lib/customers.js b/lib/customers.js index 5384a320..fc089d05 100644 --- a/lib/customers.js +++ b/lib/customers.js @@ -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) diff --git a/lib/new-admin/graphql/errors/authentication.js b/lib/new-admin/graphql/errors/authentication.js index 7ddaede5..ac9b0153 100644 --- a/lib/new-admin/graphql/errors/authentication.js +++ b/lib/new-admin/graphql/errors/authentication.js @@ -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, diff --git a/lib/new-admin/graphql/modules/userManagement.js b/lib/new-admin/graphql/modules/userManagement.js index eeee2a26..13c42e70 100644 --- a/lib/new-admin/graphql/modules/userManagement.js +++ b/lib/new-admin/graphql/modules/userManagement.js @@ -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 } diff --git a/lib/new-admin/graphql/resolvers/customer.resolver.js b/lib/new-admin/graphql/resolvers/customer.resolver.js index 36a24b0f..bc258284 100644 --- a/lib/new-admin/graphql/resolvers/customer.resolver.js +++ b/lib/new-admin/graphql/resolvers/customer.resolver.js @@ -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) } } } diff --git a/lib/new-admin/graphql/types/customer.type.js b/lib/new-admin/graphql/types/customer.type.js index 14336086..adc6d2d6 100644 --- a/lib/new-admin/graphql/types/customer.type.js +++ b/lib/new-admin/graphql/types/customer.type.js @@ -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 } ` diff --git a/migrations/1627868356883-customer-custom-notes.js b/migrations/1627868356883-customer-custom-notes.js new file mode 100644 index 00000000..87a16e5e --- /dev/null +++ b/migrations/1627868356883-customer-custom-notes.js @@ -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() +} diff --git a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js index 39f3d25e..e0be120c 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js @@ -69,6 +69,7 @@ const GET_CUSTOMER = gql` label value } + notes transactions { txClass id diff --git a/new-lamassu-admin/src/pages/Customers/components/CustomerNotes.js b/new-lamassu-admin/src/pages/Customers/components/CustomerNotes.js new file mode 100644 index 00000000..130b8e7d --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/components/CustomerNotes.js @@ -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} +
+ + ) + }, fragments) + } + + return ( + setEditing(true)} + confirm={true} + isEditing={editing} + formName="notes-form" + className={classes.root} + contentClassName={classes.content}> + {!editing && ( + {getFormattedNotes(R.path(['notes'])(customer))} + )} + {editing && ( + handleConfirm(values)}> +
+ + +
+ )} +
+ ) +} + +export default CustomerNotes diff --git a/new-lamassu-admin/src/pages/Customers/components/CustomerNotes.styles.js b/new-lamassu-admin/src/pages/Customers/components/CustomerNotes.styles.js new file mode 100644 index 00000000..67fc82b5 --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/components/CustomerNotes.styles.js @@ -0,0 +1,16 @@ +const styles = { + root: { + height: 'auto' + }, + content: { + height: 'auto' + }, + form: { + display: 'flex', + flexBasis: '90%', + flexShrink: 1, + marginTop: 8 + } +} + +export default styles diff --git a/new-lamassu-admin/src/pages/Customers/components/propertyCard/PropertyCard.js b/new-lamassu-admin/src/pages/Customers/components/propertyCard/PropertyCard.js index 115462f3..a2d1df31 100644 --- a/new-lamassu-admin/src/pages/Customers/components/propertyCard/PropertyCard.js +++ b/new-lamassu-admin/src/pages/Customers/components/propertyCard/PropertyCard.js @@ -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( ) + const EditButton = () => ( + edit()}> + Edit + + ) + + const ConfirmButton = () => ( + + Confirm + + ) + const authorized = state === OVERRIDE_PENDING ? { label: 'Pending', type: 'neutral' } @@ -64,14 +101,22 @@ const PropertyCard = memo( className={classnames(classes.propertyCard, className)} elevation={0}>

{title}

-
-
- -
+
+ {state && ( +
+ +
+ )} {children}
{authorize && state !== OVERRIDE_AUTHORIZED && AuthorizeButton()} {reject && state !== OVERRIDE_REJECTED && RejectButton()} + {edit && !isEditing && EditButton()} + {confirm && isEditing && ConfirmButton()}