feat(api): Add store metrics endpoints with localhost bypass

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 <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-09 12:13:33 -07:00
parent cfb4b6e4ce
commit 1d6e67d837

View File

@@ -130,10 +130,25 @@ const CONSUMER_TRUSTED_ORIGINS = [
'http://localhost:3002', '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 * Check if request is from a trusted consumer origin
*/ */
function isConsumerTrustedRequest(req: Request): boolean { function isConsumerTrustedRequest(req: Request): boolean {
// Localhost always bypasses
if (isLocalhost(req)) {
return true;
}
const origin = req.headers.origin; const origin = req.headers.origin;
if (origin && CONSUMER_TRUSTED_ORIGINS.includes(origin)) { if (origin && CONSUMER_TRUSTED_ORIGINS.includes(origin)) {
return true; 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 /api/v1/menu
* Get complete menu summary for the authenticated dispensary * Get complete menu summary for the authenticated dispensary