changemaker.lite/api/dist/modules/map/geocoding/geocoding.service.js

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