Files
cannaiq/backend/src/services/GeoValidationService.ts
Kelly b4a2fb7d03 feat: Add v2 architecture with multi-state support and orchestrator services
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>
2025-12-07 11:30:57 -07:00

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;