- Add brand promotional history endpoint (GET /api/analytics/v2/brand/:name/promotions) - Tracks when products go on special, duration, discounts, quantity sold estimates - Aggregates by category with frequency metrics (weekly/monthly) - Add quantity changes endpoint (GET /api/analytics/v2/store/:id/quantity-changes) - Filter by direction (increase/decrease/all) for sales vs restock estimation - Fix canonical-upsert to populate stock_quantity and total_quantity_available - Add API key edit functionality in admin UI - Edit allowed domains and IPs - Display domains in list view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
641 lines
22 KiB
TypeScript
641 lines
22 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
|
|
*/
|
|
|
|
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' });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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' });
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// 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;
|
|
}
|