Add customer lookup for phone app, add fallback mysql search for new products in product editor
This commit is contained in:
322
inventory-server/dashboard/acot-server/routes/customers.js
Normal file
322
inventory-server/dashboard/acot-server/routes/customers.js
Normal file
@@ -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/<dir1>/<dir2>/<pid>-t-<iid>.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;
|
||||
@@ -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) => {
|
||||
|
||||
28
inventory-server/dashboard/acot-server/utils/phoneAuth.js
Normal file
28
inventory-server/dashboard/acot-server/utils/phoneAuth.js
Normal file
@@ -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 };
|
||||
@@ -1237,6 +1237,7 @@ router.get('/search-products', async (req, res) => {
|
||||
ELSE 6
|
||||
END
|
||||
`}
|
||||
${isPidSearch ? '' : 'LIMIT 100'}
|
||||
`;
|
||||
|
||||
// Prepare query parameters based on search type
|
||||
|
||||
@@ -50,6 +50,7 @@ export function ProductSearch({
|
||||
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
|
||||
const [resultsOpen, setResultsOpen] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [fullByPid, setFullByPid] = useState<Map<number, SearchProduct>>(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 },
|
||||
});
|
||||
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(() => {
|
||||
|
||||
Reference in New Issue
Block a user