Fix identified issues with server consolidation
This commit is contained in:
@@ -32,13 +32,81 @@ router.get('/brands', async (req, res) => {
|
||||
});
|
||||
|
||||
// Get all products with pagination, filtering, and sorting
|
||||
// Whitelist of allowed sort keys → SQL column expressions. Used to gate
|
||||
// `?sort=` against direct interpolation into ORDER BY (the previous code
|
||||
// dropped req.query.sort straight into the query string — SQL injection sink).
|
||||
// Keys are the camelCase identifiers the frontend ProductMetricColumnKey union
|
||||
// emits. Anything not in the map falls back to `p.title`.
|
||||
const SORT_COLUMN_MAP = {
|
||||
pid: 'p.pid',
|
||||
title: 'p.title',
|
||||
sku: 'p.sku',
|
||||
barcode: 'p.barcode',
|
||||
brand: 'p.brand',
|
||||
line: 'p.line',
|
||||
subline: 'p.subline',
|
||||
artist: 'p.artist',
|
||||
vendor: 'p.vendor',
|
||||
vendorReference: 'p.vendor_reference',
|
||||
notionsReference: 'p.notions_reference',
|
||||
harmonizedTariffCode: 'p.harmonized_tariff_code',
|
||||
countryOfOrigin: 'p.country_of_origin',
|
||||
location: 'p.location',
|
||||
moq: 'p.moq',
|
||||
weight: 'p.weight',
|
||||
rating: 'p.rating',
|
||||
reviews: 'p.reviews',
|
||||
baskets: 'p.baskets',
|
||||
notifies: 'p.notifies',
|
||||
preorderCount: 'p.preorder_count',
|
||||
notionsInvCount: 'p.notions_inv_count',
|
||||
dateCreated: 'p.created_at',
|
||||
dateLastSold: 'p.date_last_sold',
|
||||
stock: 'p.stock_quantity',
|
||||
stockQuantity: 'p.stock_quantity',
|
||||
price: 'p.price',
|
||||
costPrice: 'p.cost_price',
|
||||
totalSold: 'p.total_sold',
|
||||
// product_metrics columns (current schema names; camelCase aliases the
|
||||
// frontend uses are mapped to the canonical SQL column)
|
||||
dailySalesAvg: 'pm.avg_sales_per_day_30d',
|
||||
weeklySalesAvg: 'pm.sales_7d',
|
||||
monthlySalesAvg: 'pm.avg_sales_per_month_30d',
|
||||
margin: 'pm.margin_30d',
|
||||
gmroi: 'pm.gmroi_30d',
|
||||
gmroi30d: 'pm.gmroi_30d',
|
||||
inventoryValue: 'pm.current_stock_cost',
|
||||
costOfGoodsSold: 'pm.cogs_30d',
|
||||
grossProfit: 'pm.profit_30d',
|
||||
turnoverRate: 'pm.stockturn_30d',
|
||||
stockturn30d: 'pm.stockturn_30d',
|
||||
leadTime: 'pm.config_lead_time',
|
||||
currentLeadTime: 'pm.config_lead_time',
|
||||
targetLeadTime: 'pm.config_lead_time',
|
||||
stockCoverage: 'pm.stock_cover_in_days',
|
||||
daysOfStock: 'pm.stock_cover_in_days',
|
||||
reorderPoint: 'pm.replenishment_units',
|
||||
safetyStock: 'pm.config_safety_stock',
|
||||
abcClass: 'pm.abc_class',
|
||||
status: 'pm.status',
|
||||
ageDays: 'pm.age_days',
|
||||
sales7d: 'pm.sales_7d',
|
||||
sales30d: 'pm.sales_30d',
|
||||
sales365d: 'pm.sales_365d',
|
||||
revenue7d: 'pm.revenue_7d',
|
||||
revenue30d: 'pm.revenue_30d',
|
||||
revenue365d: 'pm.revenue_365d',
|
||||
sellThrough30d: 'pm.sell_through_30d',
|
||||
salesGrowth30dVsPrev: 'pm.sales_growth_30d_vs_prev',
|
||||
};
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
const offset = (page - 1) * limit;
|
||||
const sortColumn = req.query.sort || 'title';
|
||||
const sortColumn = SORT_COLUMN_MAP[req.query.sort] || 'p.title';
|
||||
const sortDirection = req.query.order === 'desc' ? 'DESC' : 'ASC';
|
||||
|
||||
const conditions = ['p.visible = true'];
|
||||
@@ -120,30 +188,28 @@ router.get('/', async (req, res) => {
|
||||
paramCounter++;
|
||||
}
|
||||
|
||||
// Handle numeric filters with operators
|
||||
// Handle numeric filters with operators. Mapped to current product_metrics
|
||||
// column names; frontend keys (camelCase) preserved for compatibility.
|
||||
const numericFields = {
|
||||
stock: 'p.stock_quantity',
|
||||
price: 'p.price',
|
||||
costPrice: 'p.cost_price',
|
||||
dailySalesAvg: 'pm.daily_sales_avg',
|
||||
weeklySalesAvg: 'pm.weekly_sales_avg',
|
||||
monthlySalesAvg: 'pm.monthly_sales_avg',
|
||||
avgQuantityPerOrder: 'pm.avg_quantity_per_order',
|
||||
numberOfOrders: 'pm.number_of_orders',
|
||||
margin: 'pm.avg_margin_percent',
|
||||
gmroi: 'pm.gmroi',
|
||||
inventoryValue: 'pm.inventory_value',
|
||||
costOfGoodsSold: 'pm.cost_of_goods_sold',
|
||||
grossProfit: 'pm.gross_profit',
|
||||
turnoverRate: 'pm.turnover_rate',
|
||||
leadTime: 'pm.current_lead_time',
|
||||
currentLeadTime: 'pm.current_lead_time',
|
||||
targetLeadTime: 'pm.target_lead_time',
|
||||
stockCoverage: 'pm.days_of_inventory',
|
||||
daysOfStock: 'pm.days_of_inventory',
|
||||
weeksOfStock: 'pm.weeks_of_inventory',
|
||||
reorderPoint: 'pm.reorder_point',
|
||||
safetyStock: 'pm.safety_stock',
|
||||
dailySalesAvg: 'pm.avg_sales_per_day_30d',
|
||||
weeklySalesAvg: 'pm.sales_7d',
|
||||
monthlySalesAvg: 'pm.avg_sales_per_month_30d',
|
||||
margin: 'pm.margin_30d',
|
||||
gmroi: 'pm.gmroi_30d',
|
||||
inventoryValue: 'pm.current_stock_cost',
|
||||
costOfGoodsSold: 'pm.cogs_30d',
|
||||
grossProfit: 'pm.profit_30d',
|
||||
turnoverRate: 'pm.stockturn_30d',
|
||||
leadTime: 'pm.config_lead_time',
|
||||
currentLeadTime: 'pm.config_lead_time',
|
||||
targetLeadTime: 'pm.config_lead_time',
|
||||
stockCoverage: 'pm.stock_cover_in_days',
|
||||
daysOfStock: 'pm.stock_cover_in_days',
|
||||
reorderPoint: 'pm.replenishment_units',
|
||||
safetyStock: 'pm.config_safety_stock',
|
||||
// Add new numeric fields
|
||||
preorderCount: 'p.preorder_count',
|
||||
notionsInvCount: 'p.notions_inv_count',
|
||||
@@ -267,88 +333,33 @@ router.get('/', async (req, res) => {
|
||||
'SELECT DISTINCT COALESCE(brand, \'Unbranded\') as brand FROM products WHERE visible = true ORDER BY brand'
|
||||
);
|
||||
|
||||
// Main query with all fields
|
||||
// Main query with all fields. Aliases new product_metrics column names back to
|
||||
// the legacy names the frontend ProductRow type still uses — same pattern as the
|
||||
// /:id detail route below.
|
||||
const query = `
|
||||
WITH RECURSIVE
|
||||
category_path AS (
|
||||
SELECT
|
||||
c.cat_id,
|
||||
c.name,
|
||||
c.parent_id,
|
||||
c.name::text as path
|
||||
FROM categories c
|
||||
WHERE c.parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
c.cat_id,
|
||||
c.name,
|
||||
c.parent_id,
|
||||
(cp.path || ' > ' || c.name)::text
|
||||
FROM categories c
|
||||
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||
),
|
||||
product_thresholds AS (
|
||||
SELECT
|
||||
p.pid,
|
||||
COALESCE(
|
||||
(SELECT overstock_days FROM stock_thresholds st
|
||||
WHERE st.category_id IN (
|
||||
SELECT pc.cat_id
|
||||
FROM product_categories pc
|
||||
WHERE pc.pid = p.pid
|
||||
)
|
||||
AND (st.vendor = p.vendor OR st.vendor IS NULL)
|
||||
ORDER BY st.vendor IS NULL
|
||||
LIMIT 1),
|
||||
(SELECT overstock_days FROM stock_thresholds st
|
||||
WHERE st.category_id IS NULL
|
||||
AND (st.vendor = p.vendor OR st.vendor IS NULL)
|
||||
ORDER BY st.vendor IS NULL
|
||||
LIMIT 1),
|
||||
90
|
||||
) as target_days
|
||||
FROM products p
|
||||
),
|
||||
product_leaf_categories AS (
|
||||
SELECT DISTINCT pc.cat_id
|
||||
FROM product_categories pc
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM categories child
|
||||
JOIN product_categories child_pc ON child.cat_id = child_pc.cat_id
|
||||
WHERE child.parent_id = pc.cat_id
|
||||
AND child_pc.pid = pc.pid
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
SELECT
|
||||
p.*,
|
||||
COALESCE(p.brand, 'Unbranded') as brand,
|
||||
string_agg(DISTINCT (c.cat_id || ':' || c.name), ',') as categories,
|
||||
pm.daily_sales_avg,
|
||||
pm.weekly_sales_avg,
|
||||
pm.monthly_sales_avg,
|
||||
pm.avg_quantity_per_order,
|
||||
pm.number_of_orders,
|
||||
pm.first_sale_date,
|
||||
pm.last_sale_date,
|
||||
pm.days_of_inventory,
|
||||
pm.weeks_of_inventory,
|
||||
pm.reorder_point,
|
||||
pm.safety_stock,
|
||||
pm.avg_margin_percent,
|
||||
CAST(pm.total_revenue AS DECIMAL(15,3)) as total_revenue,
|
||||
CAST(pm.inventory_value AS DECIMAL(15,3)) as inventory_value,
|
||||
CAST(pm.cost_of_goods_sold AS DECIMAL(15,3)) as cost_of_goods_sold,
|
||||
CAST(pm.gross_profit AS DECIMAL(15,3)) as gross_profit,
|
||||
pm.gmroi,
|
||||
pm.avg_sales_per_day_30d AS daily_sales_avg,
|
||||
pm.sales_7d AS weekly_sales_avg,
|
||||
pm.avg_sales_per_month_30d AS monthly_sales_avg,
|
||||
pm.date_first_sold AS first_sale_date,
|
||||
pm.date_last_sold AS last_sale_date,
|
||||
pm.stock_cover_in_days AS days_of_inventory,
|
||||
pm.replenishment_units AS reorder_point,
|
||||
pm.config_safety_stock AS safety_stock,
|
||||
pm.margin_30d AS avg_margin_percent,
|
||||
CAST(pm.lifetime_revenue AS DECIMAL(15,3)) as total_revenue,
|
||||
CAST(pm.current_stock_cost AS DECIMAL(15,3)) as inventory_value,
|
||||
CAST(pm.cogs_30d AS DECIMAL(15,3)) as cost_of_goods_sold,
|
||||
CAST(pm.profit_30d AS DECIMAL(15,3)) as gross_profit,
|
||||
pm.gmroi_30d AS gmroi,
|
||||
pm.avg_lead_time_days,
|
||||
pm.last_purchase_date,
|
||||
pm.last_received_date,
|
||||
pm.date_last_received AS last_received_date,
|
||||
pm.abc_class,
|
||||
pm.stock_status,
|
||||
pm.turnover_rate,
|
||||
pm.status AS stock_status,
|
||||
pm.stockturn_30d AS turnover_rate,
|
||||
p.date_last_sold
|
||||
FROM products p
|
||||
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||
@@ -389,12 +400,12 @@ router.get('/trending', async (req, res) => {
|
||||
try {
|
||||
// First check if we have any data
|
||||
const { rows } = await pool.query(`
|
||||
SELECT COUNT(*) as count,
|
||||
MAX(total_revenue) as max_revenue,
|
||||
MAX(daily_sales_avg) as max_daily_sales,
|
||||
SELECT COUNT(*) as count,
|
||||
MAX(lifetime_revenue) as max_revenue,
|
||||
MAX(avg_sales_per_day_30d) as max_daily_sales,
|
||||
COUNT(DISTINCT pid) as products_with_metrics
|
||||
FROM product_metrics
|
||||
WHERE total_revenue > 0 OR daily_sales_avg > 0
|
||||
FROM product_metrics
|
||||
WHERE lifetime_revenue > 0 OR avg_sales_per_day_30d > 0
|
||||
`);
|
||||
console.log('Product metrics stats:', rows[0]);
|
||||
|
||||
@@ -403,25 +414,24 @@ router.get('/trending', async (req, res) => {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
// Get trending products
|
||||
// Get trending products. growth_rate uses sales_growth_30d_vs_prev — a
|
||||
// pre-computed % delta of the last 30d window vs the prior 30d window.
|
||||
// (The old formula compared a per-day rate against a 7-day total, which
|
||||
// mixed units and produced nonsense after the metrics-schema rename.)
|
||||
const { rows: trendingProducts } = await pool.query(`
|
||||
SELECT
|
||||
SELECT
|
||||
p.pid,
|
||||
p.sku,
|
||||
p.title,
|
||||
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
|
||||
COALESCE(pm.weekly_sales_avg, 0) as weekly_sales_avg,
|
||||
CASE
|
||||
WHEN pm.weekly_sales_avg > 0 AND pm.daily_sales_avg > 0
|
||||
THEN ((pm.daily_sales_avg - pm.weekly_sales_avg) / pm.weekly_sales_avg) * 100
|
||||
ELSE 0
|
||||
END as growth_rate,
|
||||
COALESCE(pm.total_revenue, 0) as total_revenue
|
||||
COALESCE(pm.avg_sales_per_day_30d, 0) as daily_sales_avg,
|
||||
COALESCE(pm.sales_7d, 0) as weekly_sales_avg,
|
||||
COALESCE(pm.sales_growth_30d_vs_prev, 0) as growth_rate,
|
||||
COALESCE(pm.lifetime_revenue, 0) as total_revenue
|
||||
FROM products p
|
||||
INNER JOIN product_metrics pm ON p.pid = pm.pid
|
||||
WHERE (pm.total_revenue > 0 OR pm.daily_sales_avg > 0)
|
||||
WHERE (pm.lifetime_revenue > 0 OR pm.avg_sales_per_day_30d > 0)
|
||||
AND p.visible = true
|
||||
ORDER BY growth_rate DESC
|
||||
ORDER BY growth_rate DESC NULLS LAST
|
||||
LIMIT 50
|
||||
`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user