Add 5000ms timeout to Places Text Search API call in searchStationByName. Timeout errors log a warning instead of error and return null gracefully. Add timeout test case to station-matching unit tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
233 lines
7.2 KiB
TypeScript
233 lines
7.2 KiB
TypeScript
/**
|
|
* @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<Station[]> {
|
|
const cacheKey = `stations:${latitude.toFixed(4)},${longitude.toFixed(4)},${radius}`;
|
|
|
|
try {
|
|
// Check cache
|
|
const cached = await cacheService.get<Station[]>(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<GooglePlacesResponse>(
|
|
`${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<Station | null> {
|
|
const query = `${merchantName} gas station`;
|
|
const cacheKey = `station-match:${query.toLowerCase().trim()}`;
|
|
|
|
try {
|
|
const cached = await cacheService.get<Station | null>(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<GoogleTextSearchResponse>(
|
|
`${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<Buffer> {
|
|
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(); |