diff --git a/lib/customer-notes.js b/lib/customer-notes.js new file mode 100644 index 00000000..b9e4ae81 --- /dev/null +++ b/lib/customer-notes.js @@ -0,0 +1,31 @@ +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` + return db.oneOrNone(sql, [customerId]).then(res => _.mapKeys((_, key) => _.camelize(key), res)) +} + +const createCustomerNote = (customerId, userId, title, content) => { + const sql = `INSERT INTO customer_notes (id, customer_id, last_edited_by, last_edited_at, title, content) VALUES ($1, $2, $3, now(), $4, $5)` + return db.none(sql, [uuid.v4(), customerId, userId, title, content]) +} + +const deleteCustomerNote = noteId => { + const sql = `DELETE FROM customer_notes WHERE id=$1` + return db.none(sql, [noteId]) +} + +const updateCustomerNote = (noteId, userId, content) => { + const sql = `UPDATE customer_notes SET last_edited_at=now(), last_edited_by=$1, content=$2 WHERE id=$3` + return db.none(sql, [userId, content, noteId]) +} + +module.exports = { + getCustomerNotes, + createCustomerNote, + deleteCustomerNote, + updateCustomerNote +} diff --git a/lib/customers.js b/lib/customers.js index 5384a320..89ea723f 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, 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.notes, 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,10 @@ 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, coalesce(json_agg(customer_notes.*), '[]'::json) AS notes FROM customer_notes + GROUP BY customer_notes.customer_id + ) cn ON c.id = cn.customer_id WHERE c.id != $2 ) AS cl WHERE rn = 1 AND ($4 IS NULL OR phone = $4) @@ -666,6 +670,7 @@ function getCustomersList (phone = null, name = null, address = null, id = null) .then(customers => Promise.all(_.map(customer => { return populateOverrideUsernames(customer) .then(camelize) + .then(it => ({ ...it, notes: it.notes.map(camelize) })) }, customers))) } @@ -678,35 +683,39 @@ 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, 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.notes, + 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, coalesce(json_agg(customer_notes.*), '[]'::json) AS notes FROM customer_notes + GROUP BY customer_notes.customer_id + ) 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) @@ -714,6 +723,7 @@ function getCustomerById (id) { }) .then(populateOverrideUsernames) .then(camelize) + .then(it => ({ ...it, notes: it.notes.map(camelize) })) } /** 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..d3563669 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/userManagement') 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,17 @@ const resolvers = { deleteEditedData: (root, { customerId, customerEdit }) => { // TODO: NOT IMPLEMENTING THIS FEATURE FOR THE CURRENT VERSION return customers.getCustomerById(customerId) + }, + createCustomerNote: (...[, { customerId, title, content }, context]) => { + const token = authentication.getToken(context) + return customerNotes.createCustomerNote(customerId, token, title, content) + }, + editCustomerNote: (...[, { noteId, newContent }, context]) => { + const token = authentication.getToken(context) + return customerNotes.updateCustomerNote(noteId, token, newContent) + }, + deleteCustomerNote: (...[, { noteId }]) => { + return customerNotes.deleteCustomerNote(noteId) } } } diff --git a/lib/new-admin/graphql/types/customer.type.js b/lib/new-admin/graphql/types/customer.type.js index 14336086..bdbf3a94 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: [CustomerNote] } input CustomerInput { @@ -75,6 +76,16 @@ const typeDef = gql` usSsn: String } + type CustomerNote { + id: ID + customerId: ID + created: Date + lastEditedAt: Date + lastEditedBy: ID + title: String + content: String + } + type Query { customers(phone: String, name: String, address: String, id: String): [Customer] @auth customer(customerId: ID!): Customer @auth @@ -89,6 +100,9 @@ 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 + createCustomerNote(customerId: ID!, title: String!, content: String!): Boolean @auth + editCustomerNote(noteId: ID!, newContent: String!): Boolean @auth + deleteCustomerNote(noteId: ID!): Boolean @auth } ` diff --git a/migrations/1627868356883-customer-custom-notes.js b/migrations/1627868356883-customer-custom-notes.js new file mode 100644 index 00000000..4e8497ec --- /dev/null +++ b/migrations/1627868356883-customer-custom-notes.js @@ -0,0 +1,21 @@ +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), + title TEXT NOT NULL DEFAULT '' + content TEXT NOT NULL DEFAULT '' + )` + ] + + db.multi(sql, next) +} + +exports.down = function (next) { + next() +} diff --git a/new-lamassu-admin/src/pages/Customers/CustomerNotes.js b/new-lamassu-admin/src/pages/Customers/CustomerNotes.js new file mode 100644 index 00000000..b7f055ba --- /dev/null +++ b/new-lamassu-admin/src/pages/Customers/CustomerNotes.js @@ -0,0 +1,94 @@ +import { makeStyles } from '@material-ui/core' +import * as R from 'ramda' +import { React, useState } from 'react' + +import { H3 } from 'src/components/typography' + +import styles from './CustomerNotes.styles' +import NewNoteCard from './components/notes/NewNoteCard' +import NewNoteModal from './components/notes/NewNoteModal' +import NoteCard from './components/notes/NoteCard' +import NoteEdit from './components/notes/NoteEdit' + +const useStyles = makeStyles(styles) + +const CustomerNotes = ({ + customer, + createNote, + deleteNote, + editNote, + timezone +}) => { + const classes = useStyles() + const [openModal, setOpenModal] = useState(false) + const [editing, setEditing] = useState(null) + + const customerNotes = R.sort( + (a, b) => new Date(b?.created).getTime() - new Date(a?.created).getTime(), + customer.notes ?? [] + ) + + const handleModalClose = () => { + setOpenModal(false) + } + + const handleModalSubmit = it => { + createNote(it) + return handleModalClose() + } + + const cancelNoteEditing = () => { + setEditing(null) + } + + const submitNoteEditing = it => { + if (!R.equals(it.newContent, it.oldContent)) { + editNote({ + noteId: it.noteId, + newContent: it.newContent + }) + } + setEditing(null) + } + + return ( +
Add new
+{formatDate(note?.created, timezone, 'yyyy-MM-dd')}
++ {formatContent(note?.content)} +
++ {`Last edited `} + {formatDurationWithOptions( + { delimited: ', ' }, + intervalToDuration({ + start: toTimezone(new Date(note.lastEditedAt), timezone), + end: toTimezone(new Date(), timezone) + }) + )} + {` ago`} +
+