const uuid = require('uuid') const Pgp = require('pg-promise')() const _ = require('lodash/fp') const crypto = require('crypto') const makeDir = require('make-dir') const path = require('path') const fs = require('fs') const util = require('util') const db = require('./db') const anonymous = require('../lib/constants').anonymousCustomer const complianceOverrides = require('./compliance_overrides') const writeFile = util.promisify(fs.writeFile) const notifierQueries = require('./notifier/queries') const notifierUtils = require('./notifier/utils') const NUM_RESULTS = 1000 const sms = require('./sms') const settingsLoader = require('./new-settings-loader') const logger = require('./logger') const externalCompliance = require('./compliance-external') const { APPROVED, RETRY } = require('./plugins/compliance/consts') const TX_PASSTHROUGH_ERROR_CODES = ['operatorCancel', 'scoreThresholdReached', 'walletScoringError'] const ID_PHOTO_CARD_DIR = process.env.ID_PHOTO_CARD_DIR const FRONT_CAMERA_DIR = process.env.FRONT_CAMERA_DIR const OPERATOR_DATA_DIR = process.env.OPERATOR_DATA_DIR /** * Add new customer * * @name add * @function * * @param {object} Customer object (with phone number) * * @returns {object} Newly created customer */ function add (customer) { const sql = 'insert into customers (id, phone, phone_at) values ($1, $2, now()) returning *' return db.one(sql, [uuid.v4(), customer.phone]) .then(camelize) } function addWithEmail (customer) { const sql = 'insert into customers (id, email, email_at) values ($1, $2, now()) returning *' return db.one(sql, [uuid.v4(), customer.email]) .then(camelize) } /** * Get single customer by phone * Phone numbers are unique per customer * * @name get * @function * * @param {string} phone Customer's phone number * * @returns {object} Customer */ function get (phone) { const sql = 'select * from customers where phone=$1' return db.oneOrNone(sql, [phone]) .then(camelize) } function getWithEmail (email) { const sql = 'select * from customers where email=$1' return db.oneOrNone(sql, [email]) .then(camelize) } /** * Update customer record * * @name update * @function * * @param {string} id Customer's id * @param {object} data Fields to update * @param {string} Acting user's token * * @returns {Promise} Newly updated Customer */ function update (id, data, userToken) { const formattedData = _.omit(['id'], _.mapKeys(_.snakeCase, data)) const enhancedUpdateData = enhanceAtFields(enhanceOverrideFields(formattedData, userToken)) const updateData = updateOverride(enhancedUpdateData) const sql = Pgp.helpers.update(updateData, _.keys(updateData), 'customers') + ' where id=$1 returning *' return db.one(sql, [id]) .then(assignCustomerData) .then(addComplianceOverrides(id, updateData, userToken)) .then(getCustomInfoRequestsData) .then(camelize) } /** * Update customer record * * @name updateCustomer * @function * * @param {string} id Customer's id * @param {object} data Fields to update * * @returns {Promise} Newly updated Customer */ async function updateCustomer (id, data, userToken) { const formattedData = _.pick( [ 'authorized_override', 'id_card_photo_override', 'id_card_data_override', 'sms_override', 'us_ssn_override', 'sanctions_override', 'front_camera_override', 'suspended_until', 'phone_override' ], _.mapKeys(_.snakeCase, data)) const enhancedUpdateData = enhanceAtFields(enhanceOverrideFields(formattedData, userToken)) const updateData = updateOverride(enhancedUpdateData) if (!_.isEmpty(updateData)) { const sql = Pgp.helpers.update(updateData, _.keys(updateData), 'customers') + ' where id=$1' await db.none(sql, [id]) } if (data.subscriberInfo) { await Promise.all([getCustomerById(id), settingsLoader.loadLatest()]) .then(([customer, config]) => sms.getLookup(config, customer.phone)) .then(res => updateSubscriberData(id, res, userToken)) .catch(logger.error) } invalidateCustomerNotifications(id, formattedData) 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', 'subscriber_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' ? FRONT_CAMERA_DIR : ID_PHOTO_CARD_DIR 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. ..//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() const detailB = notifierUtils.buildDetail({ code: 'BLOCKED', customerId: id }) return notifierQueries.invalidateNotification(detailB, 'compliance') } const updateSubscriberData = (customerId, data, userToken) => { const sql = `UPDATE customers SET subscriber_info=$1, subscriber_info_at=now(), subscriber_info_by=$2 WHERE id=$3` return db.none(sql, [data, userToken, customerId]) } /** * Get customer by id * * @name getById * @function * * @param {string} id Customer's unique id * @param {string} userToken Acting user's token * * @returns {object} Customer found * * Used for the machine. */ function getById (id) { const sql = 'select * from customers where id=$1' return db.oneOrNone(sql, [id]) .then(assignCustomerData) .then(getCustomInfoRequestsData) .then(getExternalComplianceMachine) .then(camelize) } /** * Camelize customer fields * Note: return null if customer is undefined * * @name camelize * @function * * @param {object} customer Customer with snake_case fields * @returns {object} Camelized Customer object */ function camelize (customer) { return customer ? _.mapKeys(_.camelCase, customer) : null } function camelizeDeep (customer) { return _.flow( camelize, it => ({ ...it, notes: (it.notes ?? []).map(camelize), externalCompliance: (it.externalCompliance ?? []).map(camelize) }) )(customer) } /** * Get all available complianceTypes * that can be overridden (excluding hard_limit) * * @name getComplianceTypes * @function * * @returns {array} Array of compliance types' names */ function getComplianceTypes () { return [ 'sms', 'email', 'id_card_data', 'id_card_photo', 'front_camera', 'sanctions', 'authorized', 'us_ssn' ] } function updateOverride (fields) { const updateableFields = [ 'id_card_data', 'id_card_photo_path', 'front_camera_path', 'authorized', 'us_ssn' ] const removePathSuffix = _.map(_.replace('_path', '')) const getPairs = _.map(f => [`${f}_override`, 'automatic']) const updatedFields = _.intersection(updateableFields, _.keys(fields)) const overrideFields = _.compose(_.fromPairs, getPairs, removePathSuffix)(updatedFields) return _.merge(fields, overrideFields) } function enhanceAtFields (fields) { const updateableFields = [ 'id_card_data', 'id_card_photo', 'front_camera', 'sanctions', 'authorized', 'us_ssn' ] const updatedFields = _.intersection(updateableFields, _.keys(fields)) const atFields = _.fromPairs(_.map(f => [`${f}_at`, 'now()^'], updatedFields)) return _.merge(fields, atFields) } /** * Add *override_by and *override_at fields with acting user's token * and date of override respectively before saving to db. * * @name enhanceOverrideFields * @function * * @param {object} fields Override fields to be enhanced * @param {string} userToken Acting user's token * @returns {object} fields enhanced with *_by and *_at fields */ function enhanceOverrideFields (fields, userToken) { if (!userToken) return fields // Populate with computedFields (user who overrode and overridden timestamps date) return _.reduce(_.assign, fields, _.map((type) => { return (fields[type + '_override']) ? { [type + '_override_by']: userToken, [type + '_override_at']: 'now()^' } : {} }, getComplianceTypes())) } /** * Save new compliance override records * * Take the override fields that are modified in customer and create * a compliance override record in db for each compliance type. * * @name addComplianceOverrides * @function * * @param {string} id Customer's id * @param {object} customer Customer that is updating * @param {string} userToken Acting user's token * * @returns {promise} Result from compliance_overrides creation */ function addComplianceOverrides (id, customer, userToken) { // Prepare compliance overrides to save const overrides = _.map(field => { const complianceName = field + '_override' return (customer[complianceName]) ? { customerId: id, complianceType: field, overrideBy: userToken, verification: customer[complianceName] } : null }, getComplianceTypes()) // Save all the updated override fields return Promise.all(_.map(complianceOverrides.add, _.compact(overrides))) .then(() => customer) } /** * Query all customers * * Add status as computed column, * which will indicate the name of the latest * compliance verfication completed by user. * * @returns {array} Array of customers populated with status field */ function batch () { const sql = `select * from customers where id != $1 order by created desc limit $2` return db.any(sql, [ anonymous.uuid, NUM_RESULTS ]) .then(customers => Promise.all(_.map(customer => { return getCustomInfoRequestsData(customer) .then(camelize) }, customers))) } // TODO: getCustomersList and getCustomerById are very similar, so this should be refactored /** * Query all customers, ordered by last activity * and with aggregate columns based on their * transactions * * @returns {array} Array of customers with it's transactions aggregations */ function getCustomersList (phone = null, name = null, address = null, id = null, email = null) { 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_override, phone, email, 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, GREATEST(created, last_transaction, last_data_provided, last_auth_attempt) AS last_active, fiat AS last_tx_fiat, fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, custom_fields, notes, is_test_customer 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.email, 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.last_auth_attempt, GREATEST(c.phone_at, c.email_at, c.id_card_data_at, c.front_camera_at, c.id_card_photo_at, c.us_ssn_at) AS last_data_provided, c.sanctions_at, c.sanctions_override, c.is_test_customer, c.created, t.tx_class, t.fiat, t.fiat_code, t.created as last_transaction, 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 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 OR batched = 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) AS 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 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) AND ($5 IS NULL OR CONCAT(id_card_data::json->>'firstName', ' ', id_card_data::json->>'lastName') = $5 OR id_card_data::json->>'firstName' = $5 OR id_card_data::json->>'lastName' = $5) AND ($6 IS NULL OR id_card_data::json->>'address' = $6) AND ($7 IS NULL OR id_card_data::json->>'documentNumber' = $7) AND ($8 IS NULL OR email = $8) limit $3` return db.any(sql, [ passableErrorCodes, anonymous.uuid, NUM_RESULTS, phone, name, address, id, email ]) .then(customers => Promise.all(_.map(customer => getCustomInfoRequestsData(customer) .then(camelizeDeep), customers) ) ) } /** * Query a specific customer, ordered by last activity * and with aggregate columns based on their * transactions * * @returns {array} A single customer instance with non edited * * Used for the server. */ 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, phone_at, email, email_at, phone_override, sms_override, id_card_data_at, id_card_data, id_card_data_override, id_card_data_expiration, id_card_photo_path, id_card_photo_at, id_card_photo_override, us_ssn_at, us_ssn, us_ssn_override, sanctions, sanctions_at, sanctions_override, total_txs, total_spent, GREATEST(created, last_transaction, last_auth_attempt, last_data_provided) AS last_active, fiat AS last_tx_fiat, fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, subscriber_info, subscriber_info_at, custom_fields, notes, is_test_customer, last_used_machine FROM ( SELECT c.id, c.authorized_override, greatest(0, date_part('day', c.suspended_until - now())) AS days_suspended, GREATEST(c.phone_at, c.email_at, c.id_card_data_at, c.front_camera_at, c.id_card_photo_at, c.us_ssn_at) AS last_data_provided, c.suspended_until > now() AS is_suspended, c.front_camera_path, c.front_camera_override, c.front_camera_at, c.last_auth_attempt, c.last_used_machine, c.phone, c.phone_at, c.email, c.email_at, c.phone_override, 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, c.subscriber_info_at, c.is_test_customer, c.created, t.tx_class, t.fiat, t.fiat_code, t.created as last_transaction, 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 OR batched = 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 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(assignCustomerData) .then(getCustomInfoRequestsData) .then(getExternalCompliance) .then(camelizeDeep) .then(formatSubscriberInfo) } function assignCustomerData (customer) { return getEditedData(customer.id) .then(customerEditedData => selectLatestData(customer, customerEditedData)) } function formatSubscriberInfo(customer) { const subscriberInfo = customer.subscriberInfo if(!subscriberInfo) return customer const result = subscriberInfo.result if(_.isEmpty(result)) return _.omit(['subscriberInfo'], customer) const name = _.get('belongs_to.name')(result) const street = _.get('current_addresses[0].street_line_1')(result) const city = _.get('current_addresses[0].city')(result) const stateCode = _.get('current_addresses[0].state_code')(result) const postalCode = _.get('current_addresses[0].postal_code')(result) customer.subscriberInfo = { name, address: `${street ?? ''} ${city ?? ''}${street || city ? ',' : ''} ${stateCode ?? ''} ${postalCode ?? ''}` } return customer } /** * 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 => { const atField = field + '_at' const byField = field + '_by' if (_.includes(field, ['front_camera', 'id_card_photo'])) field = field + '_path' if (!_.has(field, customerData) || !_.has(field, customerEditedData)) return if (customerData[atField] < customerEditedData[atField]) { customerData[field] = customerEditedData[field] customerData[atField] = customerEditedData[atField] customerData[byField] = customerEditedData[byField] } } , defaults) return customerData } /** * @param {String} id customer id * @param {Object} patch customer update record * @returns {Promise} new patch to be applied */ function updatePhotoCard (id, patch) { return Promise.resolve(patch) .then(patch => { // Base64 encoded image /9j/4AAQSkZJRgABAQAAAQ.. const imageData = _.get('idCardPhotoData', patch) if (_.isEmpty(imageData)) { return patch } // remove idCardPhotoData from the update record const newPatch = _.omit('idCardPhotoData', patch) // decode the base64 string to binary data const decodedImageData = Buffer.from(imageData, 'base64') // workout the image hash // i.e. 240e85ff2e4bb931f235985dd0134e459239496d2b5af6c5665168d38ef89b50 const hash = crypto .createHash('sha256') .update(imageData) .digest('hex') // workout the image folder // i.e. 24/0e/85 const rpath = _.join(path.sep, _.map(_.wrap(_.join, ''), _.take(3, _.chunk(2, _.split('', hash))))) // i.e. ..//idphotocard/24/0e/85 const dirname = path.join(ID_PHOTO_CARD_DIR, rpath) // create the directory tree if needed _.attempt(() => makeDir.sync(dirname)) // i.e. ..//idphotocard/24/0e/85/240e85ff2e4bb931f235985dd01....jpg const filename = path.join(dirname, hash + '.jpg') // update db record patch // i.e. { // "idCardPhotoPath": "24/0e/85/240e85ff2e4bb931f235985dd01....jpg", // "idCardPhotoAt": "now()" // } newPatch.idCardPhotoPath = path.join(rpath, hash + '.jpg') newPatch.idCardPhotoAt = 'now()' // write image file return writeFile(filename, decodedImageData) .then(() => newPatch) }) } /** * @param {String} imageData image encoded * @param {String} directory directory path of id card data for a certain user */ function updatePhotos (imagesData, id, dir) { return Promise.resolve(imagesData) .then(imagesData => { const newPatch = {} if (_.isEmpty(imagesData)) { return newPatch } // i.e. ..////idcarddata const dirname = path.join(dir) // create the directory tree if needed _.attempt(() => makeDir.sync(dirname)) const promises = imagesData.map((imageData, index) => { // decode the base64 string to binary data const decodedImageData = Buffer.from(imageData, 'base64') // i.e. ..////idcarddata/1.jpg const filename = path.join(dirname, index + '.jpg') return writeFile(filename, decodedImageData) }) return Promise.all(promises) .then(arr => { newPatch.idCardData = path.join(dirname) newPatch.idCardDataAt = 'now()' return newPatch }) }) } /** * @param {String} id customer id * @param {Object} patch customer latest id card photos * @returns {Promise} new patch to be applied */ function updateIdCardData (patch, id) { /* TODO: fetch operator id */ const operatorId = 'id-operator' const directory = `${OPERATOR_DATA_DIR}/${operatorId}/${id}/` return Promise.resolve(patch) .then(patch => { const imagesData = _.get('photos', patch) return updatePhotos(imagesData, id, directory) .catch(err => logger.error('while saving the image: ', err)) }) } /** * @param {String} imageData customer t&c photo data * @returns {Promise} new patch to be applied */ function updateTxCustomerPhoto (imageData) { return Promise.resolve(imageData) .then(imageData => { const newPatch = {} const directory = `${OPERATOR_DATA_DIR}/customersphotos` if (_.isEmpty(imageData)) { return } // decode the base64 string to binary data const decodedImageData = Buffer.from(imageData, 'base64') // workout the image hash // i.e. 240e85ff2e4bb931f235985dd0134e459239496d2b5af6c5665168d38ef89b50 const hash = crypto .createHash('sha256') .update(imageData) .digest('hex') // workout the image folder // i.e. 24/0e/85 const rpath = _.join(path.sep, _.map(_.wrap(_.join, ''), _.take(3, _.chunk(2, _.split('', hash))))) // i.e. ..///customersphotos/24/0e/85 const dirname = path.join(directory, rpath) // create the directory tree if needed _.attempt(() => makeDir.sync(dirname)) // i.e. ..///customersphotos/24/0e/85/240e85ff2e4bb931f235985dd01....jpg const filename = path.join(dirname, hash + '.jpg') // update db record patch // i.e. { // "idCustomerTxPhoto": "24/0e/85/240e85ff2e4bb931f235985dd01....jpg", // "idCustomerTxPhotoAt": "now()" // } newPatch.txCustomerPhotoPath = path.join(rpath, hash + '.jpg') newPatch.txCustomerPhotoAt = 'now()' // write image file return writeFile(filename, decodedImageData) .then(() => newPatch) }) } function updateFrontCamera (id, patch) { return Promise.resolve(patch) .then(patch => { // Base64 encoded image /9j/4AAQSkZJRgABAQAAAQ.. const imageData = _.get('frontCameraData', patch) if (_.isEmpty(imageData)) { return patch } // remove idCardPhotoData from the update record const newPatch = _.omit('frontCameraData', patch) // decode the base64 string to binary data const decodedImageData = Buffer.from(imageData, 'base64') // workout the image hash // i.e. 240e85ff2e4bb931f235985dd0134e459239496d2b5af6c5665168d38ef89b50 const hash = crypto .createHash('sha256') .update(imageData) .digest('hex') // workout the image folder // i.e. 24/0e/85 const rpath = _.join(path.sep, _.map(_.wrap(_.join, ''), _.take(3, _.chunk(2, _.split('', hash))))) // i.e. ..//idphotocard/24/0e/85 const dirname = path.join(FRONT_CAMERA_DIR, rpath) // create the directory tree if needed _.attempt(() => makeDir.sync(dirname)) // i.e. ..//idphotocard/24/0e/85/240e85ff2e4bb931f235985dd01....jpg const filename = path.join(dirname, hash + '.jpg') // update db record patch // i.e. { // "idCardPhotoPath": "24/0e/85/240e85ff2e4bb931f235985dd01....jpg", // "idCardPhotoAt": "now()" // } newPatch.frontCameraPath = path.join(rpath, hash + '.jpg') newPatch.frontCameraAt = 'now()' // write image file return writeFile(filename, decodedImageData) .then(() => newPatch) }) } function addCustomField (customerId, label, value) { const sql = `SELECT * FROM custom_field_definitions WHERE label=$1 LIMIT 1` return db.oneOrNone(sql, [label]) .then(res => db.tx(t => { if (_.isNil(res)) { const fieldId = uuid.v4() const q1 = t.none(`INSERT INTO custom_field_definitions (id, label) VALUES ($1, $2)`, [fieldId, label]) const q2 = t.none(`INSERT INTO customer_custom_field_pairs (customer_id, custom_field_id, value) VALUES ($1, $2, $3)`, [customerId, fieldId, value]) return t.batch([q1, q2]) } if (!_.isNil(res) && !res.active) { const q1 = t.none(`UPDATE custom_field_definitions SET active = true WHERE id=$1`, [res.id]) const q2 = t.none(`INSERT INTO customer_custom_field_pairs (customer_id, custom_field_id, value) VALUES ($1, $2, $3)`, [customerId, res.id, value]) return t.batch([q1, q2]) } else if (!_.isNil(res) && res.active) { const q1 = t.none(`INSERT INTO customer_custom_field_pairs (customer_id, custom_field_id, value) VALUES ($1, $2, $3)`, [customerId, res.id, value]) return t.batch([q1]) } }) ) .then(res => !_.isNil(res)) } function saveCustomField (customerId, fieldId, newValue) { const sql = `UPDATE customer_custom_field_pairs SET value=$1 WHERE customer_id=$2 AND custom_field_id=$3` return db.none(sql, [newValue, customerId, fieldId]) } function removeCustomField (customerId, fieldId) { const sql = `SELECT * FROM customer_custom_field_pairs WHERE custom_field_id=$1` return db.any(sql, [fieldId]) .then(res => db.tx(t => { // Is the field to be removed the only one of its kind in the pairs table? if (_.size(res) === 1) { const q1 = t.none(`DELETE FROM customer_custom_field_pairs WHERE customer_id=$1 AND custom_field_id=$2`, [customerId, fieldId]) const q2 = t.none(`UPDATE custom_field_definitions SET active = false WHERE id=$1`, [fieldId]) return t.batch([q1, q2]) } else { const q1 = t.none(`DELETE FROM customer_custom_field_pairs WHERE customer_id=$1 AND custom_field_id=$2`, [customerId, fieldId]) return t.batch([q1]) } })) } function getCustomInfoRequestsData (customer) { if (!customer) return const sql = `SELECT * FROM customers_custom_info_requests WHERE customer_id = $1` return db.any(sql, [customer.id]).then(res => _.set('custom_info_request_data', res, customer)) } function enableTestCustomer (customerId) { const sql = `UPDATE customers SET is_test_customer=true WHERE id=$1` return db.none(sql, [customerId]) } function disableTestCustomer (customerId) { const sql = `UPDATE customers SET is_test_customer=false WHERE id=$1` return db.none(sql, [customerId]) } function updateLastAuthAttempt (customerId, deviceId) { const sql = `UPDATE customers SET last_auth_attempt=NOW(), last_used_machine=$2 WHERE id=$1` return db.none(sql, [customerId, deviceId]) } function getExternalComplianceMachine (customer) { return settingsLoader.loadLatest() .then(settings => externalCompliance.getStatusMap(settings, customer.id)) .then(statusMap => { return updateExternalComplianceByMap(customer.id, statusMap) .then(() => customer.externalCompliance = statusMap) .then(() => customer) }) } function updateExternalCompliance(customerId, service, status) { const sql = ` UPDATE customer_external_compliance SET last_known_status = $1, last_updated = now() WHERE customer_id=$2 AND service=$3 ` return db.none(sql, [status, customerId, service]) } function updateExternalComplianceByMap(customerId, serviceMap) { const sql = ` UPDATE customer_external_compliance SET last_known_status = $1, last_updated = now() WHERE customer_id=$2 AND service=$3 ` const pairs = _.toPairs(serviceMap) const promises = _.map(([service, status]) => db.none(sql, [status.answer, customerId, service]))(pairs) return Promise.all(promises) } function getExternalCompliance(customer) { const sql = `SELECT external_id, service, last_known_status, last_updated FROM customer_external_compliance where customer_id=$1` return db.manyOrNone(sql, [customer.id]) .then(compliance => { customer.externalCompliance = compliance }) .then(() => customer) } function getOpenExternalCompliance() { const sql = `SELECT customer_id, service, last_known_status FROM customer_external_compliance where last_known_status in ('PENDING', 'RETRY') or last_known_status is null` return db.manyOrNone(sql) } function notifyRetryExternalCompliance(settings, customerId, service) { const sql = 'SELECT phone FROM customers WHERE id=$1' const promises = [db.one(sql, [customerId]), externalCompliance.createLink(settings, service, customerId)] return Promise.all(promises) .then(([toNumber, link]) => { const body = `Your external compliance verification has failed. Please try again. Link for retry: ${link}` return sms.sendMessage(settings, { toNumber, body }) }) } function notifyApprovedExternalCompliance(settings, customerId) { const sql = 'SELECT phone FROM customers WHERE id=$1' return db.one(sql, [customerId]) .then((toNumber) => { const body = 'Your external compliance verification has been approved.' return sms.sendMessage(settings, { toNumber, body }) }) } function checkExternalCompliance(settings) { return getOpenExternalCompliance() .then(externals => { console.log(externals) const promises = _.map(external => { return externalCompliance.getStatus(settings, external.service, external.customer_id) .then(status => { console.log('status', status, external.customer_id, external.service) if (status.status.answer === RETRY) notifyRetryExternalCompliance(settings, external.customer_id, status.service) if (status.status.answer === APPROVED) notifyApprovedExternalCompliance(settings, external.customer_id) return updateExternalCompliance(external.customer_id, external.service, status.status.answer) }) }, externals) return Promise.all(promises) }) } function addExternalCompliance(customerId, service, id) { const sql = `INSERT INTO customer_external_compliance (customer_id, external_id, service) VALUES ($1, $2, $3)` return db.none(sql, [customerId, id, service]) } module.exports = { add, addWithEmail, get, getWithEmail, batch, getCustomersList, getCustomerById, getById, update, updateCustomer, updatePhotoCard, updateFrontCamera, updateIdCardData, addCustomField, saveCustomField, removeCustomField, edit, deleteEditedData, updateEditedPhoto, updateTxCustomerPhoto, enableTestCustomer, disableTestCustomer, updateLastAuthAttempt, addExternalCompliance, checkExternalCompliance }