feat(cannaiq): Add Workers Dashboard and visibility tracking
Workers Dashboard: - New /workers route with two-pane layout - Workers table showing Alice, Henry, Bella, Oscar with role badges - Run history with visibility stats (lost/restored counts) - "Run Now" action to trigger workers immediately Migrations: - 057: Add visibility tracking columns (visibility_lost, visibility_lost_at, visibility_restored_at) - 058: Add ID resolution columns for Henry worker - 059: Add job queue columns (max_retries, retry_count, worker_id, locked_at, locked_by) Backend fixes: - Add httpStatus to CrawlResult interface for error classification - Fix pool.ts typing for event listener - Update completeJob to accept visibility stats in metadata Frontend fixes: - Fix NationalDashboard crash with safe formatMoney helper - Fix OrchestratorDashboard/Stores StoreInfo type mismatches - Add workerName/workerRole to getDutchieAZSchedules API type 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
451
backend/src/multi-state/routes.ts
Normal file
451
backend/src/multi-state/routes.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* 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/:brandId
|
||||
* Compare a brand across multiple states
|
||||
*/
|
||||
router.get('/analytics/compare/brand/:brandId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const brandId = parseInt(req.params.brandId);
|
||||
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.compareBrandAcrossStates(brandId, 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 });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in New Issue
Block a user