554 lines
21 KiB
JavaScript
554 lines
21 KiB
JavaScript
"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
|