From 8721ba67df34f313bc7396e07e9ee32c04621041 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 24 Apr 2026 09:20:34 -0400 Subject: [PATCH] Add customer lookup for phone app, add fallback mysql search for new products in product editor --- .../dashboard/acot-server/routes/customers.js | 322 ++++++++++++++++++ .../dashboard/acot-server/server.js | 1 + .../dashboard/acot-server/utils/phoneAuth.js | 28 ++ inventory-server/src/routes/import.js | 1 + .../product-editor/ProductSearch.tsx | 34 +- 5 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 inventory-server/dashboard/acot-server/routes/customers.js create mode 100644 inventory-server/dashboard/acot-server/utils/phoneAuth.js diff --git a/inventory-server/dashboard/acot-server/routes/customers.js b/inventory-server/dashboard/acot-server/routes/customers.js new file mode 100644 index 0000000..5b900bf --- /dev/null +++ b/inventory-server/dashboard/acot-server/routes/customers.js @@ -0,0 +1,322 @@ +// Customer lookup for the phone app (acot-phone-server). +// +// All queries hit the MySQL `sg` database via the shared SSH-tunneled pool in +// db/connection.js. The stats/orders logic mirrors the freescout +// ACOTCustomerData module so both apps display the same numbers for a given +// customer — the difference is that we key by phone, not email. +// +// NOTE: `users.phone` is not yet indexed in production. Admin will add +// `idx_phone (phone)` — queries here assume that exists for acceptable latency. + +const express = require('express'); +const router = express.Router(); +const { getDbConnection, getCachedQuery } = require('../db/connection'); +const { requirePhoneApiKey } = require('../utils/phoneAuth'); + +// Order status labels mirror ACOTCustomerDataServiceProvider.php. +const ORDER_STATUS_LABEL = { + 0: 'Created', 10: 'Incomplete', 15: 'Cancelled', 16: 'Combined', + 20: 'Placed', 22: 'Placed (Incomplete)', 40: 'Awaiting Payment', + 45: 'Payment Pending', 50: 'Awaiting Products', 55: 'Shipping Later', + 56: 'Shipping Together', 60: 'Ready', 61: 'Flagged', 62: 'Fix Before Pick', + 65: 'Manual Picking', 67: 'Remote Send', 70: 'In PT', 80: 'Picked', + 90: 'Awaiting Shipment', 91: 'Remote Wait', 92: 'Awaiting Pickup', + 93: 'Fix Before Ship', 95: 'Shipped (Confirmed)', 100: 'Shipped', +}; +const ORDER_STATUS_SHORT = { + 0: 'Created', 10: 'Incomplete', 15: 'Cancelled', 16: 'Combined', + 20: 'Placed', 22: 'Plcd Incomp', 40: 'Await Payment', 45: 'Pymt Pending', + 50: 'Await Products', 55: 'Ship Later', 56: 'Ship Togethr', 60: 'Ready', + 61: 'Flagged', 62: 'Fix Bfr Pick', 65: 'Manual Pick', 67: 'Remote Send', + 70: 'In PT', 80: 'Picked', 90: 'Await Ship', 91: 'Remote Wait', + 92: 'Await Pickup', 93: 'Fix Bfr Ship', 95: 'Shpd Confirm', 100: 'Shipped', +}; + +function statusLabel(s) { return ORDER_STATUS_LABEL[s] ?? `Unknown (${s})`; } +function statusShort(s) { return ORDER_STATUS_SHORT[s] ?? `Unknown (${s})`; } + +// SIP trunks and historical CRM imports all disagree on phone format. Rather +// than normalize everything upstream, we search across the most common +// variations for US/Canada numbers. Falls through to the raw input for +// international numbers we can't safely reformat. +function phoneVariations(input) { + const raw = String(input || '').trim(); + if (!raw) return []; + const digits = raw.replace(/\D/g, ''); + const out = new Set([raw, digits]); + if (digits.length === 10) { + out.add(`+1${digits}`); + out.add(`1${digits}`); + } else if (digits.length === 11 && digits.startsWith('1')) { + out.add(`+${digits}`); + out.add(digits.slice(1)); // 10-digit form + out.add(`+1${digits.slice(1)}`); + } + return Array.from(out).filter(Boolean); +} + +function trackingLink(method, tracking) { + if (!tracking) return ''; + if (typeof method === 'string') { + if (method.startsWith('usps_') || method === 'fedex_smartpost') { + return `https://tools.usps.com/go/TrackConfirmAction?qtc_tLabels1=${tracking}`; + } + if (method.startsWith('fedex_')) { + return `https://www.fedex.com/fedextrack/?trknbr=${tracking}`; + } + } + return ''; +} + +// Matches ACOTCustomerDataServiceProvider::imageUrl — sbing.com/i/products///-t-.jpg +function imageUrl(pid, iid = 1) { + const padded = String(pid).padStart(10, '0'); + const dir1 = padded.slice(0, 4); + const dir2 = padded.slice(4, 7); + return `https://sbing.com/i/products/${dir1}/${dir2}/${pid}-t-${iid}.jpg`; +} + +router.use(requirePhoneApiKey); + +// ── GET /by-phone ────────────────────────────────────────────────────────── +// Returns top-line customer info for the incoming-call overlay. +router.get('/by-phone', async (req, res) => { + const phone = String(req.query.phone || '').trim(); + if (!phone) return res.status(400).json({ success: false, error: 'phone required' }); + + const variations = phoneVariations(phone); + if (variations.length === 0) return res.json({ success: true, customer: null }); + + try { + const data = await getCachedQuery( + `customer-by-phone:${variations.join('|')}`, + 'default', + async () => { + const { connection, release } = await getDbConnection(); + try { + const placeholders = variations.map(() => '?').join(','); + // Tie-break by highest LTV per user instructions: subquery computes LTV + // for every matching user, then we pick the biggest. + const [users] = await connection.execute( + `SELECT u.cid, u.uid, u.firstname, u.lastname, u.email, u.phone, u.points, + COALESCE(( + SELECT SUM(summary_total) + FROM _order + WHERE order_cid = u.cid AND order_status >= 50 + ), 0) AS lifetime_value, + COALESCE(( + SELECT COUNT(*) + FROM _order + WHERE order_cid = u.cid AND order_status >= 20 + ), 0) AS num_orders, + ( + SELECT AVG(summary_total) + FROM _order + WHERE order_cid = u.cid AND order_status >= 20 + ) AS avg_order + FROM users u + WHERE u.phone IN (${placeholders}) + ORDER BY lifetime_value DESC + LIMIT 1`, + variations + ); + return users[0] ?? null; + } finally { + release(); + } + } + ); + + if (!data) return res.json({ success: true, customer: null }); + res.json({ + success: true, + customer: { + cid: Number(data.cid), + uid: data.uid, + firstName: data.firstname || null, + lastName: data.lastname || null, + email: data.email || null, + phone: data.phone, + points: Number(data.points) || 0, + lifetimeValue: Number(data.lifetime_value) || 0, + orderCount: Number(data.num_orders) || 0, + avgOrderValue: data.avg_order != null ? Number(data.avg_order) : 0, + }, + }); + } catch (err) { + console.error('customers/by-phone failed:', err); + res.status(500).json({ success: false, error: 'query_failed' }); + } +}); + +// ── GET /search ──────────────────────────────────────────────────────────── +// Name search for the dialer. Accepts a free-text query; splits on whitespace. +// - 1 token: LIKE against firstname OR lastname (prefix). +// - 2+ tokens: firstname LIKE A% AND lastname LIKE B% (order-sensitive on purpose). +router.get('/search', async (req, res) => { + const q = String(req.query.q || '').trim(); + const limit = Math.min(Math.max(parseInt(req.query.limit || '10', 10) || 10, 1), 25); + if (q.length < 2) return res.json({ success: true, results: [] }); + + try { + const data = await getCachedQuery( + `customer-search:${q}:${limit}`, + 'default', + async () => { + const { connection, release } = await getDbConnection(); + try { + const tokens = q.split(/\s+/).filter(Boolean); + let sql; + let params; + if (tokens.length === 1) { + const pattern = `${tokens[0]}%`; + sql = `SELECT cid, firstname, lastname, email, phone + FROM users + WHERE (firstname LIKE ? OR lastname LIKE ?) + AND phone <> '' + ORDER BY lastname, firstname + LIMIT ?`; + params = [pattern, pattern, limit]; + } else { + const firstPat = `${tokens[0]}%`; + const lastPat = `${tokens.slice(1).join(' ')}%`; + sql = `SELECT cid, firstname, lastname, email, phone + FROM users + WHERE firstname LIKE ? AND lastname LIKE ? + AND phone <> '' + ORDER BY lastname, firstname + LIMIT ?`; + params = [firstPat, lastPat, limit]; + } + const [rows] = await connection.execute(sql, params); + return rows; + } finally { + release(); + } + } + ); + + res.json({ + success: true, + results: data.map((r) => ({ + cid: Number(r.cid), + firstName: r.firstname || null, + lastName: r.lastname || null, + email: r.email || null, + phone: r.phone, + })), + }); + } catch (err) { + console.error('customers/search failed:', err); + res.status(500).json({ success: false, error: 'query_failed' }); + } +}); + +// ── GET /:cid/orders ─────────────────────────────────────────────────────── +// Recent orders for the active-call screen — mirrors the freescout sidebar. +router.get('/:cid/orders', async (req, res) => { + const cid = Number(req.params.cid); + if (!Number.isFinite(cid) || cid <= 0) { + return res.status(400).json({ success: false, error: 'bad_cid' }); + } + + try { + const data = await getCachedQuery( + `customer-orders:${cid}`, + 'orders', + async () => { + const { connection, release } = await getDbConnection(); + try { + // MySQL-safe equivalent of the Laravel query in the freescout module. + // Active = placed OR shipped within the last 3 months. + const [ordersRaw] = await connection.execute( + `SELECT order_id, order_status, order_type, summary_total, + date_placed, ship_method_type, ship_method_tracking, + CASE + WHEN (order_status BETWEEN 20 AND 92 + OR date_shipped > DATE_SUB(NOW(), INTERVAL 3 MONTH)) + THEN 1 ELSE 0 + END AS _is_active + FROM _order + WHERE order_cid = ? + AND (order_status >= 20 + OR date_shipped > DATE_SUB(NOW(), INTERVAL 3 MONTH)) + ORDER BY _is_active DESC, date_placed DESC`, + [cid] + ); + + const active = ordersRaw.filter((o) => o._is_active === 1); + const inactive = ordersRaw.filter((o) => o._is_active === 0); + const orders = active.concat(inactive.slice(0, Math.max(0, 10 - active.length))); + + if (orders.length === 0) return []; + + const orderIds = orders.map((o) => o.order_id); + const idPlaceholders = orderIds.map(() => '?').join(','); + + const [items] = await connection.execute( + `SELECT order_id, prod_pid, prod_itemnumber, prod_description, prod_price, qty_ordered + FROM order_items + WHERE order_id IN (${idPlaceholders})`, + orderIds + ); + + // Main-image lookup: per-pid highest \`order\` at type=3 (matches the + // freescout module's raw SQL). + const pids = [...new Set(items.map((i) => Number(i.prod_pid)).filter(Boolean))]; + const mainImagesByPid = new Map(); + if (pids.length > 0) { + const pidList = pids.join(','); + const [imgRows] = await connection.execute( + `SELECT pi.pid, pi.iid + FROM product_images pi + INNER JOIN ( + SELECT pid, MAX(\`order\`) AS max_order + FROM product_images + WHERE pid IN (${pidList}) AND type = 3 + GROUP BY pid + ) pm ON pi.pid = pm.pid AND pi.\`order\` = pm.max_order AND pi.type = 3` + ); + for (const r of imgRows) mainImagesByPid.set(Number(r.pid), Number(r.iid)); + } + + const itemsByOrder = new Map(); + for (const it of items) { + const oid = Number(it.order_id); + if (!itemsByOrder.has(oid)) itemsByOrder.set(oid, []); + const iid = mainImagesByPid.get(Number(it.prod_pid)) ?? 1; + itemsByOrder.get(oid).push({ + pid: Number(it.prod_pid), + sku: it.prod_itemnumber || null, + name: it.prod_description || null, + price: Number(it.prod_price) || 0, + quantity: Number(it.qty_ordered) || 0, + imageUrl: imageUrl(it.prod_pid, iid), + }); + } + + return orders.map((o) => ({ + orderId: Number(o.order_id), + datePlaced: o.date_placed, + total: Number(o.summary_total) || 0, + status: Number(o.order_status), + statusLabel: statusLabel(Number(o.order_status)), + statusShort: statusShort(Number(o.order_status)), + trackingNumber: o.ship_method_tracking || '', + trackingUrl: trackingLink(o.ship_method_type, o.ship_method_tracking), + items: itemsByOrder.get(Number(o.order_id)) || [], + })); + } finally { + release(); + } + } + ); + + res.json({ success: true, orders: data }); + } catch (err) { + console.error('customers/:cid/orders failed:', err); + res.status(500).json({ success: false, error: 'query_failed' }); + } +}); + +module.exports = router; diff --git a/inventory-server/dashboard/acot-server/server.js b/inventory-server/dashboard/acot-server/server.js index 1a53965..df0ee0e 100644 --- a/inventory-server/dashboard/acot-server/server.js +++ b/inventory-server/dashboard/acot-server/server.js @@ -52,6 +52,7 @@ app.use('/api/acot/discounts', require('./routes/discounts')); app.use('/api/acot/employee-metrics', require('./routes/employee-metrics')); app.use('/api/acot/payroll-metrics', require('./routes/payroll-metrics')); app.use('/api/acot/operations-metrics', require('./routes/operations-metrics')); +app.use('/api/acot/customers', require('./routes/customers')); // Error handling middleware app.use((err, req, res, next) => { diff --git a/inventory-server/dashboard/acot-server/utils/phoneAuth.js b/inventory-server/dashboard/acot-server/utils/phoneAuth.js new file mode 100644 index 0000000..6e51f59 --- /dev/null +++ b/inventory-server/dashboard/acot-server/utils/phoneAuth.js @@ -0,0 +1,28 @@ +// Shared-secret auth for customer-lookup endpoints that expose PII. +// The acot-phone-server sends `x-acot-api-key` on every request; we compare +// against ACOT_PHONE_API_KEY from the environment using timing-safe comparison. + +const crypto = require('crypto'); + +function requirePhoneApiKey(req, res, next) { + const expected = process.env.ACOT_PHONE_API_KEY; + if (!expected) { + console.error('ACOT_PHONE_API_KEY not configured; rejecting all requests'); + return res.status(503).json({ success: false, error: 'auth_not_configured' }); + } + + const provided = req.get('x-acot-api-key') || ''; + const expectedBuf = Buffer.from(expected); + const providedBuf = Buffer.from(provided); + + if ( + providedBuf.length !== expectedBuf.length || + !crypto.timingSafeEqual(providedBuf, expectedBuf) + ) { + return res.status(401).json({ success: false, error: 'unauthorized' }); + } + + next(); +} + +module.exports = { requirePhoneApiKey }; diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index 5fd50fe..509d416 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -1237,6 +1237,7 @@ router.get('/search-products', async (req, res) => { ELSE 6 END `} + ${isPidSearch ? '' : 'LIMIT 100'} `; // Prepare query parameters based on search type diff --git a/inventory/src/components/product-editor/ProductSearch.tsx b/inventory/src/components/product-editor/ProductSearch.tsx index c87ff62..8da3948 100644 --- a/inventory/src/components/product-editor/ProductSearch.tsx +++ b/inventory/src/components/product-editor/ProductSearch.tsx @@ -50,6 +50,7 @@ export function ProductSearch({ const [isLoadingProduct, setIsLoadingProduct] = useState(null); const [resultsOpen, setResultsOpen] = useState(false); const [isFocused, setIsFocused] = useState(false); + const [fullByPid, setFullByPid] = useState>(new Map()); const handleSearch = useCallback(async () => { if (!searchTerm.trim()) return; @@ -59,8 +60,29 @@ export function ProductSearch({ const res = await axios.get("/api/products/search", { params: { q: searchTerm }, }); - setSearchResults(res.data.results); - setTotalCount(res.data.total); + if (res.data.total === 0) { + const fallback = await axios.get("/api/import/search-products", { + params: { q: searchTerm }, + }); + const fullProducts = fallback.data as SearchProduct[]; + const previews: QuickSearchResult[] = fullProducts.map((p) => ({ + pid: Number(p.pid), + title: p.title, + sku: p.sku, + barcode: p.barcode, + brand: p.brand, + line: p.line, + regular_price: p.regular_price, + image_175: null, + })); + setSearchResults(previews); + setTotalCount(fullProducts.length); + setFullByPid(new Map(fullProducts.map((p) => [Number(p.pid), p]))); + } else { + setSearchResults(res.data.results); + setTotalCount(res.data.total); + setFullByPid(new Map()); + } setResultsOpen(true); } catch { toast.error("Search failed"); @@ -72,6 +94,12 @@ export function ProductSearch({ const handleSelect = useCallback( async (product: QuickSearchResult) => { if (loadedPids.has(Number(product.pid))) return; + const cached = fullByPid.get(Number(product.pid)); + if (cached) { + onSelect(cached); + setResultsOpen(false); + return; + } setIsLoadingProduct(product.pid); try { const res = await axios.get("/api/import/search-products", { @@ -90,7 +118,7 @@ export function ProductSearch({ setIsLoadingProduct(null); } }, - [onSelect, loadedPids] + [onSelect, loadedPids, fullByPid] ); const handleLoadAll = useCallback(() => {