/** * Analytics V2 API Routes * * Enhanced analytics endpoints using the canonical schema with * rec/med state segmentation and comprehensive market analysis. * * Routes are prefixed with /api/analytics/v2 * * Phase 3: Analytics Engine + Rec/Med by State */ import { Router, Request, Response } from 'express'; import { Pool } from 'pg'; import { PriceAnalyticsService } from '../services/analytics/PriceAnalyticsService'; import { BrandPenetrationService } from '../services/analytics/BrandPenetrationService'; import { CategoryAnalyticsService } from '../services/analytics/CategoryAnalyticsService'; import { StoreAnalyticsService } from '../services/analytics/StoreAnalyticsService'; import { StateAnalyticsService } from '../services/analytics/StateAnalyticsService'; import { TimeWindow, LegalType } from '../services/analytics/types'; function parseTimeWindow(window?: string): TimeWindow { if (window === '7d' || window === '30d' || window === '90d' || window === 'custom') { return window; } return '30d'; } function parseLegalType(legalType?: string): LegalType { if (legalType === 'recreational' || legalType === 'medical_only' || legalType === 'no_program') { return legalType; } return 'all'; } export function createAnalyticsV2Router(pool: Pool): Router { const router = Router(); // Initialize services const priceService = new PriceAnalyticsService(pool); const brandService = new BrandPenetrationService(pool); const categoryService = new CategoryAnalyticsService(pool); const storeService = new StoreAnalyticsService(pool); const stateService = new StateAnalyticsService(pool); // ============================================================ // PRICE ANALYTICS // ============================================================ /** * GET /price/product/:id * Get price trends for a specific store product */ router.get('/price/product/:id', async (req: Request, res: Response) => { try { const storeProductId = parseInt(req.params.id); const window = parseTimeWindow(req.query.window as string); const result = await priceService.getPriceTrendsForStoreProduct(storeProductId, { window }); if (!result) { return res.status(404).json({ error: 'Product not found' }); } res.json(result); } catch (error) { console.error('[AnalyticsV2] Price product error:', error); res.status(500).json({ error: 'Failed to fetch product price trend' }); } }); /** * GET /price/category/:category * Get price statistics for a category by state */ router.get('/price/category/:category', async (req: Request, res: Response) => { try { const category = decodeURIComponent(req.params.category); const stateCode = req.query.state as string | undefined; const result = await priceService.getCategoryPriceByState(category, { stateCode }); res.json(result); } catch (error) { console.error('[AnalyticsV2] Price category error:', error); res.status(500).json({ error: 'Failed to fetch category price stats' }); } }); /** * GET /price/brand/:brand * Get price statistics for a brand by state */ router.get('/price/brand/:brand', async (req: Request, res: Response) => { try { const brandName = decodeURIComponent(req.params.brand); const stateCode = req.query.state as string | undefined; const result = await priceService.getBrandPriceByState(brandName, { stateCode }); res.json(result); } catch (error) { console.error('[AnalyticsV2] Price brand error:', error); res.status(500).json({ error: 'Failed to fetch brand price stats' }); } }); /** * GET /price/volatile * Get most volatile products (frequent price changes) */ router.get('/price/volatile', async (req: Request, res: Response) => { try { const window = parseTimeWindow(req.query.window as string); const limit = req.query.limit ? parseInt(req.query.limit as string) : 50; const stateCode = req.query.state as string | undefined; const category = req.query.category as string | undefined; const result = await priceService.getMostVolatileProducts({ window, limit, stateCode, category, }); res.json(result); } catch (error) { console.error('[AnalyticsV2] Price volatile error:', error); res.status(500).json({ error: 'Failed to fetch volatile products' }); } }); /** * GET /price/rec-vs-med * Get rec vs med price comparison by category */ router.get('/price/rec-vs-med', async (req: Request, res: Response) => { try { const category = req.query.category as string | undefined; const result = await priceService.getCategoryRecVsMedPrices(category); res.json(result); } catch (error) { console.error('[AnalyticsV2] Price rec vs med error:', error); res.status(500).json({ error: 'Failed to fetch rec vs med prices' }); } }); // ============================================================ // BRAND PENETRATION // ============================================================ /** * GET /brand/:name/penetration * Get brand penetration metrics */ router.get('/brand/:name/penetration', async (req: Request, res: Response) => { try { const brandName = decodeURIComponent(req.params.name); const window = parseTimeWindow(req.query.window as string); const result = await brandService.getBrandPenetration(brandName, { window }); if (!result) { return res.status(404).json({ error: 'Brand not found' }); } res.json(result); } catch (error) { console.error('[AnalyticsV2] Brand penetration error:', error); res.status(500).json({ error: 'Failed to fetch brand penetration' }); } }); /** * GET /brand/:name/market-position * Get brand market position within categories */ router.get('/brand/:name/market-position', async (req: Request, res: Response) => { try { const brandName = decodeURIComponent(req.params.name); const category = req.query.category as string | undefined; const stateCode = req.query.state as string | undefined; const result = await brandService.getBrandMarketPosition(brandName, { category, stateCode }); res.json(result); } catch (error) { console.error('[AnalyticsV2] Brand market position error:', error); res.status(500).json({ error: 'Failed to fetch brand market position' }); } }); /** * GET /brand/:name/rec-vs-med * Get brand presence in rec vs med-only states */ router.get('/brand/:name/rec-vs-med', async (req: Request, res: Response) => { try { const brandName = decodeURIComponent(req.params.name); const result = await brandService.getBrandRecVsMedFootprint(brandName); res.json(result); } catch (error) { console.error('[AnalyticsV2] Brand rec vs med error:', error); res.status(500).json({ error: 'Failed to fetch brand rec vs med footprint' }); } }); /** * GET /brand/top * Get top brands by penetration */ router.get('/brand/top', async (req: Request, res: Response) => { try { const limit = req.query.limit ? parseInt(req.query.limit as string) : 25; const stateCode = req.query.state as string | undefined; const category = req.query.category as string | undefined; const result = await brandService.getTopBrandsByPenetration({ limit, stateCode, category }); res.json(result); } catch (error) { console.error('[AnalyticsV2] Top brands error:', error); res.status(500).json({ error: 'Failed to fetch top brands' }); } }); /** * GET /brand/expansion-contraction * Get brands that have expanded or contracted */ router.get('/brand/expansion-contraction', async (req: Request, res: Response) => { try { const window = parseTimeWindow(req.query.window as string); const limit = req.query.limit ? parseInt(req.query.limit as string) : 25; const result = await brandService.getBrandExpansionContraction({ window, limit }); res.json(result); } catch (error) { console.error('[AnalyticsV2] Brand expansion error:', error); res.status(500).json({ error: 'Failed to fetch brand expansion/contraction' }); } }); // ============================================================ // CATEGORY ANALYTICS // ============================================================ /** * GET /category/:name/growth * Get category growth metrics */ router.get('/category/:name/growth', async (req: Request, res: Response) => { try { const category = decodeURIComponent(req.params.name); const window = parseTimeWindow(req.query.window as string); const result = await categoryService.getCategoryGrowth(category, { window }); if (!result) { return res.status(404).json({ error: 'Category not found' }); } res.json(result); } catch (error) { console.error('[AnalyticsV2] Category growth error:', error); res.status(500).json({ error: 'Failed to fetch category growth' }); } }); /** * GET /category/:name/trend * Get category growth trend over time */ router.get('/category/:name/trend', async (req: Request, res: Response) => { try { const category = decodeURIComponent(req.params.name); const window = parseTimeWindow(req.query.window as string); const result = await categoryService.getCategoryGrowthTrend(category, { window }); res.json(result); } catch (error) { console.error('[AnalyticsV2] Category trend error:', error); res.status(500).json({ error: 'Failed to fetch category trend' }); } }); /** * GET /category/:name/top-brands * Get top brands within a category */ router.get('/category/:name/top-brands', async (req: Request, res: Response) => { try { const category = decodeURIComponent(req.params.name); const limit = req.query.limit ? parseInt(req.query.limit as string) : 25; const stateCode = req.query.state as string | undefined; const result = await categoryService.getTopBrandsInCategory(category, { limit, stateCode }); res.json(result); } catch (error) { console.error('[AnalyticsV2] Category top brands error:', error); res.status(500).json({ error: 'Failed to fetch top brands in category' }); } }); /** * GET /category/all * Get all categories with metrics */ router.get('/category/all', async (req: Request, res: Response) => { try { const stateCode = req.query.state as string | undefined; const limit = req.query.limit ? parseInt(req.query.limit as string) : 50; const result = await categoryService.getAllCategories({ stateCode, limit }); res.json(result); } catch (error) { console.error('[AnalyticsV2] All categories error:', error); res.status(500).json({ error: 'Failed to fetch categories' }); } }); /** * GET /category/rec-vs-med * Get category comparison between rec and med-only states */ router.get('/category/rec-vs-med', async (req: Request, res: Response) => { try { const category = req.query.category as string | undefined; const result = await categoryService.getCategoryRecVsMedComparison(category); res.json(result); } catch (error) { console.error('[AnalyticsV2] Category rec vs med error:', error); res.status(500).json({ error: 'Failed to fetch category rec vs med comparison' }); } }); /** * GET /category/fastest-growing * Get fastest growing categories */ router.get('/category/fastest-growing', async (req: Request, res: Response) => { try { const window = parseTimeWindow(req.query.window as string); const limit = req.query.limit ? parseInt(req.query.limit as string) : 25; const result = await categoryService.getFastestGrowingCategories({ window, limit }); res.json(result); } catch (error) { console.error('[AnalyticsV2] Fastest growing error:', error); res.status(500).json({ error: 'Failed to fetch fastest growing categories' }); } }); // ============================================================ // STORE ANALYTICS // ============================================================ /** * GET /store/:id/summary * Get change summary for a store */ router.get('/store/:id/summary', async (req: Request, res: Response) => { try { const dispensaryId = parseInt(req.params.id); const window = parseTimeWindow(req.query.window as string); const result = await storeService.getStoreChangeSummary(dispensaryId, { window }); if (!result) { return res.status(404).json({ error: 'Store not found' }); } res.json(result); } catch (error) { console.error('[AnalyticsV2] Store summary error:', error); res.status(500).json({ error: 'Failed to fetch store summary' }); } }); /** * GET /store/:id/events * Get recent product change events for a store */ router.get('/store/:id/events', async (req: Request, res: Response) => { try { const dispensaryId = parseInt(req.params.id); const window = parseTimeWindow(req.query.window as string); const limit = req.query.limit ? parseInt(req.query.limit as string) : 100; const result = await storeService.getProductChangeEvents(dispensaryId, { window, limit }); res.json(result); } catch (error) { console.error('[AnalyticsV2] Store events error:', error); res.status(500).json({ error: 'Failed to fetch store events' }); } }); /** * GET /store/:id/changes * Alias for /store/:id/events - matches Analytics V2 spec naming * Returns list of detected changes (new products, price drops, new brands) */ router.get('/store/:id/changes', async (req: Request, res: Response) => { try { const dispensaryId = parseInt(req.params.id); const window = parseTimeWindow(req.query.window as string); const limit = req.query.limit ? parseInt(req.query.limit as string) : 100; const result = await storeService.getProductChangeEvents(dispensaryId, { window, limit }); res.json(result); } catch (error) { console.error('[AnalyticsV2] Store changes error:', error); res.status(500).json({ error: 'Failed to fetch store changes' }); } }); /** * GET /store/:id/inventory * Get store inventory composition */ router.get('/store/:id/inventory', async (req: Request, res: Response) => { try { const dispensaryId = parseInt(req.params.id); const result = await storeService.getStoreInventoryComposition(dispensaryId); res.json(result); } catch (error) { console.error('[AnalyticsV2] Store inventory error:', error); res.status(500).json({ error: 'Failed to fetch store inventory' }); } }); /** * GET /store/:id/price-position * Get store price positioning vs market */ router.get('/store/:id/price-position', async (req: Request, res: Response) => { try { const dispensaryId = parseInt(req.params.id); const result = await storeService.getStorePricePositioning(dispensaryId); res.json(result); } catch (error) { console.error('[AnalyticsV2] Store price position error:', error); res.status(500).json({ error: 'Failed to fetch store price positioning' }); } }); /** * GET /store/most-active * Get stores with most changes */ router.get('/store/most-active', async (req: Request, res: Response) => { try { const window = parseTimeWindow(req.query.window as string); const limit = req.query.limit ? parseInt(req.query.limit as string) : 25; const stateCode = req.query.state as string | undefined; const result = await storeService.getMostActiveStores({ window, limit, stateCode }); res.json(result); } catch (error) { console.error('[AnalyticsV2] Most active stores error:', error); res.status(500).json({ error: 'Failed to fetch most active stores' }); } }); // ============================================================ // STATE ANALYTICS // ============================================================ /** * GET /state/:code/summary * Get market summary for a specific state */ router.get('/state/:code/summary', async (req: Request, res: Response) => { try { const stateCode = req.params.code.toUpperCase(); const result = await stateService.getStateMarketSummary(stateCode); if (!result) { return res.status(404).json({ error: 'State not found' }); } res.json(result); } catch (error) { console.error('[AnalyticsV2] State summary error:', error); res.status(500).json({ error: 'Failed to fetch state summary' }); } }); /** * GET /state/all * Get all states with coverage metrics */ router.get('/state/all', async (_req: Request, res: Response) => { try { const result = await stateService.getAllStatesWithCoverage(); res.json(result); } catch (error) { console.error('[AnalyticsV2] All states error:', error); res.status(500).json({ error: 'Failed to fetch states' }); } }); /** * GET /state/legal-breakdown * Get breakdown by legal status (rec, med-only, no program) */ router.get('/state/legal-breakdown', async (_req: Request, res: Response) => { try { const result = await stateService.getLegalStateBreakdown(); res.json(result); } catch (error) { console.error('[AnalyticsV2] Legal breakdown error:', error); res.status(500).json({ error: 'Failed to fetch legal breakdown' }); } }); /** * GET /state/rec-vs-med-pricing * Get rec vs med price comparison by category */ router.get('/state/rec-vs-med-pricing', async (req: Request, res: Response) => { try { const category = req.query.category as string | undefined; const result = await stateService.getRecVsMedPriceComparison(category); res.json(result); } catch (error) { console.error('[AnalyticsV2] Rec vs med pricing error:', error); res.status(500).json({ error: 'Failed to fetch rec vs med pricing' }); } }); /** * GET /state/coverage-gaps * Get states with coverage gaps */ router.get('/state/coverage-gaps', async (_req: Request, res: Response) => { try { const result = await stateService.getStateCoverageGaps(); res.json(result); } catch (error) { console.error('[AnalyticsV2] Coverage gaps error:', error); res.status(500).json({ error: 'Failed to fetch coverage gaps' }); } }); /** * GET /state/price-comparison * Get pricing comparison across all states */ router.get('/state/price-comparison', async (_req: Request, res: Response) => { try { const result = await stateService.getStatePricingComparison(); res.json(result); } catch (error) { console.error('[AnalyticsV2] State price comparison error:', error); res.status(500).json({ error: 'Failed to fetch state price comparison' }); } }); /** * GET /state/recreational * Get list of recreational state codes */ router.get('/state/recreational', async (_req: Request, res: Response) => { try { const result = await stateService.getRecreationalStates(); res.json({ legal_type: 'recreational', states: result, count: result.length }); } catch (error) { console.error('[AnalyticsV2] Recreational states error:', error); res.status(500).json({ error: 'Failed to fetch recreational states' }); } }); /** * GET /state/medical-only * Get list of medical-only state codes (not recreational) */ router.get('/state/medical-only', async (_req: Request, res: Response) => { try { const result = await stateService.getMedicalOnlyStates(); res.json({ legal_type: 'medical_only', states: result, count: result.length }); } catch (error) { console.error('[AnalyticsV2] Medical-only states error:', error); res.status(500).json({ error: 'Failed to fetch medical-only states' }); } }); /** * GET /state/no-program * Get list of states with no cannabis program */ router.get('/state/no-program', async (_req: Request, res: Response) => { try { const result = await stateService.getNoProgramStates(); res.json({ legal_type: 'no_program', states: result, count: result.length }); } catch (error) { console.error('[AnalyticsV2] No-program states error:', error); res.status(500).json({ error: 'Failed to fetch no-program states' }); } }); return router; }