/** * @ai-summary Google Maps client for station discovery * @ai-context Searches for gas stations and caches results */ import axios from 'axios'; import { appConfig } from '../../../../core/config/config-loader'; import { logger } from '../../../../core/logging/logger'; import { cacheService } from '../../../../core/config/redis'; import { GooglePlacesResponse, GoogleTextSearchResponse, GooglePlace } from './google-maps.types'; import { Station } from '../../domain/stations.types'; export class GoogleMapsClient { private readonly apiKey = appConfig.secrets.google_maps_api_key; private readonly baseURL = 'https://maps.googleapis.com/maps/api/place'; private readonly cacheTTL = 3600; // 1 hour async searchNearbyStations( latitude: number, longitude: number, radius: number = 5000 ): Promise { const cacheKey = `stations:${latitude.toFixed(4)},${longitude.toFixed(4)},${radius}`; try { // Check cache const cached = await cacheService.get(cacheKey); if (cached) { logger.debug('Station search cache hit', { latitude, longitude }); return cached; } // Search Google Places logger.info('Searching Google Places for stations', { latitude, longitude, radius }); const response = await axios.get( `${this.baseURL}/nearbysearch/json`, { params: { location: `${latitude},${longitude}`, radius, type: 'gas_station', key: this.apiKey } } ); if (response.data.status !== 'OK' && response.data.status !== 'ZERO_RESULTS') { throw new Error(`Google Places API error: ${response.data.status}`); } // Transform results const stations = response.data.results.map(place => this.transformPlaceToStation(place, latitude, longitude) ); // Cache results await cacheService.set(cacheKey, stations, this.cacheTTL); return stations; } catch (error) { logger.error('Station search failed', { error, latitude, longitude }); throw error; } } private transformPlaceToStation(place: GooglePlace, searchLat: number, searchLng: number): Station { const distance = this.calculateDistance( searchLat, searchLng, place.geometry.location.lat, place.geometry.location.lng ); // Store photo reference instead of full URL to avoid exposing API key let photoReference: string | undefined; if (place.photos && place.photos.length > 0 && place.photos[0]) { photoReference = place.photos[0].photo_reference; } const station: Station = { id: place.place_id, placeId: place.place_id, name: place.name, address: place.vicinity, latitude: place.geometry.location.lat, longitude: place.geometry.location.lng, distance }; if (photoReference !== undefined) { station.photoReference = photoReference; } if (place.opening_hours?.open_now !== undefined) { station.isOpen = place.opening_hours.open_now; } if (place.rating !== undefined) { station.rating = place.rating; } return station; } /** * Search for a gas station by merchant name using Google Places Text Search API. * Used to match receipt merchant names (e.g. "Shell", "COSTCO #123") to actual stations. */ async searchStationByName(merchantName: string): Promise { const query = `${merchantName} gas station`; const cacheKey = `station-match:${query.toLowerCase().trim()}`; try { const cached = await cacheService.get(cacheKey); if (cached !== undefined && cached !== null) { logger.debug('Station name match cache hit', { merchantName }); return cached; } logger.info('Searching Google Places Text Search for station', { merchantName, query }); const response = await axios.get( `${this.baseURL}/textsearch/json`, { params: { query, type: 'gas_station', key: this.apiKey, }, timeout: 5000, } ); if (response.data.status !== 'OK' && response.data.status !== 'ZERO_RESULTS') { throw new Error(`Google Places Text Search API error: ${response.data.status}`); } if (response.data.results.length === 0) { await cacheService.set(cacheKey, null, this.cacheTTL); return null; } const topResult = response.data.results[0]; const station = this.transformTextSearchResult(topResult); await cacheService.set(cacheKey, station, this.cacheTTL); return station; } catch (error: any) { if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) { logger.warn('Station name search timed out', { merchantName, timeoutMs: 5000 }); } else { logger.error('Station name search failed', { error, merchantName }); } return null; } } private transformTextSearchResult(place: GooglePlace): Station { let photoReference: string | undefined; if (place.photos && place.photos.length > 0 && place.photos[0]) { photoReference = place.photos[0].photo_reference; } // Text Search returns formatted_address instead of vicinity const address = (place as any).formatted_address || place.vicinity || ''; const station: Station = { id: place.place_id, placeId: place.place_id, name: place.name, address, latitude: place.geometry.location.lat, longitude: place.geometry.location.lng, }; if (photoReference !== undefined) { station.photoReference = photoReference; } if (place.opening_hours?.open_now !== undefined) { station.isOpen = place.opening_hours.open_now; } if (place.rating !== undefined) { station.rating = place.rating; } return station; } /** * Fetch photo from Google Maps API using photo reference * Used by photo proxy endpoint to serve photos without exposing API key */ async fetchPhoto(photoReference: string, maxWidth: number = 400): Promise { try { const response = await axios.get( `${this.baseURL}/photo`, { params: { photo_reference: photoReference, maxwidth: maxWidth, key: this.apiKey }, responseType: 'arraybuffer' } ); return Buffer.from(response.data); } catch (error) { logger.error('Failed to fetch photo from Google Maps', { error, photoReference }); throw error; } } private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371e3; // Earth's radius in meters const φ1 = lat1 * Math.PI / 180; const φ2 = lat2 * Math.PI / 180; const Δφ = (lat2 - lat1) * Math.PI / 180; const Δλ = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ/2) * Math.sin(Δλ/2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); return Math.round(R * c); // Distance in meters } } export const googleMapsClient = new GoogleMapsClient();