- Add state filter to Intelligence Brands API and frontend - Add state filter to Intelligence Pricing API and frontend - Add state filter to Intelligence Stores API and frontend - Fix null safety issues with toLocaleString() calls - Update backend /stores endpoint to return skuCount, snapshotCount, chainName - Add overall stats to pricing endpoint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
/**
|
|
* Intelligence API Routes
|
|
*
|
|
* Brand and pricing intelligence endpoints for the CannaiQ admin dashboard.
|
|
* Uses canonical store_products table for aggregated analytics.
|
|
*/
|
|
import { Router, Request, Response } from 'express';
|
|
import { authMiddleware } from '../auth/middleware';
|
|
import { pool } from '../db/pool';
|
|
|
|
const router = Router();
|
|
router.use(authMiddleware);
|
|
|
|
/**
|
|
* GET /api/admin/intelligence/brands
|
|
* List all brands with state presence, store counts, and pricing
|
|
* Query params:
|
|
* - state: Filter by state (e.g., "AZ")
|
|
* - limit: Max results (default 500)
|
|
* - offset: Pagination offset
|
|
*/
|
|
router.get('/brands', async (req: Request, res: Response) => {
|
|
try {
|
|
const { limit = '500', offset = '0', state } = req.query;
|
|
const limitNum = Math.min(parseInt(limit as string, 10), 1000);
|
|
const offsetNum = parseInt(offset as string, 10);
|
|
|
|
// Build WHERE clause based on state filter
|
|
let stateFilter = '';
|
|
const params: any[] = [limitNum, offsetNum];
|
|
if (state && state !== 'all') {
|
|
stateFilter = 'AND d.state = $3';
|
|
params.push(state);
|
|
}
|
|
|
|
const { rows } = await pool.query(`
|
|
SELECT
|
|
sp.brand_name_raw as brand_name,
|
|
array_agg(DISTINCT d.state) FILTER (WHERE d.state IS NOT NULL) as states,
|
|
COUNT(DISTINCT d.id) as store_count,
|
|
COUNT(DISTINCT sp.id) as sku_count,
|
|
ROUND(AVG(sp.price_rec) FILTER (WHERE sp.price_rec > 0)::numeric, 2) as avg_price_rec,
|
|
ROUND(AVG(sp.price_med) FILTER (WHERE sp.price_med > 0)::numeric, 2) as avg_price_med
|
|
FROM store_products sp
|
|
JOIN dispensaries d ON sp.dispensary_id = d.id
|
|
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
|
|
${stateFilter}
|
|
GROUP BY sp.brand_name_raw
|
|
ORDER BY store_count DESC, sku_count DESC
|
|
LIMIT $1 OFFSET $2
|
|
`, params);
|
|
|
|
// Get total count with same state filter
|
|
const countParams: any[] = [];
|
|
let countStateFilter = '';
|
|
if (state && state !== 'all') {
|
|
countStateFilter = 'AND d.state = $1';
|
|
countParams.push(state);
|
|
}
|
|
const { rows: countRows } = await pool.query(`
|
|
SELECT COUNT(DISTINCT sp.brand_name_raw) as total
|
|
FROM store_products sp
|
|
JOIN dispensaries d ON sp.dispensary_id = d.id
|
|
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
|
|
${countStateFilter}
|
|
`, countParams);
|
|
|
|
res.json({
|
|
brands: rows.map((r: any) => ({
|
|
brandName: r.brand_name,
|
|
states: r.states || [],
|
|
storeCount: parseInt(r.store_count, 10),
|
|
skuCount: parseInt(r.sku_count, 10),
|
|
avgPriceRec: r.avg_price_rec ? parseFloat(r.avg_price_rec) : null,
|
|
avgPriceMed: r.avg_price_med ? parseFloat(r.avg_price_med) : null,
|
|
})),
|
|
total: parseInt(countRows[0]?.total || '0', 10),
|
|
limit: limitNum,
|
|
offset: offsetNum,
|
|
});
|
|
} catch (error: any) {
|
|
console.error('[Intelligence] Error fetching brands:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/admin/intelligence/brands/:brandName/penetration
|
|
* Get state-by-state penetration for a specific brand
|
|
*/
|
|
router.get('/brands/:brandName/penetration', async (req: Request, res: Response) => {
|
|
try {
|
|
const { brandName } = req.params;
|
|
|
|
const { rows } = await pool.query(`
|
|
WITH state_totals AS (
|
|
SELECT
|
|
d.state,
|
|
s.name AS state_name,
|
|
COUNT(DISTINCT d.id) AS total_stores
|
|
FROM dispensaries d
|
|
JOIN states s ON d.state = s.code
|
|
WHERE d.state IS NOT NULL
|
|
GROUP BY d.state, s.name
|
|
),
|
|
brand_presence AS (
|
|
SELECT
|
|
d.state,
|
|
COUNT(DISTINCT d.id) AS stores_with_brand,
|
|
COUNT(DISTINCT sp.id) AS product_count,
|
|
ROUND(AVG(sp.price_rec)::numeric, 2) AS avg_price
|
|
FROM store_products sp
|
|
JOIN dispensaries d ON sp.dispensary_id = d.id
|
|
WHERE sp.brand_name_raw ILIKE $1
|
|
AND d.state IS NOT NULL
|
|
GROUP BY d.state
|
|
)
|
|
SELECT
|
|
st.state,
|
|
st.state_name AS "stateName",
|
|
st.total_stores AS "totalStores",
|
|
COALESCE(bp.stores_with_brand, 0) AS "storesWithBrand",
|
|
CASE
|
|
WHEN st.total_stores > 0
|
|
THEN ROUND((COALESCE(bp.stores_with_brand, 0)::numeric / st.total_stores) * 100, 2)
|
|
ELSE 0
|
|
END AS "penetrationPct",
|
|
COALESCE(bp.product_count, 0) AS "productCount",
|
|
bp.avg_price AS "avgPrice"
|
|
FROM state_totals st
|
|
LEFT JOIN brand_presence bp ON st.state = bp.state
|
|
WHERE COALESCE(bp.stores_with_brand, 0) > 0
|
|
ORDER BY COALESCE(bp.stores_with_brand, 0) DESC
|
|
`, [brandName]);
|
|
|
|
// Calculate national metrics
|
|
const { rows: nationalRows } = await pool.query(`
|
|
SELECT
|
|
COUNT(DISTINCT d.id) AS total_stores,
|
|
COUNT(DISTINCT CASE WHEN sp.brand_name_raw ILIKE $1 THEN d.id END) AS stores_with_brand,
|
|
AVG(sp.price_rec) FILTER (WHERE sp.brand_name_raw ILIKE $1) AS avg_price
|
|
FROM dispensaries d
|
|
LEFT JOIN store_products sp ON d.id = sp.dispensary_id
|
|
WHERE d.state IS NOT NULL
|
|
`, [brandName]);
|
|
|
|
const national = nationalRows[0];
|
|
const nationalPenetration = national.total_stores > 0
|
|
? (national.stores_with_brand / national.total_stores) * 100
|
|
: 0;
|
|
|
|
res.json({
|
|
brandName,
|
|
states: rows,
|
|
nationalPenetration: Math.round(nationalPenetration * 100) / 100,
|
|
nationalAvgPrice: national.avg_price
|
|
? Math.round(parseFloat(national.avg_price) * 100) / 100
|
|
: null,
|
|
bestPerformingState: rows[0]?.state || null,
|
|
worstPerformingState: rows[rows.length - 1]?.state || null,
|
|
});
|
|
} catch (error: any) {
|
|
console.error('[Intelligence] Error fetching brand penetration:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/admin/intelligence/pricing
|
|
* Get pricing analytics by category
|
|
* Query params:
|
|
* - state: Filter by state (e.g., "AZ")
|
|
*/
|
|
router.get('/pricing', async (req: Request, res: Response) => {
|
|
try {
|
|
const { state } = req.query;
|
|
|
|
// Build WHERE clause based on state filter
|
|
let stateFilter = '';
|
|
const categoryParams: any[] = [];
|
|
const stateQueryParams: any[] = [];
|
|
const overallParams: any[] = [];
|
|
|
|
if (state && state !== 'all') {
|
|
stateFilter = 'AND d.state = $1';
|
|
categoryParams.push(state);
|
|
overallParams.push(state);
|
|
}
|
|
|
|
// Category pricing with optional state filter
|
|
const categoryQuery = state && state !== 'all'
|
|
? `
|
|
SELECT
|
|
sp.category_raw as category,
|
|
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
|
MIN(sp.price_rec) as min_price,
|
|
MAX(sp.price_rec) as max_price,
|
|
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2) as median_price,
|
|
COUNT(*) as product_count
|
|
FROM store_products sp
|
|
JOIN dispensaries d ON sp.dispensary_id = d.id
|
|
WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0 ${stateFilter}
|
|
GROUP BY sp.category_raw
|
|
ORDER BY product_count DESC
|
|
`
|
|
: `
|
|
SELECT
|
|
sp.category_raw as category,
|
|
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
|
MIN(sp.price_rec) as min_price,
|
|
MAX(sp.price_rec) as max_price,
|
|
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2) as median_price,
|
|
COUNT(*) as product_count
|
|
FROM store_products sp
|
|
WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0
|
|
GROUP BY sp.category_raw
|
|
ORDER BY product_count DESC
|
|
`;
|
|
|
|
const { rows: categoryRows } = await pool.query(categoryQuery, categoryParams);
|
|
|
|
// State pricing
|
|
const { rows: stateRows } = await pool.query(`
|
|
SELECT
|
|
d.state,
|
|
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
|
MIN(sp.price_rec) as min_price,
|
|
MAX(sp.price_rec) as max_price,
|
|
COUNT(DISTINCT sp.id) as product_count
|
|
FROM store_products sp
|
|
JOIN dispensaries d ON sp.dispensary_id = d.id
|
|
WHERE d.state IS NOT NULL AND sp.price_rec > 0
|
|
GROUP BY d.state
|
|
ORDER BY avg_price DESC
|
|
`);
|
|
|
|
// Overall stats with optional state filter
|
|
const overallQuery = state && state !== 'all'
|
|
? `
|
|
SELECT
|
|
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
|
MIN(sp.price_rec) as min_price,
|
|
MAX(sp.price_rec) as max_price,
|
|
COUNT(*) as total_products
|
|
FROM store_products sp
|
|
JOIN dispensaries d ON sp.dispensary_id = d.id
|
|
WHERE sp.price_rec > 0 ${stateFilter}
|
|
`
|
|
: `
|
|
SELECT
|
|
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
|
MIN(sp.price_rec) as min_price,
|
|
MAX(sp.price_rec) as max_price,
|
|
COUNT(*) as total_products
|
|
FROM store_products sp
|
|
WHERE sp.price_rec > 0
|
|
`;
|
|
|
|
const { rows: overallRows } = await pool.query(overallQuery, overallParams);
|
|
const overall = overallRows[0];
|
|
|
|
res.json({
|
|
byCategory: categoryRows.map((r: any) => ({
|
|
category: r.category,
|
|
avgPrice: r.avg_price ? parseFloat(r.avg_price) : null,
|
|
minPrice: r.min_price ? parseFloat(r.min_price) : null,
|
|
maxPrice: r.max_price ? parseFloat(r.max_price) : null,
|
|
medianPrice: r.median_price ? parseFloat(r.median_price) : null,
|
|
productCount: parseInt(r.product_count, 10),
|
|
})),
|
|
byState: stateRows.map((r: any) => ({
|
|
state: r.state,
|
|
avgPrice: r.avg_price ? parseFloat(r.avg_price) : null,
|
|
minPrice: r.min_price ? parseFloat(r.min_price) : null,
|
|
maxPrice: r.max_price ? parseFloat(r.max_price) : null,
|
|
productCount: parseInt(r.product_count, 10),
|
|
})),
|
|
overall: {
|
|
avgPrice: overall?.avg_price ? parseFloat(overall.avg_price) : null,
|
|
minPrice: overall?.min_price ? parseFloat(overall.min_price) : null,
|
|
maxPrice: overall?.max_price ? parseFloat(overall.max_price) : null,
|
|
totalProducts: parseInt(overall?.total_products || '0', 10),
|
|
},
|
|
});
|
|
} catch (error: any) {
|
|
console.error('[Intelligence] Error fetching pricing:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/admin/intelligence/stores
|
|
* Get store intelligence summary
|
|
* Query params:
|
|
* - state: Filter by state (e.g., "AZ")
|
|
* - limit: Max results (default 200)
|
|
*/
|
|
router.get('/stores', async (req: Request, res: Response) => {
|
|
try {
|
|
const { state, limit = '200' } = req.query;
|
|
const limitNum = Math.min(parseInt(limit as string, 10), 500);
|
|
|
|
// Build WHERE clause based on state filter
|
|
let stateFilter = '';
|
|
const params: any[] = [limitNum];
|
|
if (state && state !== 'all') {
|
|
stateFilter = 'AND d.state = $2';
|
|
params.push(state);
|
|
}
|
|
|
|
const { rows: storeRows } = await pool.query(`
|
|
SELECT
|
|
d.id,
|
|
d.name,
|
|
d.dba_name,
|
|
d.city,
|
|
d.state,
|
|
d.menu_type,
|
|
d.crawl_enabled,
|
|
c.name as chain_name,
|
|
COUNT(DISTINCT sp.id) as sku_count,
|
|
COUNT(DISTINCT sp.brand_name_raw) as brand_count,
|
|
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
|
MAX(sp.updated_at) as last_crawl,
|
|
(SELECT COUNT(*) FROM store_product_snapshots sps
|
|
WHERE sps.store_product_id IN (SELECT id FROM store_products WHERE dispensary_id = d.id)) as snapshot_count
|
|
FROM dispensaries d
|
|
LEFT JOIN store_products sp ON sp.dispensary_id = d.id
|
|
LEFT JOIN chains c ON d.chain_id = c.id
|
|
WHERE d.state IS NOT NULL AND d.crawl_enabled = true
|
|
${stateFilter}
|
|
GROUP BY d.id, d.name, d.dba_name, d.city, d.state, d.menu_type, d.crawl_enabled, c.name
|
|
ORDER BY sku_count DESC
|
|
LIMIT $1
|
|
`, params);
|
|
|
|
res.json({
|
|
stores: storeRows.map((r: any) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
dbaName: r.dba_name,
|
|
city: r.city,
|
|
state: r.state,
|
|
menuType: r.menu_type,
|
|
crawlEnabled: r.crawl_enabled,
|
|
chainName: r.chain_name || null,
|
|
skuCount: parseInt(r.sku_count || '0', 10),
|
|
snapshotCount: parseInt(r.snapshot_count || '0', 10),
|
|
brandCount: parseInt(r.brand_count || '0', 10),
|
|
avgPrice: r.avg_price ? parseFloat(r.avg_price) : null,
|
|
lastCrawl: r.last_crawl,
|
|
crawlFrequencyHours: 4, // Default crawl frequency
|
|
})),
|
|
total: storeRows.length,
|
|
});
|
|
} catch (error: any) {
|
|
console.error('[Intelligence] Error fetching stores:', error.message);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
export default router;
|