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',
|
'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
|
||||||
|
|||||||
Reference in New Issue
Block a user