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