Add customer lookup for phone app, add fallback mysql search for new products in product editor

This commit is contained in:
2026-04-24 09:20:34 -04:00
parent 123946c159
commit 8721ba67df
5 changed files with 383 additions and 3 deletions

View 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;

View File

@@ -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/employee-metrics', require('./routes/employee-metrics'));
app.use('/api/acot/payroll-metrics', require('./routes/payroll-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/operations-metrics', require('./routes/operations-metrics'));
app.use('/api/acot/customers', require('./routes/customers'));
// Error handling middleware // Error handling middleware
app.use((err, req, res, next) => { app.use((err, req, res, next) => {

View 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 };

View File

@@ -1237,6 +1237,7 @@ router.get('/search-products', async (req, res) => {
ELSE 6 ELSE 6
END END
`} `}
${isPidSearch ? '' : 'LIMIT 100'}
`; `;
// Prepare query parameters based on search type // Prepare query parameters based on search type

View File

@@ -50,6 +50,7 @@ export function ProductSearch({
const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null); const [isLoadingProduct, setIsLoadingProduct] = useState<number | null>(null);
const [resultsOpen, setResultsOpen] = useState(false); const [resultsOpen, setResultsOpen] = useState(false);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [fullByPid, setFullByPid] = useState<Map<number, SearchProduct>>(new Map());
const handleSearch = useCallback(async () => { const handleSearch = useCallback(async () => {
if (!searchTerm.trim()) return; if (!searchTerm.trim()) return;
@@ -59,8 +60,29 @@ export function ProductSearch({
const res = await axios.get("/api/products/search", { const res = await axios.get("/api/products/search", {
params: { q: searchTerm }, params: { q: searchTerm },
}); });
setSearchResults(res.data.results); if (res.data.total === 0) {
setTotalCount(res.data.total); 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); setResultsOpen(true);
} catch { } catch {
toast.error("Search failed"); toast.error("Search failed");
@@ -72,6 +94,12 @@ export function ProductSearch({
const handleSelect = useCallback( const handleSelect = useCallback(
async (product: QuickSearchResult) => { async (product: QuickSearchResult) => {
if (loadedPids.has(Number(product.pid))) return; if (loadedPids.has(Number(product.pid))) return;
const cached = fullByPid.get(Number(product.pid));
if (cached) {
onSelect(cached);
setResultsOpen(false);
return;
}
setIsLoadingProduct(product.pid); setIsLoadingProduct(product.pid);
try { try {
const res = await axios.get("/api/import/search-products", { const res = await axios.get("/api/import/search-products", {
@@ -90,7 +118,7 @@ export function ProductSearch({
setIsLoadingProduct(null); setIsLoadingProduct(null);
} }
}, },
[onSelect, loadedPids] [onSelect, loadedPids, fullByPid]
); );
const handleLoadAll = useCallback(() => { const handleLoadAll = useCallback(() => {