## 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>
518 lines
16 KiB
TypeScript
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;
|
|
}
|