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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user