Files
motovaultpro/backend/src/features/stations/external/google-maps/google-maps.client.ts
Eric Gullickson 4e5da4782f feat: add 5s timeout and warning log for station name search (refs #141)
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>
2026-02-11 13:03:35 -06:00

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();