Major additions: - Multi-state expansion: states table, StateSelector, NationalDashboard, StateHeatmap, CrossStateCompare - Orchestrator services: trace service, error taxonomy, retry manager, proxy rotator - Discovery system: dutchie discovery service, geo validation, city seeding scripts - Analytics infrastructure: analytics v2 routes, brand/pricing/stores intelligence pages - Local development: setup-local.sh starts all 5 services (postgres, backend, cannaiq, findadispo, findagram) - Migrations 037-056: crawler profiles, states, analytics indexes, worker metadata Frontend pages added: - Discovery, ChainsDashboard, IntelligenceBrands, IntelligencePricing, IntelligenceStores - StateHeatmap, CrossStateCompare, SyncInfoPanel Components added: - StateSelector, OrchestratorTraceModal, WorkflowStepper 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
208 lines
6.3 KiB
TypeScript
208 lines
6.3 KiB
TypeScript
/**
|
|
* GeoValidationService
|
|
*
|
|
* Service for validating geographic data in discovery locations.
|
|
* All validation is done locally - no external API calls.
|
|
*/
|
|
|
|
import { isCoordinateValid, isWithinUS, isWithinCanada } from '../utils/GeoUtils';
|
|
|
|
export interface DiscoveryLocationGeoData {
|
|
latitude: number | null;
|
|
longitude: number | null;
|
|
state_code: string | null;
|
|
country_code: string | null;
|
|
}
|
|
|
|
export interface GeoValidationResult {
|
|
ok: boolean;
|
|
reason?: string;
|
|
warnings?: string[];
|
|
}
|
|
|
|
/**
|
|
* Simple state-to-region mapping for rough validation.
|
|
* This is a heuristic - not precise polygon matching.
|
|
*/
|
|
const STATE_REGION_HINTS: Record<string, { latRange: [number, number]; lonRange: [number, number] }> = {
|
|
// West Coast
|
|
'WA': { latRange: [45.5, 49.0], lonRange: [-125, -116.9] },
|
|
'OR': { latRange: [42.0, 46.3], lonRange: [-124.6, -116.5] },
|
|
'CA': { latRange: [32.5, 42.0], lonRange: [-124.5, -114.1] },
|
|
|
|
// Southwest
|
|
'AZ': { latRange: [31.3, 37.0], lonRange: [-115, -109] },
|
|
'NV': { latRange: [35.0, 42.0], lonRange: [-120, -114] },
|
|
'NM': { latRange: [31.3, 37.0], lonRange: [-109, -103] },
|
|
'UT': { latRange: [37.0, 42.0], lonRange: [-114, -109] },
|
|
|
|
// Mountain
|
|
'CO': { latRange: [37.0, 41.0], lonRange: [-109, -102] },
|
|
'WY': { latRange: [41.0, 45.0], lonRange: [-111, -104] },
|
|
'MT': { latRange: [45.0, 49.0], lonRange: [-116, -104] },
|
|
'ID': { latRange: [42.0, 49.0], lonRange: [-117, -111] },
|
|
|
|
// Midwest
|
|
'MI': { latRange: [41.7, 48.3], lonRange: [-90.5, -82.4] },
|
|
'IL': { latRange: [37.0, 42.5], lonRange: [-91.5, -87.0] },
|
|
'OH': { latRange: [38.4, 42.0], lonRange: [-84.8, -80.5] },
|
|
'MO': { latRange: [36.0, 40.6], lonRange: [-95.8, -89.1] },
|
|
|
|
// Northeast
|
|
'NY': { latRange: [40.5, 45.0], lonRange: [-79.8, -71.9] },
|
|
'MA': { latRange: [41.2, 42.9], lonRange: [-73.5, -69.9] },
|
|
'PA': { latRange: [39.7, 42.3], lonRange: [-80.5, -74.7] },
|
|
'NJ': { latRange: [38.9, 41.4], lonRange: [-75.6, -73.9] },
|
|
|
|
// Southeast
|
|
'FL': { latRange: [24.5, 31.0], lonRange: [-87.6, -80.0] },
|
|
'GA': { latRange: [30.4, 35.0], lonRange: [-85.6, -80.8] },
|
|
'TX': { latRange: [25.8, 36.5], lonRange: [-106.6, -93.5] },
|
|
'NC': { latRange: [34.0, 36.6], lonRange: [-84.3, -75.5] },
|
|
|
|
// Alaska & Hawaii
|
|
'AK': { latRange: [51.0, 72.0], lonRange: [-180, -130] },
|
|
'HI': { latRange: [18.5, 22.5], lonRange: [-161, -154] },
|
|
|
|
// Canadian provinces (rough)
|
|
'ON': { latRange: [41.7, 57.0], lonRange: [-95.2, -74.3] },
|
|
'BC': { latRange: [48.3, 60.0], lonRange: [-139, -114.0] },
|
|
'AB': { latRange: [49.0, 60.0], lonRange: [-120, -110] },
|
|
'QC': { latRange: [45.0, 62.6], lonRange: [-79.8, -57.1] },
|
|
};
|
|
|
|
export class GeoValidationService {
|
|
/**
|
|
* Validate a discovery location's geographic data.
|
|
*
|
|
* @param location Discovery location data with lat/lng and state/country codes
|
|
* @returns Validation result with ok status and optional reason/warnings
|
|
*/
|
|
validateLocationState(location: DiscoveryLocationGeoData): GeoValidationResult {
|
|
const warnings: string[] = [];
|
|
|
|
// Check if coordinates exist
|
|
if (location.latitude === null || location.longitude === null) {
|
|
return {
|
|
ok: true, // Not a failure - just no coordinates to validate
|
|
reason: 'No coordinates available for validation',
|
|
};
|
|
}
|
|
|
|
// Check basic coordinate validity
|
|
if (!isCoordinateValid(location.latitude, location.longitude)) {
|
|
return {
|
|
ok: false,
|
|
reason: `Invalid coordinates: lat=${location.latitude}, lon=${location.longitude}`,
|
|
};
|
|
}
|
|
|
|
const lat = location.latitude;
|
|
const lon = location.longitude;
|
|
|
|
// Check country code consistency
|
|
if (location.country_code === 'US') {
|
|
if (!isWithinUS(lat, lon)) {
|
|
return {
|
|
ok: false,
|
|
reason: `Coordinates (${lat}, ${lon}) are outside US bounds but country_code is US`,
|
|
};
|
|
}
|
|
} else if (location.country_code === 'CA') {
|
|
if (!isWithinCanada(lat, lon)) {
|
|
return {
|
|
ok: false,
|
|
reason: `Coordinates (${lat}, ${lon}) are outside Canada bounds but country_code is CA`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check state code consistency (if we have a hint for this state)
|
|
if (location.state_code) {
|
|
const hint = STATE_REGION_HINTS[location.state_code];
|
|
if (hint) {
|
|
const [minLat, maxLat] = hint.latRange;
|
|
const [minLon, maxLon] = hint.lonRange;
|
|
|
|
// Allow some tolerance (coordinates might be near borders)
|
|
const tolerance = 0.5; // degrees
|
|
|
|
if (
|
|
lat < minLat - tolerance ||
|
|
lat > maxLat + tolerance ||
|
|
lon < minLon - tolerance ||
|
|
lon > maxLon + tolerance
|
|
) {
|
|
warnings.push(
|
|
`Coordinates (${lat.toFixed(4)}, ${lon.toFixed(4)}) may not match state ${location.state_code} ` +
|
|
`(expected lat: ${minLat}-${maxLat}, lon: ${minLon}-${maxLon})`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
warnings: warnings.length > 0 ? warnings : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Batch validate multiple locations.
|
|
*
|
|
* @param locations Array of discovery location data
|
|
* @returns Map of validation results keyed by index
|
|
*/
|
|
validateLocations(locations: DiscoveryLocationGeoData[]): Map<number, GeoValidationResult> {
|
|
const results = new Map<number, GeoValidationResult>();
|
|
|
|
locations.forEach((location, index) => {
|
|
results.set(index, this.validateLocationState(location));
|
|
});
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Get a summary of validation results.
|
|
*
|
|
* @param results Map of validation results
|
|
* @returns Summary with counts
|
|
*/
|
|
summarizeValidation(results: Map<number, GeoValidationResult>): {
|
|
total: number;
|
|
valid: number;
|
|
invalid: number;
|
|
noCoordinates: number;
|
|
withWarnings: number;
|
|
} {
|
|
let valid = 0;
|
|
let invalid = 0;
|
|
let noCoordinates = 0;
|
|
let withWarnings = 0;
|
|
|
|
results.forEach((result) => {
|
|
if (!result.ok) {
|
|
invalid++;
|
|
} else if (result.reason?.includes('No coordinates')) {
|
|
noCoordinates++;
|
|
} else {
|
|
valid++;
|
|
if (result.warnings && result.warnings.length > 0) {
|
|
withWarnings++;
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
total: results.size,
|
|
valid,
|
|
invalid,
|
|
noCoordinates,
|
|
withWarnings,
|
|
};
|
|
}
|
|
}
|
|
|
|
export default GeoValidationService;
|