From 1d6e67d837c4d4b4fc5983f17572a973a13f8898 Mon Sep 17 00:00:00 2001 From: Kelly Date: Tue, 9 Dec 2025 12:13:33 -0700 Subject: [PATCH] feat(api): Add store metrics endpoints with localhost bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New public API v1 endpoints for third-party integrations: - GET /api/v1/stores/:id/metrics - Store performance metrics - GET /api/v1/stores/:id/product-metrics - Product-level price changes - GET /api/v1/stores/:id/competitor-snapshot - Competitive intelligence Also adds localhost IP bypass for local development testing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/src/routes/public-api.ts | 486 +++++++++++++++++++++++++++++++ 1 file changed, 486 insertions(+) diff --git a/backend/src/routes/public-api.ts b/backend/src/routes/public-api.ts index 9d524342..f59392a9 100644 --- a/backend/src/routes/public-api.ts +++ b/backend/src/routes/public-api.ts @@ -130,10 +130,25 @@ const CONSUMER_TRUSTED_ORIGINS = [ 'http://localhost:3002', ]; +// Trusted IPs for local development (bypass API key auth) +const TRUSTED_IPS = ['127.0.0.1', '::1', '::ffff:127.0.0.1']; + +/** + * Check if request is from localhost + */ +function isLocalhost(req: Request): boolean { + const clientIp = req.ip || req.socket.remoteAddress || ''; + return TRUSTED_IPS.includes(clientIp); +} + /** * Check if request is from a trusted consumer origin */ function isConsumerTrustedRequest(req: Request): boolean { + // Localhost always bypasses + if (isLocalhost(req)) { + return true; + } const origin = req.headers.origin; if (origin && CONSUMER_TRUSTED_ORIGINS.includes(origin)) { return true; @@ -1349,6 +1364,477 @@ router.get('/search', async (req: PublicApiRequest, res: Response) => { } }); +// ============================================================ +// STORE METRICS & INTELLIGENCE ENDPOINTS +// ============================================================ + +/** + * GET /api/v1/stores/:id/metrics + * Get performance metrics for a specific store + * + * Returns: + * - Product counts (total, in-stock, out-of-stock) + * - Brand counts + * - Category breakdown + * - Price statistics (avg, min, max) + * - Stock health metrics + * - Crawl status + */ +router.get('/stores/:id/metrics', async (req: PublicApiRequest, res: Response) => { + try { + const scope = req.scope!; + const storeId = parseInt(req.params.id, 10); + + if (isNaN(storeId)) { + return res.status(400).json({ error: 'Invalid store ID' }); + } + + // Validate access + if (scope.type === 'wordpress' && scope.dispensaryIds !== 'ALL') { + if (!scope.dispensaryIds.includes(storeId)) { + return res.status(403).json({ error: 'Access denied to this store' }); + } + } + + // Get store info + const { rows: storeRows } = await pool.query(` + SELECT id, name, city, state, last_crawl_at, product_count, crawl_enabled + FROM dispensaries + WHERE id = $1 + `, [storeId]); + + if (storeRows.length === 0) { + return res.status(404).json({ error: 'Store not found' }); + } + + const store = storeRows[0]; + + // Get product metrics + const { rows: productMetrics } = await pool.query(` + SELECT + COUNT(*) as total_products, + COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock, + COUNT(*) FILTER (WHERE stock_status = 'out_of_stock') as out_of_stock, + COUNT(DISTINCT brand_name) FILTER (WHERE brand_name IS NOT NULL) as unique_brands, + COUNT(DISTINCT category) FILTER (WHERE category IS NOT NULL) as unique_categories + FROM store_products + WHERE dispensary_id = $1 + `, [storeId]); + + // Get price statistics from latest snapshots + const { rows: priceStats } = await pool.query(` + SELECT + ROUND(AVG(price_rec)::numeric, 2) as avg_price, + MIN(price_rec) as min_price, + MAX(price_rec) as max_price, + COUNT(*) FILTER (WHERE is_on_special = true) as on_special_count + FROM store_product_snapshots sps + INNER JOIN ( + SELECT store_product_id, MAX(captured_at) as latest + FROM store_product_snapshots + WHERE dispensary_id = $1 + GROUP BY store_product_id + ) latest ON sps.store_product_id = latest.store_product_id AND sps.captured_at = latest.latest + WHERE sps.dispensary_id = $1 AND sps.price_rec > 0 + `, [storeId]); + + // Get category breakdown + const { rows: categoryBreakdown } = await pool.query(` + SELECT + COALESCE(category, 'Uncategorized') as category, + COUNT(*) as count, + COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock + FROM store_products + WHERE dispensary_id = $1 + GROUP BY category + ORDER BY count DESC + LIMIT 10 + `, [storeId]); + + // Calculate stock health + const metrics = productMetrics[0] || {}; + const totalProducts = parseInt(metrics.total_products || '0', 10); + const inStock = parseInt(metrics.in_stock || '0', 10); + const stockHealthPercent = totalProducts > 0 ? Math.round((inStock / totalProducts) * 100) : 0; + + const prices = priceStats[0] || {}; + + res.json({ + success: true, + store_id: storeId, + store_name: store.name, + location: { + city: store.city, + state: store.state + }, + metrics: { + products: { + total: totalProducts, + in_stock: inStock, + out_of_stock: parseInt(metrics.out_of_stock || '0', 10), + stock_health_percent: stockHealthPercent + }, + brands: { + unique_count: parseInt(metrics.unique_brands || '0', 10) + }, + categories: { + unique_count: parseInt(metrics.unique_categories || '0', 10), + breakdown: categoryBreakdown.map(c => ({ + name: c.category, + total: parseInt(c.count, 10), + in_stock: parseInt(c.in_stock, 10) + })) + }, + pricing: { + average: prices.avg_price ? parseFloat(prices.avg_price) : null, + min: prices.min_price ? parseFloat(prices.min_price) : null, + max: prices.max_price ? parseFloat(prices.max_price) : null, + on_special_count: parseInt(prices.on_special_count || '0', 10) + }, + crawl: { + enabled: store.crawl_enabled, + last_crawl_at: store.last_crawl_at, + product_count_from_crawl: store.product_count + } + }, + generated_at: new Date().toISOString() + }); + } catch (error: any) { + console.error('Store metrics error:', error); + res.status(500).json({ error: 'Failed to fetch store metrics', message: error.message }); + } +}); + +/** + * GET /api/v1/stores/:id/product-metrics + * Get detailed product-level metrics for a store + * + * Query params: + * - category: Filter by category + * - brand: Filter by brand + * - sort_by: price_change, stock_status, price (default: price_change) + * - limit: Max results (default: 50, max: 200) + * + * Returns per-product: + * - Current price and stock + * - Price change from last crawl + * - Days in stock / out of stock + * - Special/discount status + */ +router.get('/stores/:id/product-metrics', async (req: PublicApiRequest, res: Response) => { + try { + const scope = req.scope!; + const storeId = parseInt(req.params.id, 10); + + if (isNaN(storeId)) { + return res.status(400).json({ error: 'Invalid store ID' }); + } + + // Validate access + if (scope.type === 'wordpress' && scope.dispensaryIds !== 'ALL') { + if (!scope.dispensaryIds.includes(storeId)) { + return res.status(403).json({ error: 'Access denied to this store' }); + } + } + + const { category, brand, sort_by = 'price_change', limit = '50' } = req.query; + const limitNum = Math.min(parseInt(limit as string, 10) || 50, 200); + + let whereClause = 'WHERE sp.dispensary_id = $1'; + const params: any[] = [storeId]; + let paramIndex = 2; + + if (category) { + whereClause += ` AND LOWER(sp.category) = LOWER($${paramIndex})`; + params.push(category); + paramIndex++; + } + + if (brand) { + whereClause += ` AND LOWER(sp.brand_name) LIKE LOWER($${paramIndex})`; + params.push(`%${brand}%`); + paramIndex++; + } + + params.push(limitNum); + + // Get products with their latest and previous snapshots for price comparison + const { rows: products } = await pool.query(` + WITH latest_snapshots AS ( + SELECT DISTINCT ON (store_product_id) + store_product_id, + price_rec as current_price, + price_rec_special as current_special_price, + is_on_special, + stock_quantity, + captured_at as last_seen + FROM store_product_snapshots + WHERE dispensary_id = $1 + ORDER BY store_product_id, captured_at DESC + ), + previous_snapshots AS ( + SELECT DISTINCT ON (store_product_id) + store_product_id, + price_rec as previous_price, + captured_at as previous_seen + FROM store_product_snapshots sps + WHERE dispensary_id = $1 + AND captured_at < (SELECT MIN(last_seen) FROM latest_snapshots ls WHERE ls.store_product_id = sps.store_product_id) + ORDER BY store_product_id, captured_at DESC + ) + SELECT + sp.id, + sp.name, + sp.brand_name, + sp.category, + sp.stock_status, + ls.current_price, + ls.current_special_price, + ls.is_on_special, + ls.stock_quantity, + ls.last_seen, + ps.previous_price, + ps.previous_seen, + CASE + WHEN ls.current_price IS NOT NULL AND ps.previous_price IS NOT NULL + THEN ROUND(((ls.current_price - ps.previous_price) / ps.previous_price * 100)::numeric, 2) + ELSE NULL + END as price_change_percent + FROM store_products sp + LEFT JOIN latest_snapshots ls ON sp.id = ls.store_product_id + LEFT JOIN previous_snapshots ps ON sp.id = ps.store_product_id + ${whereClause} + ORDER BY + ${sort_by === 'price' ? 'ls.current_price DESC NULLS LAST' : + sort_by === 'stock_status' ? "CASE sp.stock_status WHEN 'out_of_stock' THEN 0 ELSE 1 END, sp.name" : + 'ABS(COALESCE(price_change_percent, 0)) DESC'} + LIMIT $${paramIndex} + `, params); + + res.json({ + success: true, + store_id: storeId, + products: products.map(p => ({ + id: p.id, + name: p.name, + brand: p.brand_name, + category: p.category, + stock_status: p.stock_status, + pricing: { + current: p.current_price ? parseFloat(p.current_price) : null, + special: p.current_special_price ? parseFloat(p.current_special_price) : null, + previous: p.previous_price ? parseFloat(p.previous_price) : null, + change_percent: p.price_change_percent ? parseFloat(p.price_change_percent) : null, + is_on_special: p.is_on_special || false + }, + inventory: { + quantity: p.stock_quantity || 0, + last_seen: p.last_seen + } + })), + filters: { + category: category || null, + brand: brand || null, + sort_by + }, + count: products.length, + generated_at: new Date().toISOString() + }); + } catch (error: any) { + console.error('Product metrics error:', error); + res.status(500).json({ error: 'Failed to fetch product metrics', message: error.message }); + } +}); + +/** + * GET /api/v1/stores/:id/competitor-snapshot + * Get competitive intelligence for a store + * + * Returns: + * - Nearby competitor stores (same city/state) + * - Price comparisons by category + * - Brand overlap analysis + * - Market position indicators + */ +router.get('/stores/:id/competitor-snapshot', async (req: PublicApiRequest, res: Response) => { + try { + const scope = req.scope!; + const storeId = parseInt(req.params.id, 10); + + if (isNaN(storeId)) { + return res.status(400).json({ error: 'Invalid store ID' }); + } + + // Validate access + if (scope.type === 'wordpress' && scope.dispensaryIds !== 'ALL') { + if (!scope.dispensaryIds.includes(storeId)) { + return res.status(403).json({ error: 'Access denied to this store' }); + } + } + + // Get store info + const { rows: storeRows } = await pool.query(` + SELECT id, name, city, state, latitude, longitude + FROM dispensaries + WHERE id = $1 + `, [storeId]); + + if (storeRows.length === 0) { + return res.status(404).json({ error: 'Store not found' }); + } + + const store = storeRows[0]; + + // Get competitor stores in same city (or nearby if coordinates available) + const { rows: competitors } = await pool.query(` + SELECT + d.id, + d.name, + d.city, + d.state, + d.product_count, + d.last_crawl_at, + CASE + WHEN d.latitude IS NOT NULL AND d.longitude IS NOT NULL + AND $2::numeric IS NOT NULL AND $3::numeric IS NOT NULL + THEN ROUND(( + 6371 * acos( + cos(radians($2::numeric)) * cos(radians(d.latitude::numeric)) + * cos(radians(d.longitude::numeric) - radians($3::numeric)) + + sin(radians($2::numeric)) * sin(radians(d.latitude::numeric)) + ) + )::numeric, 2) + ELSE NULL + END as distance_km + FROM dispensaries d + WHERE d.id != $1 + AND d.state = $4 + AND d.crawl_enabled = true + AND d.product_count > 0 + AND (d.city = $5 OR d.latitude IS NOT NULL) + ORDER BY distance_km NULLS LAST, d.name + LIMIT 10 + `, [storeId, store.latitude, store.longitude, store.state, store.city]); + + // Get this store's average prices by category + const { rows: storePrices } = await pool.query(` + SELECT + sp.category, + ROUND(AVG(sps.price_rec)::numeric, 2) as avg_price, + COUNT(*) as product_count + FROM store_products sp + INNER JOIN ( + SELECT DISTINCT ON (store_product_id) store_product_id, price_rec + FROM store_product_snapshots + WHERE dispensary_id = $1 + ORDER BY store_product_id, captured_at DESC + ) sps ON sp.id = sps.store_product_id + WHERE sp.dispensary_id = $1 AND sp.category IS NOT NULL AND sps.price_rec > 0 + GROUP BY sp.category + `, [storeId]); + + // Get market average prices by category (all competitors) + const competitorIds = competitors.map(c => c.id); + let marketPrices: any[] = []; + + if (competitorIds.length > 0) { + const { rows } = await pool.query(` + SELECT + sp.category, + ROUND(AVG(sps.price_rec)::numeric, 2) as market_avg_price, + COUNT(DISTINCT sp.dispensary_id) as store_count + FROM store_products sp + INNER JOIN ( + SELECT DISTINCT ON (store_product_id) store_product_id, price_rec + FROM store_product_snapshots + WHERE dispensary_id = ANY($1) + ORDER BY store_product_id, captured_at DESC + ) sps ON sp.id = sps.store_product_id + WHERE sp.dispensary_id = ANY($1) AND sp.category IS NOT NULL AND sps.price_rec > 0 + GROUP BY sp.category + `, [competitorIds]); + marketPrices = rows; + } + + // Get this store's brands + const { rows: storeBrands } = await pool.query(` + SELECT DISTINCT brand_name + FROM store_products + WHERE dispensary_id = $1 AND brand_name IS NOT NULL + `, [storeId]); + + const storeBrandSet = new Set(storeBrands.map(b => b.brand_name.toLowerCase())); + + // Get brand overlap with competitors + let brandOverlap: any[] = []; + if (competitorIds.length > 0) { + const { rows } = await pool.query(` + SELECT + d.id as competitor_id, + d.name as competitor_name, + COUNT(DISTINCT sp.brand_name) as total_brands, + COUNT(DISTINCT sp.brand_name) FILTER ( + WHERE LOWER(sp.brand_name) = ANY($2) + ) as shared_brands + FROM dispensaries d + INNER JOIN store_products sp ON sp.dispensary_id = d.id + WHERE d.id = ANY($1) AND sp.brand_name IS NOT NULL + GROUP BY d.id, d.name + `, [competitorIds, Array.from(storeBrandSet)]); + brandOverlap = rows; + } + + // Build price comparison + const priceComparison = storePrices.map(sp => { + const marketPrice = marketPrices.find(mp => mp.category === sp.category); + const diff = marketPrice + ? parseFloat(((parseFloat(sp.avg_price) - parseFloat(marketPrice.market_avg_price)) / parseFloat(marketPrice.market_avg_price) * 100).toFixed(2)) + : null; + + return { + category: sp.category, + your_avg_price: parseFloat(sp.avg_price), + market_avg_price: marketPrice ? parseFloat(marketPrice.market_avg_price) : null, + diff_percent: diff, + position: diff === null ? 'unknown' : diff < -5 ? 'below_market' : diff > 5 ? 'above_market' : 'at_market' + }; + }); + + res.json({ + success: true, + store: { + id: storeId, + name: store.name, + city: store.city, + state: store.state + }, + competitors: competitors.map(c => ({ + id: c.id, + name: c.name, + city: c.city, + distance_km: c.distance_km ? parseFloat(c.distance_km) : null, + product_count: c.product_count, + last_crawl: c.last_crawl_at + })), + price_comparison: priceComparison, + brand_analysis: { + your_brand_count: storeBrandSet.size, + overlap_with_competitors: brandOverlap.map(bo => ({ + competitor_id: bo.competitor_id, + competitor_name: bo.competitor_name, + shared_brands: parseInt(bo.shared_brands, 10), + their_total_brands: parseInt(bo.total_brands, 10), + overlap_percent: Math.round((parseInt(bo.shared_brands, 10) / storeBrandSet.size) * 100) + })) + }, + generated_at: new Date().toISOString() + }); + } catch (error: any) { + console.error('Competitor snapshot error:', error); + res.status(500).json({ error: 'Failed to fetch competitor snapshot', message: error.message }); + } +}); + /** * GET /api/v1/menu * Get complete menu summary for the authenticated dispensary