323 lines
13 KiB
JavaScript
323 lines
13 KiB
JavaScript
// 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;
|