const express = require('express'); const router = express.Router(); // --- Configuration & Helpers --- const DEFAULT_PAGE_LIMIT = 50; const MAX_PAGE_LIMIT = 200; // Prevent excessive data requests // Define direct mapping from frontend column names to database columns // This simplifies the code by eliminating conversion logic const COLUMN_MAP = { // Product Info pid: 'pm.pid', sku: 'pm.sku', title: 'pm.title', brand: 'pm.brand', vendor: 'pm.vendor', imageUrl: 'pm.image_url', isVisible: 'pm.is_visible', isReplenishable: 'pm.is_replenishable', // Additional Product Fields barcode: 'pm.barcode', harmonizedTariffCode: 'pm.harmonized_tariff_code', vendorReference: 'pm.vendor_reference', notionsReference: 'pm.notions_reference', line: 'pm.line', subline: 'pm.subline', artist: 'pm.artist', moq: 'pm.moq', rating: 'pm.rating', reviews: 'pm.reviews', weight: 'pm.weight', length: 'pm.length', width: 'pm.width', height: 'pm.height', countryOfOrigin: 'pm.country_of_origin', location: 'pm.location', baskets: 'pm.baskets', notifies: 'pm.notifies', preorderCount: 'pm.preorder_count', notionsInvCount: 'pm.notions_inv_count', // Current Status currentPrice: 'pm.current_price', currentRegularPrice: 'pm.current_regular_price', currentCostPrice: 'pm.current_cost_price', currentStock: 'pm.current_stock', currentStockCost: 'pm.current_stock_cost', currentStockRetail: 'pm.current_stock_retail', currentStockGross: 'pm.current_stock_gross', onOrderQty: 'pm.on_order_qty', onOrderCost: 'pm.on_order_cost', onOrderRetail: 'pm.on_order_retail', earliestExpectedDate: 'pm.earliest_expected_date', // Historical Dates dateCreated: 'pm.date_created', dateFirstReceived: 'pm.date_first_received', dateLastReceived: 'pm.date_last_received', dateFirstSold: 'pm.date_first_sold', dateLastSold: 'pm.date_last_sold', ageDays: 'pm.age_days', // Rolling Period Metrics sales7d: 'pm.sales_7d', revenue7d: 'pm.revenue_7d', sales14d: 'pm.sales_14d', revenue14d: 'pm.revenue_14d', sales30d: 'pm.sales_30d', revenue30d: 'pm.revenue_30d', cogs30d: 'pm.cogs_30d', profit30d: 'pm.profit_30d', returnsUnits30d: 'pm.returns_units_30d', returnsRevenue30d: 'pm.returns_revenue_30d', discounts30d: 'pm.discounts_30d', grossRevenue30d: 'pm.gross_revenue_30d', grossRegularRevenue30d: 'pm.gross_regular_revenue_30d', stockoutDays30d: 'pm.stockout_days_30d', sales365d: 'pm.sales_365d', revenue365d: 'pm.revenue_365d', avgStockUnits30d: 'pm.avg_stock_units_30d', avgStockCost30d: 'pm.avg_stock_cost_30d', avgStockRetail30d: 'pm.avg_stock_retail_30d', avgStockGross30d: 'pm.avg_stock_gross_30d', receivedQty30d: 'pm.received_qty_30d', receivedCost30d: 'pm.received_cost_30d', // Lifetime Metrics lifetimeSales: 'pm.lifetime_sales', lifetimeRevenue: 'pm.lifetime_revenue', // First Period Metrics first7DaysSales: 'pm.first_7_days_sales', first7DaysRevenue: 'pm.first_7_days_revenue', first30DaysSales: 'pm.first_30_days_sales', first30DaysRevenue: 'pm.first_30_days_revenue', first60DaysSales: 'pm.first_60_days_sales', first60DaysRevenue: 'pm.first_60_days_revenue', first90DaysSales: 'pm.first_90_days_sales', first90DaysRevenue: 'pm.first_90_days_revenue', // Calculated KPIs asp30d: 'pm.asp_30d', acp30d: 'pm.acp_30d', avgRos30d: 'pm.avg_ros_30d', avgSalesPerDay30d: 'pm.avg_sales_per_day_30d', avgSalesPerMonth30d: 'pm.avg_sales_per_month_30d', margin30d: 'pm.margin_30d', markup30d: 'pm.markup_30d', gmroi30d: 'pm.gmroi_30d', stockturn30d: 'pm.stockturn_30d', returnRate30d: 'pm.return_rate_30d', discountRate30d: 'pm.discount_rate_30d', stockoutRate30d: 'pm.stockout_rate_30d', markdown30d: 'pm.markdown_30d', markdownRate30d: 'pm.markdown_rate_30d', sellThrough30d: 'pm.sell_through_30d', avgLeadTimeDays: 'pm.avg_lead_time_days', // Forecasting & Replenishment abcClass: 'pm.abc_class', salesVelocityDaily: 'pm.sales_velocity_daily', configLeadTime: 'pm.config_lead_time', configDaysOfStock: 'pm.config_days_of_stock', configSafetyStock: 'pm.config_safety_stock', planningPeriodDays: 'pm.planning_period_days', leadTimeForecastUnits: 'pm.lead_time_forecast_units', daysOfStockForecastUnits: 'pm.days_of_stock_forecast_units', planningPeriodForecastUnits: 'pm.planning_period_forecast_units', leadTimeClosingStock: 'pm.lead_time_closing_stock', daysOfStockClosingStock: 'pm.days_of_stock_closing_stock', replenishmentNeededRaw: 'pm.replenishment_needed_raw', replenishmentUnits: 'pm.replenishment_units', replenishmentCost: 'pm.replenishment_cost', replenishmentRetail: 'pm.replenishment_retail', replenishmentProfit: 'pm.replenishment_profit', toOrderUnits: 'pm.to_order_units', forecastLostSalesUnits: 'pm.forecast_lost_sales_units', forecastLostRevenue: 'pm.forecast_lost_revenue', stockCoverInDays: 'pm.stock_cover_in_days', poCoverInDays: 'pm.po_cover_in_days', sellsOutInDays: 'pm.sells_out_in_days', replenishDate: 'pm.replenish_date', overstockedUnits: 'pm.overstocked_units', overstockedCost: 'pm.overstocked_cost', overstockedRetail: 'pm.overstocked_retail', isOldStock: 'pm.is_old_stock', // Yesterday yesterdaySales: 'pm.yesterday_sales', // Map status column - directly mapped now instead of calculated on frontend status: 'pm.status', // Growth Metrics (P3) salesGrowth30dVsPrev: 'pm.sales_growth_30d_vs_prev', revenueGrowth30dVsPrev: 'pm.revenue_growth_30d_vs_prev', salesGrowthYoy: 'pm.sales_growth_yoy', revenueGrowthYoy: 'pm.revenue_growth_yoy', // Demand Variability Metrics (P3) salesVariance30d: 'pm.sales_variance_30d', salesStdDev30d: 'pm.sales_std_dev_30d', salesCv30d: 'pm.sales_cv_30d', demandPattern: 'pm.demand_pattern', // Service Level Metrics (P5) fillRate30d: 'pm.fill_rate_30d', stockoutIncidents30d: 'pm.stockout_incidents_30d', serviceLevel30d: 'pm.service_level_30d', lostSalesIncidents30d: 'pm.lost_sales_incidents_30d', // Seasonality Metrics (P5) seasonalityIndex: 'pm.seasonality_index', seasonalPattern: 'pm.seasonal_pattern', peakSeason: 'pm.peak_season', // Lifetime Revenue Quality lifetimeRevenueQuality: 'pm.lifetime_revenue_quality' }; // Define column types for use in sorting/filtering // This helps apply correct comparison operators and sorting logic const COLUMN_TYPES = { // Numeric columns (use numeric operators and sorting) numeric: [ 'pid', 'currentPrice', 'currentRegularPrice', 'currentCostPrice', 'currentStock', 'currentStockCost', 'currentStockRetail', 'currentStockGross', 'onOrderQty', 'onOrderCost', 'onOrderRetail', 'ageDays', 'sales7d', 'revenue7d', 'sales14d', 'revenue14d', 'sales30d', 'revenue30d', 'cogs30d', 'profit30d', 'returnsUnits30d', 'returnsRevenue30d', 'discounts30d', 'grossRevenue30d', 'grossRegularRevenue30d', 'stockoutDays30d', 'sales365d', 'revenue365d', 'avgStockUnits30d', 'avgStockCost30d', 'avgStockRetail30d', 'avgStockGross30d', 'receivedQty30d', 'receivedCost30d', 'lifetimeSales', 'lifetimeRevenue', 'first7DaysSales', 'first7DaysRevenue', 'first30DaysSales', 'first30DaysRevenue', 'first60DaysSales', 'first60DaysRevenue', 'first90DaysSales', 'first90DaysRevenue', 'asp30d', 'acp30d', 'avgRos30d', 'avgSalesPerDay30d', 'avgSalesPerMonth30d', 'margin30d', 'markup30d', 'gmroi30d', 'stockturn30d', 'returnRate30d', 'discountRate30d', 'stockoutRate30d', 'markdown30d', 'markdownRate30d', 'sellThrough30d', 'avgLeadTimeDays', 'salesVelocityDaily', 'configLeadTime', 'configDaysOfStock', 'configSafetyStock', 'planningPeriodDays', 'leadTimeForecastUnits', 'daysOfStockForecastUnits', 'planningPeriodForecastUnits', 'leadTimeClosingStock', 'daysOfStockClosingStock', 'replenishmentNeededRaw', 'replenishmentUnits', 'replenishmentCost', 'replenishmentRetail', 'replenishmentProfit', 'toOrderUnits', 'forecastLostSalesUnits', 'forecastLostRevenue', 'stockCoverInDays', 'poCoverInDays', 'sellsOutInDays', 'overstockedUnits', 'overstockedCost', 'overstockedRetail', 'yesterdaySales', // New numeric columns 'moq', 'rating', 'reviews', 'weight', 'length', 'width', 'height', 'baskets', 'notifies', 'preorderCount', 'notionsInvCount', // Growth metrics 'salesGrowth30dVsPrev', 'revenueGrowth30dVsPrev', 'salesGrowthYoy', 'revenueGrowthYoy', // Demand variability metrics 'salesVariance30d', 'salesStdDev30d', 'salesCv30d', // Service level metrics 'fillRate30d', 'stockoutIncidents30d', 'serviceLevel30d', 'lostSalesIncidents30d', // Seasonality metrics 'seasonalityIndex' ], // Date columns (use date operators and sorting) date: [ 'dateCreated', 'dateFirstReceived', 'dateLastReceived', 'dateFirstSold', 'dateLastSold', 'earliestExpectedDate', 'replenishDate', 'forecastedOutOfStockDate' ], // String columns (use string operators and sorting) string: [ 'sku', 'title', 'brand', 'vendor', 'imageUrl', 'abcClass', 'status', // New string columns 'barcode', 'harmonizedTariffCode', 'vendorReference', 'notionsReference', 'line', 'subline', 'artist', 'countryOfOrigin', 'location', // New string columns for patterns 'demandPattern', 'seasonalPattern', 'peakSeason', 'lifetimeRevenueQuality' ], // Boolean columns (use boolean operators and sorting) boolean: ['isVisible', 'isReplenishable', 'isOldStock'] }; // Special sort handling for certain columns const SPECIAL_SORT_COLUMNS = { // Percentage columns where we want to sort by the numeric value margin30d: true, markup30d: true, sellThrough30d: true, discountRate30d: true, stockoutRate30d: true, returnRate30d: true, markdownRate30d: true, // Columns where we may want to sort by absolute value profit30d: 'abs', // Velocity columns salesVelocityDaily: true, // Growth rate columns salesGrowth30dVsPrev: 'abs', revenueGrowth30dVsPrev: 'abs', salesGrowthYoy: 'abs', revenueGrowthYoy: 'abs', // Status column needs special ordering status: 'priority' }; // Status priority for sorting (lower number = higher priority) // Values must match what's stored in the DB status column const STATUS_PRIORITY = { 'Critical': 1, 'At Risk': 2, 'Reorder Soon': 3, 'Overstock': 4, 'Healthy': 5, 'New': 6 }; // Get database column name from frontend column name // Returns null for unknown keys so callers can skip them function getDbColumn(frontendColumn) { return COLUMN_MAP[frontendColumn] || null; } // Get column type by searching through the COLUMN_TYPES arrays function getColumnType(frontendColumn) { if (COLUMN_TYPES.numeric.includes(frontendColumn)) return 'numeric'; if (COLUMN_TYPES.date.includes(frontendColumn)) return 'date'; if (COLUMN_TYPES.boolean.includes(frontendColumn)) return 'boolean'; return 'string'; } // --- Route Handlers --- // GET /metrics/summary - Aggregate KPI summary for the current view router.get('/summary', async (req, res) => { const pool = req.app.locals.pool; try { // Build WHERE clause from same filters as main list endpoint const conditions = ['pm.is_visible = true', 'pm.is_replenishable = true']; const params = []; let paramCounter = 1; // Handle showNonReplenishable if (req.query.showNonReplenishable === 'true') { // Remove the is_replenishable condition conditions.pop(); } // Handle showInvisible if (req.query.showInvisible === 'true') { conditions.shift(); // Remove is_visible condition } // Handle stock_status filter if (req.query.stock_status) { conditions.push(`pm.status = $${paramCounter++}`); params.push(req.query.stock_status); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const sql = ` SELECT COUNT(*)::int AS total_products, COALESCE(SUM(pm.current_stock_cost), 0)::numeric(15,2) AS total_stock_value, COALESCE(SUM(pm.current_stock_retail), 0)::numeric(15,2) AS total_stock_retail, COUNT(*) FILTER (WHERE pm.status IN ('Critical', 'Reorder Soon'))::int AS needs_reorder_count, COALESCE(SUM(pm.replenishment_cost) FILTER (WHERE pm.replenishment_units > 0), 0)::numeric(15,2) AS total_replenishment_cost, COALESCE(SUM(pm.replenishment_units) FILTER (WHERE pm.replenishment_units > 0), 0)::int AS total_replenishment_units, COALESCE(SUM(pm.overstocked_cost) FILTER (WHERE pm.overstocked_units > 0), 0)::numeric(15,2) AS total_overstock_value, COALESCE(SUM(pm.overstocked_units) FILTER (WHERE pm.overstocked_units > 0), 0)::int AS total_overstock_units, COALESCE(SUM(pm.on_order_qty), 0)::int AS total_on_order_units, COALESCE(SUM(pm.on_order_cost), 0)::numeric(15,2) AS total_on_order_cost, COALESCE(AVG(pm.stock_cover_in_days) FILTER (WHERE pm.stock_cover_in_days IS NOT NULL AND pm.current_stock > 0), 0)::numeric(10,1) AS avg_stock_cover_days, COUNT(*) FILTER (WHERE pm.current_stock = 0)::int AS out_of_stock_count, COALESCE(SUM(pm.forecast_lost_revenue) FILTER (WHERE pm.forecast_lost_revenue > 0), 0)::numeric(15,2) AS total_lost_revenue, COALESCE(SUM(pm.forecast_lost_sales_units) FILTER (WHERE pm.forecast_lost_sales_units > 0), 0)::int AS total_lost_sales_units, COUNT(*) FILTER (WHERE pm.status = 'Critical')::int AS critical_count, COUNT(*) FILTER (WHERE pm.status = 'Reorder Soon')::int AS reorder_count, COUNT(*) FILTER (WHERE pm.status = 'At Risk')::int AS at_risk_count, COUNT(*) FILTER (WHERE pm.status = 'Overstock')::int AS overstock_count, COUNT(*) FILTER (WHERE pm.status = 'Healthy')::int AS healthy_count, COUNT(*) FILTER (WHERE pm.status = 'New')::int AS new_count FROM public.product_metrics pm ${whereClause} `; const { rows } = await pool.query(sql, params); res.json(rows[0]); } catch (error) { console.error('Error fetching metrics summary:', error); res.status(500).json({ error: 'Failed to fetch metrics summary.' }); } }); // GET /metrics/filter-options - Provide distinct values for filter dropdowns router.get('/filter-options', async (req, res) => { const pool = req.app.locals.pool; try { const [vendorRes, brandRes, abcClassRes] = await Promise.all([ pool.query(`SELECT DISTINCT vendor FROM public.product_metrics WHERE vendor IS NOT NULL AND vendor <> '' ORDER BY vendor`), pool.query(`SELECT DISTINCT COALESCE(brand, 'Unbranded') as brand FROM public.product_metrics WHERE brand IS NOT NULL AND brand <> '' ORDER BY brand`), pool.query(`SELECT DISTINCT abc_class FROM public.product_metrics WHERE abc_class IS NOT NULL ORDER BY abc_class`) // Add queries for other distinct options if needed (e.g., categories if stored on pm) ]); res.json({ vendors: vendorRes.rows.map(r => r.vendor), brands: brandRes.rows.map(r => r.brand), abcClasses: abcClassRes.rows.map(r => r.abc_class), }); } catch (error) { console.error('Error fetching filter options:', error); res.status(500).json({ error: 'Failed to fetch filter options' }); } }); // GET /metrics/ - List all product metrics with filtering, sorting, pagination router.get('/', async (req, res) => { const pool = req.app.locals.pool; try { // --- Pagination --- let page = parseInt(req.query.page, 10); let limit = parseInt(req.query.limit, 10); if (isNaN(page) || page < 1) page = 1; if (isNaN(limit) || limit < 1) limit = DEFAULT_PAGE_LIMIT; limit = Math.min(limit, MAX_PAGE_LIMIT); // Cap the limit const offset = (page - 1) * limit; // --- Sorting --- const sortQueryKey = req.query.sort || 'title'; // Default sort field key const sortDbColumn = getDbColumn(sortQueryKey) || 'pm.title'; const columnType = getColumnType(sortQueryKey); const sortDirection = req.query.order?.toLowerCase() === 'desc' ? 'DESC' : 'ASC'; // Always put nulls last regardless of sort direction or column type const nullsOrder = 'NULLS LAST'; // Build the ORDER BY clause based on column type and special handling let orderByClause; if (SPECIAL_SORT_COLUMNS[sortQueryKey] === 'abs') { // Sort by absolute value for columns where negative values matter orderByClause = `ABS(${sortDbColumn}::numeric) ${sortDirection} ${nullsOrder}`; } else if (columnType === 'numeric' || SPECIAL_SORT_COLUMNS[sortQueryKey] === true) { // For numeric columns, cast to numeric to ensure proper sorting orderByClause = `${sortDbColumn}::numeric ${sortDirection} ${nullsOrder}`; } else if (columnType === 'date') { // For date columns, cast to timestamp to ensure proper sorting orderByClause = `CASE WHEN ${sortDbColumn} IS NULL THEN 1 ELSE 0 END, ${sortDbColumn}::timestamp ${sortDirection}`; } else if (SPECIAL_SORT_COLUMNS[sortQueryKey] === 'priority') { // Special handling for status column, using priority for known statuses orderByClause = ` CASE WHEN ${sortDbColumn} IS NULL THEN 999 WHEN ${sortDbColumn} = 'Critical' THEN 1 WHEN ${sortDbColumn} = 'At Risk' THEN 2 WHEN ${sortDbColumn} = 'Reorder Soon' THEN 3 WHEN ${sortDbColumn} = 'Overstock' THEN 4 WHEN ${sortDbColumn} = 'Healthy' THEN 5 WHEN ${sortDbColumn} = 'New' THEN 6 ELSE 100 END ${sortDirection} ${nullsOrder}, ${sortDbColumn} ${sortDirection}`; } else { // For string and boolean columns, no special casting needed orderByClause = `CASE WHEN ${sortDbColumn} IS NULL THEN 1 ELSE 0 END, ${sortDbColumn} ${sortDirection}`; } // --- Filtering --- const conditions = []; const params = []; let paramCounter = 1; // Add default visibility/replenishable filters unless overridden if (req.query.showInvisible !== 'true') conditions.push(`pm.is_visible = true`); if (req.query.showNonReplenishable !== 'true') conditions.push(`pm.is_replenishable = true`); // Special handling for stock_status if (req.query.stock_status) { const status = req.query.stock_status; // Handle special case for "at-risk" which is stored as "At Risk" in the database if (status.toLowerCase() === 'at-risk') { conditions.push(`pm.status = $${paramCounter++}`); params.push('At Risk'); } else { // Capitalize first letter to match database values conditions.push(`pm.status = $${paramCounter++}`); params.push(status.charAt(0).toUpperCase() + status.slice(1)); } } // Process other filters from query parameters for (const key in req.query) { // Skip control params if (['page', 'limit', 'sort', 'order', 'showInvisible', 'showNonReplenishable', 'stock_status'].includes(key)) continue; let filterKey = key; let operator = '='; // Default operator let value = req.query[key]; // Check for operator suffixes (e.g., sales30d_gt, title_ilike, isVisible_is_true) const operatorMatch = key.match(/^(.*)_(eq|ne|gt|gte|lt|lte|like|ilike|starts_with|ends_with|not_contains|between|in|is_empty|is_not_empty|is_true|is_false)$/); if (operatorMatch) { filterKey = operatorMatch[1]; // e.g., "sales30d" operator = operatorMatch[2]; // e.g., "gt" } // Get the database column for this filter key const filterDbColumn = getDbColumn(filterKey); const valueType = getColumnType(filterKey); if (!filterDbColumn) { console.warn(`Invalid filter key ignored: ${key}`); continue; // Skip if the key doesn't map to a known column } // --- Build WHERE clause fragment --- let needsParam = true; // Declared outside try so catch can access it try { let conditionFragment = ''; 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 = 'ILIKE'; value = `%${value}%`; break; case 'ilike': operator = 'ILIKE'; value = `%${value}%`; break; case 'starts_with': operator = 'ILIKE'; value = `${value}%`; break; case 'ends_with': operator = 'ILIKE'; value = `%${value}`; break; case 'not_contains': operator = 'NOT ILIKE'; value = `%${value}%`; break; case 'is_empty': conditionFragment = `(${filterDbColumn} IS NULL OR ${filterDbColumn}::text = '')`; needsParam = false; break; case 'is_not_empty': conditionFragment = `(${filterDbColumn} IS NOT NULL AND ${filterDbColumn}::text <> '')`; needsParam = false; break; case 'is_true': conditionFragment = `${filterDbColumn} = true`; needsParam = false; break; case 'is_false': conditionFragment = `${filterDbColumn} = false`; needsParam = false; break; case 'between': const [val1, val2] = String(value).split(','); if (val1 !== undefined && val2 !== undefined) { conditionFragment = `${filterDbColumn} BETWEEN $${paramCounter++} AND $${paramCounter++}`; params.push(parseValue(val1, valueType), parseValue(val2, valueType)); needsParam = false; } else { console.warn(`Invalid 'between' value for ${key}: ${value}`); continue; } break; case 'in': const inValues = String(value).split(','); if (inValues.length > 0) { const placeholders = inValues.map(() => `$${paramCounter++}`).join(', '); conditionFragment = `${filterDbColumn} IN (${placeholders})`; params.push(...inValues.map(v => parseValue(v, valueType))); needsParam = false; } else { console.warn(`Invalid 'in' value for ${key}: ${value}`); continue; } break; case '=': default: operator = '='; break; } if (needsParam) { conditionFragment = `${filterDbColumn} ${operator} $${paramCounter++}`; params.push(parseValue(value, valueType)); } if (conditionFragment) { conditions.push(`(${conditionFragment})`); } } catch (parseError) { console.warn(`Skipping filter for key "${key}" due to parsing error: ${parseError.message}`); if (needsParam) paramCounter--; } } // --- Construct and Execute Queries --- const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; // Count Query const countSql = `SELECT COUNT(*) AS total FROM public.product_metrics pm ${whereClause}`; const countPromise = pool.query(countSql, params); // Data Query (Select all columns from metrics table for now) const dataSql = ` SELECT pm.* FROM public.product_metrics pm ${whereClause} ORDER BY ${orderByClause} LIMIT $${paramCounter} OFFSET $${paramCounter + 1} `; const dataParams = [...params, limit, offset]; const dataPromise = pool.query(dataSql, dataParams); // Execute queries in parallel const [countResult, dataResult] = await Promise.all([countPromise, dataPromise]); const total = parseInt(countResult.rows[0].total, 10); const metrics = dataResult.rows; // --- Respond --- res.json({ metrics, pagination: { total, pages: Math.ceil(total / limit), currentPage: page, limit, }, // Optionally include applied filters/sort for frontend confirmation appliedQuery: { filters: req.query, // Send back raw query filters sort: sortQueryKey, order: sortDirection.toLowerCase() } }); } catch (error) { console.error('Error fetching metrics list:', error); res.status(500).json({ error: 'Failed to fetch product metrics list.' }); } }); // GET /metrics/:pid - Get metrics for a single product router.get('/:pid', async (req, res) => { const pool = req.app.locals.pool; const pid = parseInt(req.params.pid, 10); if (isNaN(pid)) { return res.status(400).json({ error: 'Invalid Product ID.' }); } try { const { rows } = await pool.query( `SELECT * FROM public.product_metrics WHERE pid = $1`, [pid] ); if (rows.length === 0) { return res.status(404).json({ error: 'Metrics not found for this product.' }); } // Data is pre-calculated, return the first (only) row res.json(rows[0]); } catch (error) { console.error(`Error fetching metrics for PID ${pid}:`, error); res.status(500).json({ error: 'Failed to fetch product metrics.' }); } }); /** * Parses a value based on its expected type. * Throws error for invalid formats. */ function parseValue(value, type) { if (value === null || value === undefined || value === '') return null; // Allow empty strings? Or handle differently? switch (type) { case 'numeric': const num = parseFloat(value); if (isNaN(num)) throw new Error(`Invalid number format: "${value}"`); return num; 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 validation, rely on DB to handle actual date conversion if (!String(value).match(/^\d{4}-\d{2}-\d{2}$/)) { // Allow full timestamps too? Adjust regex if needed // console.warn(`Potentially invalid date format: "${value}"`); // Warn instead of throwing? } return String(value); // Send as string, let DB handle it case 'string': default: return String(value); } } module.exports = router;