fix(analytics): Fix market-summary store count and add search indexes
- market-summary now counts from store_products table (not product_variants) - Added trigram indexes for fast ILIKE product searches 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
20
backend/migrations/096_product_search_indexes.sql
Normal file
20
backend/migrations/096_product_search_indexes.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Migration: Add trigram indexes for fast ILIKE product searches
|
||||||
|
-- Enables fast searches on name_raw, brand_name_raw, and description
|
||||||
|
|
||||||
|
-- Enable pg_trgm extension if not already enabled
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
-- Create GIN trigram indexes for fast ILIKE searches
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_store_products_name_trgm
|
||||||
|
ON store_products USING gin (name_raw gin_trgm_ops);
|
||||||
|
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_store_products_brand_name_trgm
|
||||||
|
ON store_products USING gin (brand_name_raw gin_trgm_ops);
|
||||||
|
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_store_products_description_trgm
|
||||||
|
ON store_products USING gin (description gin_trgm_ops);
|
||||||
|
|
||||||
|
-- Add comment
|
||||||
|
COMMENT ON INDEX idx_store_products_name_trgm IS 'Trigram index for fast ILIKE searches on product name';
|
||||||
|
COMMENT ON INDEX idx_store_products_brand_name_trgm IS 'Trigram index for fast ILIKE searches on brand name';
|
||||||
|
COMMENT ON INDEX idx_store_products_description_trgm IS 'Trigram index for fast ILIKE searches on description';
|
||||||
@@ -397,69 +397,80 @@ router.get('/compare', async (req: Request, res: Response) => {
|
|||||||
/**
|
/**
|
||||||
* GET /api/price-analytics/market-summary
|
* GET /api/price-analytics/market-summary
|
||||||
* Get overall market analytics summary
|
* Get overall market analytics summary
|
||||||
|
* Uses store_products for product/store counts, product_variants for variant-specific stats
|
||||||
*/
|
*/
|
||||||
router.get('/market-summary', async (req: Request, res: Response) => {
|
router.get('/market-summary', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { state } = req.query;
|
const { state } = req.query;
|
||||||
|
|
||||||
let stateFilter = '';
|
let stateFilter = '';
|
||||||
|
let stateFilterSp = '';
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
if (state) {
|
if (state) {
|
||||||
stateFilter = 'WHERE d.state = $1';
|
stateFilter = 'WHERE d.state = $1';
|
||||||
|
stateFilterSp = 'WHERE d.state = $1';
|
||||||
params.push(state);
|
params.push(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get variant counts
|
// Get product/store counts from store_products (the authoritative source)
|
||||||
|
const productStats = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT sp.id) as total_products,
|
||||||
|
COUNT(DISTINCT sp.dispensary_id) as total_stores,
|
||||||
|
COUNT(DISTINCT sp.id) FILTER (WHERE sp.in_stock = true) as in_stock,
|
||||||
|
COUNT(DISTINCT sp.id) FILTER (WHERE sp.is_on_special = true) as on_special
|
||||||
|
FROM store_products sp
|
||||||
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
${stateFilterSp}
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
// Get variant counts from product_variants (if populated)
|
||||||
const variantStats = await pool.query(`
|
const variantStats = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT pv.id) as total_variants,
|
COUNT(DISTINCT pv.id) as total_variants
|
||||||
COUNT(DISTINCT pv.id) FILTER (WHERE pv.is_on_special) as on_special,
|
|
||||||
COUNT(DISTINCT pv.id) FILTER (WHERE pv.in_stock) as in_stock,
|
|
||||||
COUNT(DISTINCT pv.store_product_id) as total_products,
|
|
||||||
COUNT(DISTINCT pv.dispensary_id) as total_stores
|
|
||||||
FROM product_variants pv
|
FROM product_variants pv
|
||||||
JOIN dispensaries d ON d.id = pv.dispensary_id
|
JOIN dispensaries d ON d.id = pv.dispensary_id
|
||||||
${stateFilter}
|
${stateFilter}
|
||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
// Get category breakdown
|
// Get category breakdown from store_products
|
||||||
const categoryStats = await pool.query(`
|
const categoryStats = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.category_raw as category,
|
sp.category_raw as category,
|
||||||
COUNT(DISTINCT pv.id) as variant_count,
|
COUNT(DISTINCT sp.id) as product_count,
|
||||||
AVG(COALESCE(pv.price_rec_special, pv.price_rec)) as avg_price,
|
COUNT(DISTINCT sp.id) FILTER (WHERE sp.is_on_special = true) as on_special_count
|
||||||
COUNT(DISTINCT pv.id) FILTER (WHERE pv.is_on_special) as on_special_count
|
FROM store_products sp
|
||||||
FROM product_variants pv
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
JOIN store_products sp ON sp.id = pv.store_product_id
|
${stateFilterSp}
|
||||||
JOIN dispensaries d ON d.id = pv.dispensary_id
|
|
||||||
${stateFilter}
|
|
||||||
GROUP BY sp.category_raw
|
GROUP BY sp.category_raw
|
||||||
ORDER BY variant_count DESC
|
ORDER BY product_count DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
// Get recent price changes (last 24h)
|
// Get recent price changes (last 24h) from store_product_snapshots
|
||||||
const recentChanges = await pool.query(`
|
const recentChanges = await pool.query(`
|
||||||
SELECT COUNT(*) as price_changes_24h
|
SELECT COUNT(*) as price_changes_24h
|
||||||
FROM product_variants pv
|
FROM store_product_snapshots sps
|
||||||
JOIN dispensaries d ON d.id = pv.dispensary_id
|
JOIN store_products sp ON sp.id = sps.store_product_id
|
||||||
${stateFilter ? stateFilter + ' AND' : 'WHERE'}
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
pv.last_price_change_at >= NOW() - INTERVAL '24 hours'
|
${stateFilterSp ? stateFilterSp + ' AND' : 'WHERE'}
|
||||||
|
sps.captured_at >= NOW() - INTERVAL '24 hours'
|
||||||
|
AND sps.price_changed = true
|
||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
summary: {
|
summary: {
|
||||||
total_variants: parseInt(variantStats.rows[0]?.total_variants || '0'),
|
total_variants: parseInt(variantStats.rows[0]?.total_variants || '0'),
|
||||||
on_special: parseInt(variantStats.rows[0]?.on_special || '0'),
|
on_special: parseInt(productStats.rows[0]?.on_special || '0'),
|
||||||
in_stock: parseInt(variantStats.rows[0]?.in_stock || '0'),
|
in_stock: parseInt(productStats.rows[0]?.in_stock || '0'),
|
||||||
total_products: parseInt(variantStats.rows[0]?.total_products || '0'),
|
total_products: parseInt(productStats.rows[0]?.total_products || '0'),
|
||||||
total_stores: parseInt(variantStats.rows[0]?.total_stores || '0'),
|
total_stores: parseInt(productStats.rows[0]?.total_stores || '0'),
|
||||||
price_changes_24h: parseInt(recentChanges.rows[0]?.price_changes_24h || '0'),
|
price_changes_24h: parseInt(recentChanges.rows[0]?.price_changes_24h || '0'),
|
||||||
},
|
},
|
||||||
categories: categoryStats.rows.map((c: any) => ({
|
categories: categoryStats.rows.map((c: any) => ({
|
||||||
category: c.category || 'Unknown',
|
category: c.category || 'Unknown',
|
||||||
variant_count: parseInt(c.variant_count),
|
variant_count: parseInt(c.product_count),
|
||||||
avg_price: c.avg_price ? parseFloat(c.avg_price).toFixed(2) : null,
|
avg_price: null, // Would need price data from snapshots
|
||||||
on_special_count: parseInt(c.on_special_count),
|
on_special_count: parseInt(c.on_special_count),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user