Files
cannaiq/backend/src/routes/analytics-v2.ts
Kelly 1fb0eb94c2 security: Add authMiddleware to analytics-v2 routes
- Add authMiddleware to analytics-v2.ts to require authentication
- Add permanent rule #6 to CLAUDE.md: "ALL API ROUTES REQUIRE AUTHENTICATION"
- Add forbidden action #19: "Creating API routes without authMiddleware"
- Document authentication flow and trusted origins

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 19:01:44 -07:00

696 lines
24 KiB
TypeScript

/**
* 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
*
* SECURITY: All routes require authentication via authMiddleware.
* Access is granted to:
* - Trusted origins (cannaiq.co, findadispo.com, etc.)
* - Trusted IPs (localhost, internal pods)
* - Valid JWT or API tokens
*/
import { Router, Request, Response } from 'express';
import { Pool } from 'pg';
import { authMiddleware } from '../auth/middleware';
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 { BrandIntelligenceService } from '../services/analytics/BrandIntelligenceService';
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();
// SECURITY: Apply auth middleware to ALL routes
// This gate ensures only authenticated requests can access analytics data
router.use(authMiddleware);
// 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);
const brandIntelligenceService = new BrandIntelligenceService(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' });
}
});
/**
* GET /brand/:name/promotions
* Get brand promotional history - tracks specials, discounts, duration, and sales estimates
*
* Query params:
* - window: 7d|30d|90d (default: 90d)
* - state: state code filter (e.g., AZ)
* - category: category filter (e.g., Flower)
*/
router.get('/brand/:name/promotions', async (req: Request, res: Response) => {
try {
const brandName = decodeURIComponent(req.params.name);
const window = parseTimeWindow(req.query.window as string) || '90d';
const stateCode = req.query.state as string | undefined;
const category = req.query.category as string | undefined;
const result = await brandService.getBrandPromotionalHistory(brandName, {
window,
stateCode,
category,
});
res.json(result);
} catch (error) {
console.error('[AnalyticsV2] Brand promotions error:', error);
res.status(500).json({ error: 'Failed to fetch brand promotional history' });
}
});
/**
* GET /brand/:name/intelligence
* Get comprehensive B2B brand intelligence dashboard data
*
* Returns all brand metrics in a single unified response:
* - Performance Snapshot (active SKUs, revenue, stores, market share)
* - Alerts/Slippage (lost stores, delisted SKUs, competitor takeovers)
* - Product Velocity (daily rates, velocity status)
* - Retail Footprint (penetration, whitespace opportunities)
* - Competitive Landscape (price position, market share trend)
* - Inventory Health (days of stock, risk levels)
* - Promotion Effectiveness (baseline vs promo velocity, ROI)
*
* Query params:
* - window: 7d|30d|90d (default: 30d)
* - state: state code filter (e.g., AZ)
* - category: category filter (e.g., Flower)
*/
router.get('/brand/:name/intelligence', async (req: Request, res: Response) => {
try {
const brandName = decodeURIComponent(req.params.name);
const window = parseTimeWindow(req.query.window as string);
const stateCode = req.query.state as string | undefined;
const category = req.query.category as string | undefined;
const result = await brandIntelligenceService.getBrandIntelligence(brandName, {
window,
stateCode,
category,
});
if (!result) {
return res.status(404).json({ error: 'Brand not found' });
}
res.json(result);
} catch (error) {
console.error('[AnalyticsV2] Brand intelligence error:', error);
res.status(500).json({ error: 'Failed to fetch brand intelligence' });
}
});
// ============================================================
// 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/quantity-changes
* Get quantity changes for a store (increases/decreases)
* Useful for estimating sales (decreases) or restocks (increases)
*
* Query params:
* - window: 7d|30d|90d (default: 7d)
* - direction: increase|decrease|all (default: all)
* - limit: number (default: 100)
*/
router.get('/store/:id/quantity-changes', async (req: Request, res: Response) => {
try {
const dispensaryId = parseInt(req.params.id);
const window = parseTimeWindow(req.query.window as string);
const direction = (req.query.direction as 'increase' | 'decrease' | 'all') || 'all';
const limit = req.query.limit ? parseInt(req.query.limit as string) : 100;
const result = await storeService.getQuantityChanges(dispensaryId, { window, direction, limit });
res.json(result);
} catch (error) {
console.error('[AnalyticsV2] Store quantity changes error:', error);
res.status(500).json({ error: 'Failed to fetch store quantity 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;
}