Files
cannaiq/backend/src/multi-state/routes.ts
Kelly 2f483b3084 feat: SEO template library, discovery pipeline, and orchestrator enhancements
## SEO Template Library
- Add complete template library with 7 page types (state, city, category, brand, product, search, regeneration)
- Add Template Library tab in SEO Orchestrator with accordion-based editors
- Add template preview, validation, and variable injection engine
- Add API endpoints: /api/seo/templates, preview, validate, generate, regenerate

## Discovery Pipeline
- Add promotion.ts for discovery location validation and promotion
- Add discover-all-states.ts script for multi-state discovery
- Add promotion log migration (067)
- Enhance discovery routes and types

## Orchestrator & Admin
- Add crawl_enabled filter to stores page
- Add API permissions page
- Add job queue management
- Add price analytics routes
- Add markets and intelligence routes
- Enhance dashboard and worker monitoring

## Infrastructure
- Add migrations for worker definitions, SEO settings, field alignment
- Add canonical pipeline for scraper v2
- Update hydration and sync orchestrator
- Enhance multi-state query service

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 00:05:34 -07:00

518 lines
16 KiB
TypeScript

/**
* Multi-State API Routes
*
* Endpoints for multi-state queries, analytics, and comparisons.
* Phase 4: Multi-State Expansion
*/
import { Router, Request, Response } from 'express';
import { Pool } from 'pg';
import { StateQueryService } from './state-query-service';
import { StateQueryOptions, CrossStateQueryOptions } from './types';
export function createMultiStateRoutes(pool: Pool): Router {
const router = Router();
const stateService = new StateQueryService(pool);
// =========================================================================
// State List Endpoints
// =========================================================================
/**
* GET /api/states
* List all states (both configured and active)
*/
router.get('/states', async (req: Request, res: Response) => {
try {
const activeOnly = req.query.active === 'true';
const states = activeOnly
? await stateService.listActiveStates()
: await stateService.listStates();
res.json({
success: true,
data: {
states,
count: states.length,
},
});
} catch (error: any) {
console.error('[MultiState] Error listing states:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// =========================================================================
// State Summary Endpoints
// =========================================================================
/**
* GET /api/state/:state/summary
* Get detailed summary for a specific state
*/
router.get('/state/:state/summary', async (req: Request, res: Response) => {
try {
const { state } = req.params;
// Validate state code
const isValid = await stateService.isValidState(state.toUpperCase());
if (!isValid) {
return res.status(404).json({
success: false,
error: `Unknown state: ${state}`,
});
}
const summary = await stateService.getStateSummary(state.toUpperCase());
if (!summary) {
return res.status(404).json({
success: false,
error: `No data for state: ${state}`,
});
}
res.json({
success: true,
data: summary,
});
} catch (error: any) {
console.error(`[MultiState] Error getting state summary:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/state/:state/brands
* Get brands in a specific state
*/
router.get('/state/:state/brands', async (req: Request, res: Response) => {
try {
const { state } = req.params;
const options: StateQueryOptions = {
limit: parseInt(req.query.limit as string) || 50,
offset: parseInt(req.query.offset as string) || 0,
sortBy: (req.query.sortBy as string) || 'productCount',
sortDir: (req.query.sortDir as 'asc' | 'desc') || 'desc',
};
const brands = await stateService.getBrandsByState(state.toUpperCase(), options);
res.json({
success: true,
data: {
state: state.toUpperCase(),
brands,
count: brands.length,
pagination: {
limit: options.limit,
offset: options.offset,
},
},
});
} catch (error: any) {
console.error(`[MultiState] Error getting state brands:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/state/:state/categories
* Get categories in a specific state
*/
router.get('/state/:state/categories', async (req: Request, res: Response) => {
try {
const { state } = req.params;
const options: StateQueryOptions = {
limit: parseInt(req.query.limit as string) || 50,
offset: parseInt(req.query.offset as string) || 0,
sortBy: (req.query.sortBy as string) || 'productCount',
sortDir: (req.query.sortDir as 'asc' | 'desc') || 'desc',
};
const categories = await stateService.getCategoriesByState(state.toUpperCase(), options);
res.json({
success: true,
data: {
state: state.toUpperCase(),
categories,
count: categories.length,
pagination: {
limit: options.limit,
offset: options.offset,
},
},
});
} catch (error: any) {
console.error(`[MultiState] Error getting state categories:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/state/:state/stores
* Get stores in a specific state
*/
router.get('/state/:state/stores', async (req: Request, res: Response) => {
try {
const { state } = req.params;
const options: StateQueryOptions = {
limit: parseInt(req.query.limit as string) || 100,
offset: parseInt(req.query.offset as string) || 0,
sortBy: (req.query.sortBy as string) || 'productCount',
sortDir: (req.query.sortDir as 'asc' | 'desc') || 'desc',
includeInactive: req.query.includeInactive === 'true',
};
const stores = await stateService.getStoresByState(state.toUpperCase(), options);
res.json({
success: true,
data: {
state: state.toUpperCase(),
stores,
count: stores.length,
pagination: {
limit: options.limit,
offset: options.offset,
},
},
});
} catch (error: any) {
console.error(`[MultiState] Error getting state stores:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/state/:state/analytics/prices
* Get price distribution for a state
*/
router.get('/state/:state/analytics/prices', async (req: Request, res: Response) => {
try {
const { state } = req.params;
const options = {
category: req.query.category as string | undefined,
brandId: req.query.brandId ? parseInt(req.query.brandId as string) : undefined,
};
const priceData = await stateService.getStorePriceDistribution(
state.toUpperCase(),
options
);
res.json({
success: true,
data: {
state: state.toUpperCase(),
priceDistribution: priceData,
},
});
} catch (error: any) {
console.error(`[MultiState] Error getting price analytics:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
// =========================================================================
// National Analytics Endpoints
// =========================================================================
/**
* GET /api/analytics/national/summary
* Get national summary across all states
*/
router.get('/analytics/national/summary', async (req: Request, res: Response) => {
try {
const summary = await stateService.getNationalSummary();
res.json({
success: true,
data: summary,
});
} catch (error: any) {
console.error(`[MultiState] Error getting national summary:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/analytics/national/prices
* Get national price comparison across all states
*/
router.get('/analytics/national/prices', async (req: Request, res: Response) => {
try {
const options = {
category: req.query.category as string | undefined,
brandId: req.query.brandId ? parseInt(req.query.brandId as string) : undefined,
};
const priceData = await stateService.getNationalPriceComparison(options);
res.json({
success: true,
data: {
priceComparison: priceData,
filters: options,
},
});
} catch (error: any) {
console.error(`[MultiState] Error getting national prices:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/analytics/national/heatmap
* GET /api/national/heatmap (alias)
* Get state heatmap data for various metrics
*/
const heatmapHandler = async (req: Request, res: Response) => {
try {
const metric = (req.query.metric as 'stores' | 'products' | 'brands' | 'avgPrice' | 'penetration') || 'stores';
const brandId = req.query.brandId ? parseInt(req.query.brandId as string) : undefined;
const category = req.query.category as string | undefined;
const heatmapData = await stateService.getStateHeatmapData(metric, { brandId, category });
res.json({
success: true,
data: {
metric,
heatmap: heatmapData,
},
});
} catch (error: any) {
console.error(`[MultiState] Error getting heatmap data:`, error);
res.status(500).json({ success: false, error: error.message });
}
};
// Register heatmap on both paths for compatibility
router.get('/analytics/national/heatmap', heatmapHandler);
router.get('/national/heatmap', heatmapHandler);
/**
* GET /api/analytics/national/metrics
* Get all state metrics for dashboard
*/
router.get('/analytics/national/metrics', async (req: Request, res: Response) => {
try {
const metrics = await stateService.getAllStateMetrics();
res.json({
success: true,
data: {
stateMetrics: metrics,
count: metrics.length,
},
});
} catch (error: any) {
console.error(`[MultiState] Error getting state metrics:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
// =========================================================================
// Cross-State Comparison Endpoints
// =========================================================================
/**
* GET /api/analytics/compare/brand/:brandIdOrName
* Compare a brand across multiple states
* Accepts either numeric brand ID or brand name (URL encoded)
*/
router.get('/analytics/compare/brand/:brandIdOrName', async (req: Request, res: Response) => {
try {
const { brandIdOrName } = req.params;
const statesParam = req.query.states as string;
// Parse states - either comma-separated or get all active states
let states: string[];
if (statesParam) {
states = statesParam.split(',').map(s => s.trim().toUpperCase());
} else {
const activeStates = await stateService.listActiveStates();
states = activeStates.map(s => s.code);
}
// Check if it's a numeric ID or a brand name
const brandId = parseInt(brandIdOrName);
let comparison;
if (!isNaN(brandId)) {
// Try by ID first
try {
comparison = await stateService.compareBrandAcrossStates(brandId, states);
} catch (idErr: any) {
// If brand ID not found, try as name
comparison = await stateService.compareBrandByNameAcrossStates(brandIdOrName, states);
}
} else {
// Use brand name directly
comparison = await stateService.compareBrandByNameAcrossStates(decodeURIComponent(brandIdOrName), states);
}
res.json({
success: true,
data: comparison,
});
} catch (error: any) {
console.error(`[MultiState] Error comparing brand across states:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/analytics/compare/category/:category
* Compare a category across multiple states
*/
router.get('/analytics/compare/category/:category', async (req: Request, res: Response) => {
try {
const { category } = req.params;
const statesParam = req.query.states as string;
// Parse states - either comma-separated or get all active states
let states: string[];
if (statesParam) {
states = statesParam.split(',').map(s => s.trim().toUpperCase());
} else {
const activeStates = await stateService.listActiveStates();
states = activeStates.map(s => s.code);
}
const comparison = await stateService.compareCategoryAcrossStates(category, states);
res.json({
success: true,
data: comparison,
});
} catch (error: any) {
console.error(`[MultiState] Error comparing category across states:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/analytics/brand/:brandId/penetration
* Get brand penetration across all states
*/
router.get('/analytics/brand/:brandId/penetration', async (req: Request, res: Response) => {
try {
const brandId = parseInt(req.params.brandId);
const penetration = await stateService.getBrandStatePenetration(brandId);
res.json({
success: true,
data: {
brandId,
statePenetration: penetration,
},
});
} catch (error: any) {
console.error(`[MultiState] Error getting brand penetration:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/analytics/brand/:brandId/trend
* Get national penetration trend for a brand
*/
router.get('/analytics/brand/:brandId/trend', async (req: Request, res: Response) => {
try {
const brandId = parseInt(req.params.brandId);
const days = parseInt(req.query.days as string) || 30;
const trend = await stateService.getNationalPenetrationTrend(brandId, { days });
res.json({
success: true,
data: trend,
});
} catch (error: any) {
console.error(`[MultiState] Error getting brand trend:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
// =========================================================================
// Admin Endpoints
// =========================================================================
/**
* POST /api/admin/states/refresh-metrics
* Manually refresh materialized views
*/
router.post('/admin/states/refresh-metrics', async (req: Request, res: Response) => {
try {
const startTime = Date.now();
await stateService.refreshMetrics();
const duration = Date.now() - startTime;
res.json({
success: true,
message: 'State metrics refreshed successfully',
durationMs: duration,
});
} catch (error: any) {
console.error(`[MultiState] Error refreshing metrics:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
// =========================================================================
// Health Check Endpoint
// =========================================================================
/**
* GET /api/health/analytics
* Health check for analytics subsystem
*/
router.get('/health/analytics', async (req: Request, res: Response) => {
try {
const startTime = Date.now();
// Check materialized view is accessible
const result = await pool.query(`
SELECT COUNT(*) as state_count,
MAX(refreshed_at) as last_refresh
FROM mv_state_metrics
`);
const dbLatency = Date.now() - startTime;
const stateCount = parseInt(result.rows[0]?.state_count || '0', 10);
const lastRefresh = result.rows[0]?.last_refresh;
// Check if data is stale (more than 24 hours old)
const isStale = lastRefresh
? Date.now() - new Date(lastRefresh).getTime() > 24 * 60 * 60 * 1000
: true;
res.json({
success: true,
status: isStale ? 'degraded' : 'healthy',
data: {
statesInCache: stateCount,
lastRefresh: lastRefresh || null,
isStale,
dbLatencyMs: dbLatency,
},
timestamp: new Date().toISOString(),
});
} catch (error: any) {
console.error('[MultiState] Health check failed:', error);
res.status(503).json({
success: false,
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString(),
});
}
});
return router;
}