Add routes for brands, categories, vendors new implementation

This commit is contained in:
2025-04-01 12:03:12 -04:00
parent a9dbbbf824
commit 1b9f01d101
5 changed files with 734 additions and 0 deletions

View File

@@ -0,0 +1,212 @@
const express = require('express');
const router = express.Router();
const { parseValue } = require('../utils/apiHelpers'); // Adjust path if needed
// --- Configuration & Helpers ---
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 200;
// Maps query keys to DB columns in brand_metrics
const COLUMN_MAP = {
brandName: { dbCol: 'bm.brand_name', type: 'string' },
productCount: { dbCol: 'bm.product_count', type: 'number' },
activeProductCount: { dbCol: 'bm.active_product_count', type: 'number' },
replenishableProductCount: { dbCol: 'bm.replenishable_product_count', type: 'number' },
currentStockUnits: { dbCol: 'bm.current_stock_units', type: 'number' },
currentStockCost: { dbCol: 'bm.current_stock_cost', type: 'number' },
currentStockRetail: { dbCol: 'bm.current_stock_retail', type: 'number' },
sales7d: { dbCol: 'bm.sales_7d', type: 'number' },
revenue7d: { dbCol: 'bm.revenue_7d', type: 'number' },
sales30d: { dbCol: 'bm.sales_30d', type: 'number' },
revenue30d: { dbCol: 'bm.revenue_30d', type: 'number' },
profit30d: { dbCol: 'bm.profit_30d', type: 'number' },
cogs30d: { dbCol: 'bm.cogs_30d', type: 'number' },
sales365d: { dbCol: 'bm.sales_365d', type: 'number' },
revenue365d: { dbCol: 'bm.revenue_365d', type: 'number' },
lifetimeSales: { dbCol: 'bm.lifetime_sales', type: 'number' },
lifetimeRevenue: { dbCol: 'bm.lifetime_revenue', type: 'number' },
avgMargin30d: { dbCol: 'bm.avg_margin_30d', type: 'number' },
// Add aliases if needed
name: { dbCol: 'bm.brand_name', type: 'string' },
};
function getSafeColumnInfo(queryParamKey) {
return COLUMN_MAP[queryParamKey] || null;
}
// --- Route Handlers ---
// GET /brands-aggregate/filter-options (Just brands list for now)
router.get('/filter-options', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /brands-aggregate/filter-options');
try {
const { rows: brandRows } = await pool.query(`
SELECT DISTINCT brand_name FROM public.brand_metrics ORDER BY brand_name
`);
res.json({
brands: brandRows.map(r => r.brand_name),
});
} catch(error) {
console.error('Error fetching brand filter options:', error);
res.status(500).json({ error: 'Failed to fetch filter options' });
}
});
// GET /brands-aggregate/stats (Overall brand stats)
router.get('/stats', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /brands-aggregate/stats');
try {
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(*) AS total_brands,
SUM(active_product_count) AS total_active_products,
SUM(current_stock_cost) AS total_stock_value,
-- Weighted Average Margin
SUM(profit_30d) * 100.0 / NULLIF(SUM(revenue_30d), 0) AS overall_avg_margin_weighted
-- Add other stats
FROM public.brand_metrics bm
`);
res.json({
totalBrands: parseInt(stats?.total_brands || 0),
totalActiveProducts: parseInt(stats?.total_active_products || 0),
totalValue: parseFloat(stats?.total_stock_value || 0),
avgMargin: parseFloat(stats?.overall_avg_margin_weighted || 0),
});
} catch (error) {
console.error('Error fetching brand stats:', error);
res.status(500).json({ error: 'Failed to fetch brand stats.' });
}
});
// GET /brands-aggregate/ (List brands)
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /brands-aggregate received query:', req.query);
try {
// --- Pagination ---
let page = parseInt(req.query.page, 10) || 1;
let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT;
limit = Math.min(limit, MAX_PAGE_LIMIT);
const offset = (page - 1) * limit;
// --- Sorting ---
const sortQueryKey = req.query.sort || 'brandName'; // Default sort
const sortColumnInfo = getSafeColumnInfo(sortQueryKey);
const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'bm.brand_name';
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST');
const sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`;
// --- Filtering ---
const conditions = [];
const params = [];
let paramCounter = 1;
// Build conditions based on req.query, using COLUMN_MAP and parseValue
for (const key in req.query) {
if (['page', 'limit', 'sort', 'order'].includes(key)) continue;
let filterKey = key;
let operator = '='; // Default operator
const value = req.query[key];
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
if (operatorMatch) {
filterKey = operatorMatch[1];
operator = operatorMatch[2];
}
const columnInfo = getSafeColumnInfo(filterKey);
if (columnInfo) {
const dbColumn = columnInfo.dbCol;
const valueType = columnInfo.type;
try {
let conditionFragment = '';
let needsParam = true;
switch (operator.toLowerCase()) { // Normalize operator
case 'eq': operator = '='; break;
case 'ne': operator = '<>'; break;
case 'gt': operator = '>'; break;
case 'gte': operator = '>='; break;
case 'lt': operator = '<'; break;
case 'lte': operator = '<='; break;
case 'like': operator = 'LIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'ilike': operator = 'ILIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'between':
const [val1, val2] = String(value).split(',');
if (val1 !== undefined && val2 !== undefined) {
conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
params.push(parseValue(val1, valueType), parseValue(val2, valueType));
needsParam = false;
} else continue;
break;
case 'in':
const inValues = String(value).split(',');
if (inValues.length > 0) {
const placeholders = inValues.map(() => `$${paramCounter++}`).join(', ');
conditionFragment = `${dbColumn} IN (${placeholders})`;
params.push(...inValues.map(v => parseValue(v, valueType)));
needsParam = false;
} else continue;
break;
default: operator = '='; break;
}
if (needsParam) {
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
params.push(parseValue(value, valueType));
} else if (!conditionFragment) { // For LIKE/ILIKE
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
}
if (conditionFragment) {
conditions.push(`(${conditionFragment})`);
}
} catch (parseError) {
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`);
if (needsParam) paramCounter--;
}
} else {
console.warn(`Invalid filter key ignored: ${key}`);
}
}
// --- Execute Queries ---
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const baseSql = `FROM public.brand_metrics bm ${whereClause}`;
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
const dataSql = `SELECT bm.* ${baseSql} ${sortClause} LIMIT $${paramCounter} OFFSET $${paramCounter + 1}`;
const dataParams = [...params, limit, offset];
console.log("Count SQL:", countSql, params);
console.log("Data SQL:", dataSql, dataParams);
const [countResult, dataResult] = await Promise.all([
pool.query(countSql, params),
pool.query(dataSql, dataParams)
]);
const total = parseInt(countResult.rows[0].total, 10);
const brands = dataResult.rows;
// --- Respond ---
res.json({
brands,
pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit },
});
} catch (error) {
console.error('Error fetching brand metrics list:', error);
res.status(500).json({ error: 'Failed to fetch brand metrics.' });
}
});
// GET /brands-aggregate/:name (Get single brand metric)
// Implement if needed, remember to URL-decode the name parameter
module.exports = router;

View File

@@ -0,0 +1,265 @@
const express = require('express');
const router = express.Router();
const { parseValue } = require('../utils/apiHelpers'); // Adjust path if needed
// --- Configuration & Helpers ---
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 200;
// Maps query keys to DB columns in category_metrics and categories tables
const COLUMN_MAP = {
categoryId: { dbCol: 'cm.category_id', type: 'integer' },
categoryName: { dbCol: 'cm.category_name', type: 'string' }, // From aggregate table
categoryType: { dbCol: 'cm.category_type', type: 'integer' }, // From aggregate table
parentId: { dbCol: 'cm.parent_id', type: 'integer' }, // From aggregate table
parentName: { dbCol: 'p.name', type: 'string' }, // Requires JOIN to categories
productCount: { dbCol: 'cm.product_count', type: 'number' },
activeProductCount: { dbCol: 'cm.active_product_count', type: 'number' },
replenishableProductCount: { dbCol: 'cm.replenishable_product_count', type: 'number' },
currentStockUnits: { dbCol: 'cm.current_stock_units', type: 'number' },
currentStockCost: { dbCol: 'cm.current_stock_cost', type: 'number' },
currentStockRetail: { dbCol: 'cm.current_stock_retail', type: 'number' },
sales7d: { dbCol: 'cm.sales_7d', type: 'number' },
revenue7d: { dbCol: 'cm.revenue_7d', type: 'number' },
sales30d: { dbCol: 'cm.sales_30d', type: 'number' },
revenue30d: { dbCol: 'cm.revenue_30d', type: 'number' },
profit30d: { dbCol: 'cm.profit_30d', type: 'number' },
cogs30d: { dbCol: 'cm.cogs_30d', type: 'number' },
sales365d: { dbCol: 'cm.sales_365d', type: 'number' },
revenue365d: { dbCol: 'cm.revenue_365d', type: 'number' },
lifetimeSales: { dbCol: 'cm.lifetime_sales', type: 'number' },
lifetimeRevenue: { dbCol: 'cm.lifetime_revenue', type: 'number' },
avgMargin30d: { dbCol: 'cm.avg_margin_30d', type: 'number' },
stockTurn30d: { dbCol: 'cm.stock_turn_30d', type: 'number' },
// Add 'status' if filtering by category status needed (requires JOIN)
// status: { dbCol: 'c.status', type: 'string' },
};
function getSafeColumnInfo(queryParamKey) {
return COLUMN_MAP[queryParamKey] || null;
}
// Type Labels (Consider moving to a shared config or fetching from DB)
const TYPE_LABELS = {
10: 'Section', 11: 'Category', 12: 'Subcategory', 13: 'Sub-subcategory',
1: 'Company', 2: 'Line', 3: 'Subline', 40: 'Artist', // From old schema comments
// Add other types if needed
};
// --- Route Handlers ---
// GET /categories-aggregate/filter-options
router.get('/filter-options', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /categories-aggregate/filter-options');
try {
// Fetch distinct types directly from the aggregate table if reliable
// Or join with categories table if source of truth is needed
const { rows: typeRows } = await pool.query(`
SELECT DISTINCT category_type
FROM public.category_metrics
ORDER BY category_type
`);
const typeOptions = typeRows.map(r => ({
value: r.category_type,
label: TYPE_LABELS[r.category_type] || `Type ${r.category_type}` // Add labels
}));
// Add other filter options like status if needed
// const { rows: statusRows } = await pool.query(`SELECT DISTINCT status FROM public.categories ORDER BY status`);
res.json({
types: typeOptions,
// statuses: statusRows.map(r => r.status)
});
} catch (error) {
console.error('Error fetching category filter options:', error);
res.status(500).json({ error: 'Failed to fetch filter options' });
}
});
// GET /categories-aggregate/stats
router.get('/stats', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /categories-aggregate/stats');
try {
// Calculate stats directly from the aggregate table
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(*) AS total_categories,
-- Count active based on the source categories table status
COUNT(CASE WHEN c.status = 'active' THEN cm.category_id END) AS active_categories,
SUM(cm.active_product_count) AS total_active_products, -- Sum from aggregates
SUM(cm.current_stock_cost) AS total_stock_value, -- Sum from aggregates
-- Weighted Average Margin (Revenue as weight)
SUM(cm.profit_30d) * 100.0 / NULLIF(SUM(cm.revenue_30d), 0) AS overall_avg_margin_weighted,
-- Simple Average Margin (less accurate if categories vary greatly in size)
AVG(NULLIF(cm.avg_margin_30d, 0)) AS overall_avg_margin_simple
-- Add SUM(revenue_30d) / SUM(revenue_30d_previous) for growth if needed
FROM public.category_metrics cm
JOIN public.categories c ON cm.category_id = c.cat_id -- Join to check category status
`);
res.json({
totalCategories: parseInt(stats?.total_categories || 0),
activeCategories: parseInt(stats?.active_categories || 0), // Based on categories.status
totalActiveProducts: parseInt(stats?.total_active_products || 0),
totalValue: parseFloat(stats?.total_stock_value || 0),
// Choose which avg margin calculation to expose
avgMargin: parseFloat(stats?.overall_avg_margin_weighted || stats?.overall_avg_margin_simple || 0),
// avgGrowth: ... // Calculate if needed
});
} catch (error) {
console.error('Error fetching category stats:', error);
res.status(500).json({ error: 'Failed to fetch category stats.' });
}
});
// GET /categories-aggregate/ (List categories)
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /categories-aggregate received query:', req.query);
try {
// --- Pagination ---
let page = parseInt(req.query.page, 10) || 1;
let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT;
limit = Math.min(limit, MAX_PAGE_LIMIT);
const offset = (page - 1) * limit;
// --- Sorting ---
const sortQueryKey = req.query.sort || 'categoryName';
const sortColumnInfo = getSafeColumnInfo(sortQueryKey);
// Default sort order: Type then Name
const defaultSortOrder = 'ORDER BY cm.category_type ASC, cm.category_name ASC';
let sortClause = defaultSortOrder;
if (sortColumnInfo) {
const sortColumn = sortColumnInfo.dbCol;
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST');
sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`;
}
// --- Filtering ---
const conditions = [];
const params = [];
let paramCounter = 1;
// Add filters based on req.query using COLUMN_MAP and parseValue
for (const key in req.query) {
if (['page', 'limit', 'sort', 'order'].includes(key)) continue;
let filterKey = key;
let operator = '='; // Default operator
const value = req.query[key];
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
if (operatorMatch) {
filterKey = operatorMatch[1];
operator = operatorMatch[2];
}
// Special case for parentName requires join
const requiresJoin = filterKey === 'parentName';
const columnInfo = getSafeColumnInfo(filterKey);
if (columnInfo) {
const dbColumn = columnInfo.dbCol;
const valueType = columnInfo.type;
try {
let conditionFragment = '';
let needsParam = true;
switch (operator.toLowerCase()) {
case 'eq': operator = '='; break;
case 'ne': operator = '<>'; break;
case 'gt': operator = '>'; break;
case 'gte': operator = '>='; break;
case 'lt': operator = '<'; break;
case 'lte': operator = '<='; break;
case 'like': operator = 'LIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'ilike': operator = 'ILIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'between':
const [val1, val2] = String(value).split(',');
if (val1 !== undefined && val2 !== undefined) {
conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
params.push(parseValue(val1, valueType), parseValue(val2, valueType));
needsParam = false;
} else continue;
break;
case 'in':
const inValues = String(value).split(',');
if (inValues.length > 0) {
const placeholders = inValues.map(() => `$${paramCounter++}`).join(', ');
conditionFragment = `${dbColumn} IN (${placeholders})`;
params.push(...inValues.map(v => parseValue(v, valueType)));
needsParam = false;
} else continue;
break;
default: operator = '='; break;
}
if (needsParam) {
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
params.push(parseValue(value, valueType));
} else if (!conditionFragment) { // For LIKE/ILIKE where needsParam is false
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`; // paramCounter was already incremented in push
}
if (conditionFragment) {
conditions.push(`(${conditionFragment})`);
}
} catch (parseError) {
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`);
if (needsParam) paramCounter--; // Roll back counter if param push failed
}
} else {
console.warn(`Invalid filter key ignored: ${key}`);
}
}
// --- Execute Queries ---
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Need JOIN for parent_name if sorting/filtering by it, or always include for display
const needParentJoin = sortColumn === 'p.name' || conditions.some(c => c.includes('p.name'));
const baseSql = `
FROM public.category_metrics cm
${needParentJoin ? 'LEFT JOIN public.categories p ON cm.parent_id = p.cat_id' : ''}
${whereClause}
`;
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
const dataSql = `
SELECT cm.* ${needParentJoin ? ', p.name as parent_name' : ''}
${baseSql}
${sortClause}
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`;
const dataParams = [...params, limit, offset];
console.log("Count SQL:", countSql, params);
console.log("Data SQL:", dataSql, dataParams);
const [countResult, dataResult] = await Promise.all([
pool.query(countSql, params),
pool.query(dataSql, dataParams)
]);
const total = parseInt(countResult.rows[0].total, 10);
const categories = dataResult.rows;
// --- Respond ---
res.json({
categories,
pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit },
});
} catch (error) {
console.error('Error fetching category metrics list:', error);
res.status(500).json({ error: 'Failed to fetch category metrics.' });
}
});
module.exports = router;

View File

@@ -0,0 +1,216 @@
const express = require('express');
const router = express.Router();
const { parseValue } = require('../utils/apiHelpers'); // Adjust path if needed
// --- Configuration & Helpers ---
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 200;
// Maps query keys to DB columns in vendor_metrics
const COLUMN_MAP = {
vendorName: { dbCol: 'vm.vendor_name', type: 'string' },
productCount: { dbCol: 'vm.product_count', type: 'number' },
activeProductCount: { dbCol: 'vm.active_product_count', type: 'number' },
replenishableProductCount: { dbCol: 'vm.replenishable_product_count', type: 'number' },
currentStockUnits: { dbCol: 'vm.current_stock_units', type: 'number' },
currentStockCost: { dbCol: 'vm.current_stock_cost', type: 'number' },
currentStockRetail: { dbCol: 'vm.current_stock_retail', type: 'number' },
onOrderUnits: { dbCol: 'vm.on_order_units', type: 'number' },
onOrderCost: { dbCol: 'vm.on_order_cost', type: 'number' },
poCount365d: { dbCol: 'vm.po_count_365d', type: 'number' },
avgLeadTimeDays: { dbCol: 'vm.avg_lead_time_days', type: 'number' },
sales7d: { dbCol: 'vm.sales_7d', type: 'number' },
revenue7d: { dbCol: 'vm.revenue_7d', type: 'number' },
sales30d: { dbCol: 'vm.sales_30d', type: 'number' },
revenue30d: { dbCol: 'vm.revenue_30d', type: 'number' },
profit30d: { dbCol: 'vm.profit_30d', type: 'number' },
cogs30d: { dbCol: 'vm.cogs_30d', type: 'number' },
sales365d: { dbCol: 'vm.sales_365d', type: 'number' },
revenue365d: { dbCol: 'vm.revenue_365d', type: 'number' },
lifetimeSales: { dbCol: 'vm.lifetime_sales', type: 'number' },
lifetimeRevenue: { dbCol: 'vm.lifetime_revenue', type: 'number' },
avgMargin30d: { dbCol: 'vm.avg_margin_30d', type: 'number' },
// Add aliases if needed for frontend compatibility
name: { dbCol: 'vm.vendor_name', type: 'string' },
leadTime: { dbCol: 'vm.avg_lead_time_days', type: 'number' },
};
function getSafeColumnInfo(queryParamKey) {
return COLUMN_MAP[queryParamKey] || null;
}
// --- Route Handlers ---
// GET /vendors-aggregate/filter-options (Just vendors list for now)
router.get('/filter-options', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /vendors-aggregate/filter-options');
try {
const { rows: vendorRows } = await pool.query(`
SELECT DISTINCT vendor_name FROM public.vendor_metrics ORDER BY vendor_name
`);
res.json({
vendors: vendorRows.map(r => r.vendor_name),
});
} catch(error) {
console.error('Error fetching vendor filter options:', error);
res.status(500).json({ error: 'Failed to fetch filter options' });
}
});
// GET /vendors-aggregate/stats (Overall vendor stats)
router.get('/stats', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /vendors-aggregate/stats');
try {
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(*) AS total_vendors,
SUM(active_product_count) AS total_active_products,
SUM(current_stock_cost) AS total_stock_value,
SUM(on_order_cost) AS total_on_order_value,
AVG(NULLIF(avg_lead_time_days, 0)) AS overall_avg_lead_time -- Simple average
-- Add more overall stats: weighted margin, total POs etc.
FROM public.vendor_metrics vm
`);
res.json({
totalVendors: parseInt(stats?.total_vendors || 0),
totalActiveProducts: parseInt(stats?.total_active_products || 0),
totalValue: parseFloat(stats?.total_stock_value || 0),
totalOnOrderValue: parseFloat(stats?.total_on_order_value || 0),
avgLeadTime: parseFloat(stats?.overall_avg_lead_time || 0)
});
} catch (error) {
console.error('Error fetching vendor stats:', error);
res.status(500).json({ error: 'Failed to fetch vendor stats.' });
}
});
// GET /vendors-aggregate/ (List vendors)
router.get('/', async (req, res) => {
const pool = req.app.locals.pool;
console.log('GET /vendors-aggregate received query:', req.query);
try {
// --- Pagination ---
let page = parseInt(req.query.page, 10) || 1;
let limit = parseInt(req.query.limit, 10) || DEFAULT_PAGE_LIMIT;
limit = Math.min(limit, MAX_PAGE_LIMIT);
const offset = (page - 1) * limit;
// --- Sorting ---
const sortQueryKey = req.query.sort || 'vendorName'; // Default sort
const sortColumnInfo = getSafeColumnInfo(sortQueryKey);
const sortColumn = sortColumnInfo ? sortColumnInfo.dbCol : 'vm.vendor_name';
const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const nullsOrder = (sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST');
const sortClause = `ORDER BY ${sortColumn} ${sortDirection} ${nullsOrder}`;
// --- Filtering ---
const conditions = [];
const params = [];
let paramCounter = 1;
// Build conditions based on req.query, using COLUMN_MAP and parseValue
for (const key in req.query) {
if (['page', 'limit', 'sort', 'order'].includes(key)) continue;
let filterKey = key;
let operator = '='; // Default operator
const value = req.query[key];
const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|between|in)$/);
if (operatorMatch) {
filterKey = operatorMatch[1];
operator = operatorMatch[2];
}
const columnInfo = getSafeColumnInfo(filterKey);
if (columnInfo) {
const dbColumn = columnInfo.dbCol;
const valueType = columnInfo.type;
try {
let conditionFragment = '';
let needsParam = true;
switch (operator.toLowerCase()) { // Normalize operator
case 'eq': operator = '='; break;
case 'ne': operator = '<>'; break;
case 'gt': operator = '>'; break;
case 'gte': operator = '>='; break;
case 'lt': operator = '<'; break;
case 'lte': operator = '<='; break;
case 'like': operator = 'LIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'ilike': operator = 'ILIKE'; needsParam=false; params.push(`%${parseValue(value, valueType)}%`); break;
case 'between':
const [val1, val2] = String(value).split(',');
if (val1 !== undefined && val2 !== undefined) {
conditionFragment = `${dbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`;
params.push(parseValue(val1, valueType), parseValue(val2, valueType));
needsParam = false;
} else continue;
break;
case 'in':
const inValues = String(value).split(',');
if (inValues.length > 0) {
const placeholders = inValues.map(() => `$${paramCounter++}`).join(', ');
conditionFragment = `${dbColumn} IN (${placeholders})`;
params.push(...inValues.map(v => parseValue(v, valueType)));
needsParam = false;
} else continue;
break;
default: operator = '='; break;
}
if (needsParam) {
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
params.push(parseValue(value, valueType));
} else if (!conditionFragment) { // For LIKE/ILIKE
conditionFragment = `${dbColumn} ${operator} $${paramCounter++}`;
}
if (conditionFragment) {
conditions.push(`(${conditionFragment})`);
}
} catch (parseError) {
console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`);
if (needsParam) paramCounter--;
}
} else {
console.warn(`Invalid filter key ignored: ${key}`);
}
}
// --- Execute Queries ---
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const baseSql = `FROM public.vendor_metrics vm ${whereClause}`;
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
const dataSql = `SELECT vm.* ${baseSql} ${sortClause} LIMIT $${paramCounter} OFFSET $${paramCounter + 1}`;
const dataParams = [...params, limit, offset];
console.log("Count SQL:", countSql, params);
console.log("Data SQL:", dataSql, dataParams);
const [countResult, dataResult] = await Promise.all([
pool.query(countSql, params),
pool.query(dataSql, dataParams)
]);
const total = parseInt(countResult.rows[0].total, 10);
const vendors = dataResult.rows;
// --- Respond ---
res.json({
vendors,
pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit },
});
} catch (error) {
console.error('Error fetching vendor metrics list:', error);
res.status(500).json({ error: 'Failed to fetch vendor metrics.' });
}
});
// GET /vendors-aggregate/:name (Get single vendor metric)
// Implement if needed, remember to URL-decode the name parameter
module.exports = router;

View File

@@ -20,6 +20,9 @@ const aiValidationRouter = require('./routes/ai-validation');
const templatesRouter = require('./routes/templates');
const aiPromptsRouter = require('./routes/ai-prompts');
const reusableImagesRouter = require('./routes/reusable-images');
const categoriesAggregateRouter = require('./routes/categoriesAggregate');
const vendorsAggregateRouter = require('./routes/vendorsAggregate');
const brandsAggregateRouter = require('./routes/brandsAggregate');
// Get the absolute path to the .env file
const envPath = '/var/www/html/inventory/.env';
@@ -107,6 +110,9 @@ async function startServer() {
app.use('/api/templates', templatesRouter);
app.use('/api/ai-prompts', aiPromptsRouter);
app.use('/api/reusable-images', reusableImagesRouter);
app.use('/api/categories-aggregate', categoriesAggregateRouter);
app.use('/api/vendors-aggregate', vendorsAggregateRouter);
app.use('/api/brands-aggregate', brandsAggregateRouter);
// Basic health check route
app.get('/health', (req, res) => {

View File

@@ -0,0 +1,35 @@
/**
* Parses a query parameter value based on its expected type.
* Throws error for invalid formats. Adjust date handling as needed.
*/
function parseValue(value, type) {
if (value === null || value === undefined || value === '') return null;
switch (type) {
case 'number':
const num = parseFloat(value);
if (isNaN(num)) throw new Error(`Invalid number format: "${value}"`);
return num;
case 'integer': // Specific type for integer IDs etc.
const int = parseInt(value, 10);
if (isNaN(int)) throw new Error(`Invalid integer format: "${value}"`);
return int;
case 'boolean':
if (String(value).toLowerCase() === 'true') return true;
if (String(value).toLowerCase() === 'false') return false;
throw new Error(`Invalid boolean format: "${value}"`);
case 'date':
// Basic ISO date format validation (YYYY-MM-DD)
if (!String(value).match(/^\d{4}-\d{2}-\d{2}$/)) {
console.warn(`Potentially invalid date format passed: "${value}"`);
// Optionally throw an error or return null depending on strictness
// throw new Error(`Invalid date format (YYYY-MM-DD expected): "${value}"`);
}
return String(value); // Send as string, let DB handle casting/comparison
case 'string':
default:
return String(value);
}
}
module.exports = { parseValue };