"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.geocodingService = void 0; const client_1 = require("@prisma/client"); const crypto_1 = __importDefault(require("crypto")); const env_1 = require("../../../config/env"); const redis_1 = require("../../../config/redis"); const logger_1 = require("../../../utils/logger"); const metrics_1 = require("../../../utils/metrics"); // Redis cache configuration const CACHE_KEY_PREFIX = 'GEOCODE_CACHE:'; // Helper function to hash address for cache key function hashAddress(address) { return crypto_1.default.createHash('sha256').update(address).digest('hex').substring(0, 16); } // Helper functions for Redis cache operations async function getCachedResult(address) { if (env_1.env.GEOCODING_CACHE_ENABLED !== 'true') return null; try { const key = `${CACHE_KEY_PREFIX}${hashAddress(address)}`; const cached = await redis_1.redis.get(key); if (!cached) { metrics_1.cm_geocode_cache_misses.inc(); return null; } const parsed = JSON.parse(cached); metrics_1.cm_geocode_cache_hits.inc(); // Return result without cachedAt timestamp const { cachedAt, ...result } = parsed; return result; } catch (err) { logger_1.logger.warn('Failed to get cached geocode result:', err); metrics_1.cm_geocode_cache_misses.inc(); return null; } } async function setCachedResult(address, result) { if (env_1.env.GEOCODING_CACHE_ENABLED !== 'true') return; try { const key = `${CACHE_KEY_PREFIX}${hashAddress(address)}`; const ttlSeconds = env_1.env.GEOCODING_CACHE_TTL_HOURS * 60 * 60; const cachedResult = { ...result, cachedAt: Date.now(), }; await redis_1.redis.setex(key, ttlSeconds, JSON.stringify(cachedResult)); } catch (err) { logger_1.logger.warn('Failed to cache geocode result:', err); } } // Address abbreviation expansions (comprehensive Canadian/US terms) const abbreviations = { // Street types 'st': 'street', 'ave': 'avenue', 'blvd': 'boulevard', 'dr': 'drive', 'rd': 'road', 'ln': 'lane', 'ct': 'court', 'crt': 'court', 'pl': 'place', 'cres': 'crescent', 'terr': 'terrace', 'hwy': 'highway', 'pkwy': 'parkway', 'sq': 'square', 'mews': 'mews', 'circ': 'circle', 'cir': 'circle', 'way': 'way', 'trail': 'trail', 'grove': 'grove', 'heights': 'heights', 'gdns': 'gardens', 'gdn': 'garden', 'gate': 'gate', 'bay': 'bay', 'close': 'close', 'walk': 'walk', // Directional suffixes 'n': 'north', 'ne': 'northeast', 'e': 'east', 'se': 'southeast', 's': 'south', 'sw': 'southwest', 'w': 'west', 'nw': 'northwest', // Unit types 'apt': 'apartment', 'unit': 'unit', 'ste': 'suite', 'fl': 'floor', 'bldg': 'building', 'dept': 'department', }; /** * Normalize postal code format * K1A0B1 → K1A 0B1 * k1a 0b1 → K1A 0B1 */ function normalizePostalCode(address) { // Canadian postal code pattern: A1A 1A1 or A1A1A1 const postalCodeRegex = /\b([A-Za-z]\d[A-Za-z])\s*(\d[A-Za-z]\d)\b/g; return address.replace(postalCodeRegex, (match, p1, p2) => { return `${p1.toUpperCase()} ${p2.toUpperCase()}`; }); } /** * Normalize address: lowercase, trim whitespace, normalize postal codes */ function normalizeAddress(address, city, province) { let normalized = address.trim().toLowerCase(); // Trim consecutive whitespace normalized = normalized.replace(/\s+/g, ' '); // Normalize postal code before lowercasing normalized = normalizePostalCode(normalized); // Append city/province for better geocoding context (if available) if (city) { normalized += `, ${city.toLowerCase()}`; } if (province) { normalized += `, ${province.toLowerCase()}`; } return normalized; } /** * Expand common address abbreviations */ function expandAbbreviations(address) { let expanded = address.toLowerCase(); for (const [abbr, full] of Object.entries(abbreviations)) { // Case-insensitive replacement with word boundaries const regex = new RegExp(`\\b${abbr}\\b`, 'gi'); expanded = expanded.replace(regex, full); } return expanded; } // Default geographic bounds for filtering const DEFAULT_BOUNDS = { CANADA: { minLat: 41.67, maxLat: 83.11, minLng: -141.00, maxLng: -52.62 }, USA: { minLat: 24.52, maxLat: 49.38, minLng: -125.00, maxLng: -66.93 }, }; // Detect country from address (look for Canadian province codes or "Canada") function detectCountry(address) { const upper = address.toUpperCase(); const canadianProvinces = ['ON', 'QC', 'BC', 'AB', 'MB', 'SK', 'NS', 'NB', 'PE', 'NL', 'NT', 'NU', 'YT']; for (const prov of canadianProvinces) { if (upper.includes(` ${prov} `) || upper.includes(` ${prov},`) || upper.endsWith(` ${prov}`)) { return 'CANADA'; } } if (upper.includes('CANADA')) return 'CANADA'; if (upper.includes('USA') || upper.includes('UNITED STATES')) return 'USA'; return null; } // Adjust confidence based on provider reputation and result quality function adjustConfidence(result) { let confidence = result.rawConfidence; // Provider-specific adjustments based on historical accuracy const providerAdjustment = { [client_1.GeocodeProvider.GOOGLE]: +10, // Most accurate [client_1.GeocodeProvider.MAPBOX]: +5, // High accuracy [client_1.GeocodeProvider.NOMINATIM]: 0, // Baseline [client_1.GeocodeProvider.ARCGIS]: -5, // Less accurate for residential [client_1.GeocodeProvider.PHOTON]: -10, // Least accurate [client_1.GeocodeProvider.LOCATIONIQ]: 0, [client_1.GeocodeProvider.UNKNOWN]: 0, }; confidence += providerAdjustment[result.provider] || 0; // Clamp to 0-100 range return Math.max(0, Math.min(100, confidence)); } function calculateValidationScore(input, returned) { if (!returned) return 50; const inputParts = input.toLowerCase().split(/[\s,]+/).filter(Boolean); const returnedLower = returned.toLowerCase(); let matches = 0; for (const part of inputParts) { if (part.length > 2 && returnedLower.includes(part)) matches++; } return inputParts.length > 0 ? Math.round((matches / inputParts.length) * 100) : 50; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function geocodeGoogleMaps(address, bounds) { if (!env_1.env.GOOGLE_MAPS_API_KEY || env_1.env.GOOGLE_MAPS_ENABLED !== 'true') return null; try { const url = new URL('https://maps.googleapis.com/maps/api/geocode/json'); url.searchParams.set('address', address); url.searchParams.set('key', env_1.env.GOOGLE_MAPS_API_KEY); // Add bounds to bias results toward the specified region if (bounds) { const boundsStr = `${bounds.minLat},${bounds.minLng}|${bounds.maxLat},${bounds.maxLng}`; url.searchParams.set('bounds', boundsStr); } const res = await fetch(url.toString()); if (!res.ok) return null; const data = await res.json(); if (data.status !== 'OK' || !data.results[0]) return null; const result = data.results[0]; const locationType = result.geometry.location_type; // Map Google's location_type to confidence score const confidenceMap = { 'ROOFTOP': 100, // Precise address 'RANGE_INTERPOLATED': 85, // Interpolated between two points 'GEOMETRIC_CENTER': 70, // Center of a polygon (street, neighborhood) 'APPROXIMATE': 50, // Approximate (city, postal code) }; return { latitude: result.geometry.location.lat, longitude: result.geometry.location.lng, rawConfidence: confidenceMap[locationType] || 70, formattedAddress: result.formatted_address, provider: client_1.GeocodeProvider.GOOGLE, }; } catch (err) { logger_1.logger.warn('Google Maps geocode failed:', err); return null; } } async function geocodeNominatim(address, bounds) { try { let url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1&addressdetails=1`; // Add viewbox (bounds) to bias results toward the specified region if (bounds) { const viewbox = `${bounds.minLng},${bounds.minLat},${bounds.maxLng},${bounds.maxLat}`; url += `&viewbox=${viewbox}&bounded=1`; } const res = await fetch(url, { headers: { 'User-Agent': 'ChangemakerLite/2.0 (contact@cmlite.org)' }, }); if (!res.ok) return null; const data = await res.json(); if (!data.length) return null; const item = data[0]; return { latitude: parseFloat(item.lat), longitude: parseFloat(item.lon), rawConfidence: Math.round((item.importance ?? 0.5) * 100), formattedAddress: item.display_name, provider: client_1.GeocodeProvider.NOMINATIM, }; } catch (err) { logger_1.logger.warn('Nominatim geocode failed:', err); return null; } } async function geocodeArcGIS(address, bounds) { try { let url = `https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates?SingleLine=${encodeURIComponent(address)}&f=json&maxLocations=1`; // Add searchExtent (bounds) to filter results to the specified region if (bounds) { const extent = `${bounds.minLng},${bounds.minLat},${bounds.maxLng},${bounds.maxLat}`; url += `&searchExtent=${extent}`; } const res = await fetch(url); if (!res.ok) return null; const data = await res.json(); if (!data.candidates?.length) return null; const item = data.candidates[0]; return { latitude: item.location.y, longitude: item.location.x, rawConfidence: Math.round(item.score), formattedAddress: item.address, provider: client_1.GeocodeProvider.ARCGIS, }; } catch (err) { logger_1.logger.warn('ArcGIS geocode failed:', err); return null; } } async function geocodePhoton(address, bounds) { try { let url = `https://photon.komoot.io/api/?q=${encodeURIComponent(address)}&limit=1`; // Add bbox (bounds) to filter results to the specified region if (bounds) { const bbox = `${bounds.minLng},${bounds.minLat},${bounds.maxLng},${bounds.maxLat}`; url += `&bbox=${bbox}`; } const res = await fetch(url); if (!res.ok) return null; const data = await res.json(); if (!data.features?.length) return null; const feat = data.features[0]; const coords = feat.geometry.coordinates; const props = feat.properties; const formatted = [props.name, props.street, props.city, props.state, props.country].filter(Boolean).join(', '); // Use improved confidence scoring instead of hardcoded 65% // Photon doesn't provide confidence, so estimate based on result completeness let confidence = 50; // Base confidence if (props.name) confidence += 10; if (props.street) confidence += 15; if (props.city) confidence += 10; return { latitude: coords[1], longitude: coords[0], rawConfidence: confidence, formattedAddress: formatted || undefined, provider: client_1.GeocodeProvider.PHOTON, }; } catch (err) { logger_1.logger.warn('Photon geocode failed:', err); return null; } } async function geocodeMapbox(address, bounds) { if (!env_1.env.MAPBOX_API_KEY) return null; try { let url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json?access_token=${env_1.env.MAPBOX_API_KEY}&limit=1`; // Add bbox (bounds) to bias results toward the specified region if (bounds) { const bbox = `${bounds.minLng},${bounds.minLat},${bounds.maxLng},${bounds.maxLat}`; url += `&bbox=${bbox}`; } const res = await fetch(url); if (!res.ok) return null; const data = await res.json(); if (!data.features?.length) return null; const feat = data.features[0]; return { latitude: feat.center[1], longitude: feat.center[0], rawConfidence: Math.round((feat.relevance ?? 0.5) * 100), formattedAddress: feat.place_name, provider: client_1.GeocodeProvider.MAPBOX, }; } catch (err) { logger_1.logger.warn('Mapbox geocode failed:', err); return null; } } exports.geocodingService = { async geocodeBatch(addresses, bounds) { if (env_1.env.GEOCODING_PARALLEL_ENABLED !== 'true') { // Sequential fallback const results = []; for (const addr of addresses) { const result = await this.geocode(addr, bounds); results.push(result); } return results; } // Parallel batch processing const batchSize = env_1.env.GEOCODING_BATCH_SIZE; const results = []; for (let i = 0; i < addresses.length; i += batchSize) { const batch = addresses.slice(i, i + batchSize); const batchPromises = batch.map((addr) => this.geocode(addr, bounds).catch((err) => { logger_1.logger.warn(`Batch geocode failed for address: ${addr}`, err); return null; })); const batchResults = await Promise.allSettled(batchPromises); const resolvedResults = batchResults.map((r) => r.status === 'fulfilled' ? r.value : null); results.push(...resolvedResults); // Respect rate limits: delay between batches if (i + batchSize < addresses.length) { await sleep(env_1.env.GEOCODING_RATE_LIMIT_MS); } } return results; }, async geocode(address, bounds) { // Check Redis cache const normalizedAddress = normalizeAddress(address); const cached = await getCachedResult(normalizedAddress); if (cached) { return cached; } // Auto-detect country and apply default bounds if not provided if (!bounds) { const detectedCountry = detectCountry(address); if (detectedCountry) { bounds = DEFAULT_BOUNDS[detectedCountry]; } } // Try address variations const variations = [address, expandAbbreviations(address)]; // Remove duplicates const uniqueVariations = [...new Set(variations.map((v) => v.trim()))]; // Provider chain: Google Maps first (if enabled), then fallbacks const providers = [ geocodeGoogleMaps, geocodeNominatim, geocodeArcGIS, geocodePhoton, geocodeMapbox, ]; for (const variation of uniqueVariations) { for (const provider of providers) { const result = await provider(variation, bounds); if (result) { // Apply provider-specific confidence adjustments const adjustedConfidence = adjustConfidence(result); const validationScore = calculateValidationScore(address, result.formattedAddress); const combinedConfidence = Math.round((adjustedConfidence + validationScore) / 2); const geocodeResult = { latitude: result.latitude, longitude: result.longitude, confidence: combinedConfidence, provider: result.provider, formattedAddress: result.formattedAddress, }; // Cache the result in Redis await setCachedResult(normalizedAddress, geocodeResult); if (combinedConfidence >= 85) { return geocodeResult; } // If confidence is decent but not great, keep looking // but store as best so far let bestResult = geocodeResult; // Try remaining providers for this variation const providerIndex = providers.indexOf(provider); for (let i = providerIndex + 1; i < providers.length; i++) { await sleep(env_1.env.GEOCODING_RATE_LIMIT_MS); const altResult = await providers[i](variation, bounds); if (altResult) { const altAdjusted = adjustConfidence(altResult); const altValidation = calculateValidationScore(address, altResult.formattedAddress); const altConfidence = Math.round((altAdjusted + altValidation) / 2); if (altConfidence > bestResult.confidence) { bestResult = { latitude: altResult.latitude, longitude: altResult.longitude, confidence: altConfidence, provider: altResult.provider, formattedAddress: altResult.formattedAddress, }; } if (altConfidence >= 85) break; } } // Cache the best result in Redis await setCachedResult(normalizedAddress, bestResult); return bestResult; } await sleep(env_1.env.GEOCODING_RATE_LIMIT_MS); } } return null; }, async reverseGeocode(lat, lng) { try { const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`; const res = await fetch(url, { headers: { 'User-Agent': 'ChangemakerLite/2.0 (contact@cmlite.org)' }, }); if (!res.ok) return null; const data = await res.json(); return { address: data.display_name, city: data.address?.city || data.address?.town || data.address?.village, province: data.address?.state, country: data.address?.country, }; } catch (err) { logger_1.logger.warn('Reverse geocode failed:', err); return null; } }, async search(query, limit = 5) { try { const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=${limit}&addressdetails=1`; const res = await fetch(url, { headers: { 'User-Agent': 'ChangemakerLite/2.0 (contact@cmlite.org)' }, }); if (!res.ok) return []; const data = await res.json(); return data.map((item) => ({ latitude: parseFloat(item.lat), longitude: parseFloat(item.lon), displayName: item.display_name, type: item.type || item.class || 'place', provider: 'nominatim', })); } catch (err) { logger_1.logger.warn('Nominatim search failed:', err); return []; } }, async clearCache() { if (env_1.env.GEOCODING_CACHE_ENABLED !== 'true') return; try { // Find all cache keys and delete them const keys = await redis_1.redis.keys(`${CACHE_KEY_PREFIX}*`); if (keys.length > 0) { await redis_1.redis.del(...keys); logger_1.logger.info(`Cleared ${keys.length} geocoding cache entries`); } } catch (err) { logger_1.logger.error('Failed to clear geocoding cache:', err); throw err; } }, // Add method to get cache statistics async getCacheStats() { try { const keys = await redis_1.redis.keys(`${CACHE_KEY_PREFIX}*`); const hitsMetric = await metrics_1.cm_geocode_cache_hits.get(); const missesMetric = await metrics_1.cm_geocode_cache_misses.get(); return { keys: keys.length, hits: hitsMetric.values[0]?.value || 0, misses: missesMetric.values[0]?.value || 0, }; } catch (err) { logger_1.logger.error('Failed to get cache stats:', err); return { keys: 0, hits: 0, misses: 0 }; } }, }; //# sourceMappingURL=geocoding.service.js.map