MVP Build

This commit is contained in:
Eric Gullickson
2025-08-09 12:47:15 -05:00
parent 2e8816df7f
commit 8f5117a4e2
92 changed files with 5910 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
/**
* @ai-summary Google Maps client for station discovery
* @ai-context Searches for gas stations and caches results
*/
import axios from 'axios';
import { env } from '../../../../core/config/environment';
import { logger } from '../../../../core/logging/logger';
import { cacheService } from '../../../../core/config/redis';
import { GooglePlacesResponse, GooglePlace } from './google-maps.types';
import { Station } from '../../domain/stations.types';
export class GoogleMapsClient {
private readonly apiKey = env.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 {
// Calculate distance from search point
const distance = this.calculateDistance(
searchLat,
searchLng,
place.geometry.location.lat,
place.geometry.location.lng
);
// Generate photo URL if available
let photoUrl: string | undefined;
if (place.photos && place.photos.length > 0) {
photoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photo_reference=${place.photos[0].photo_reference}&key=${this.apiKey}`;
}
return {
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,
isOpen: place.opening_hours?.open_now,
rating: place.rating,
photoUrl
};
}
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();