package service import ( "bugulma/backend/internal/domain" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" ) // GeocodingService provides geocoding capabilities using Google Maps API type GeocodingService struct { apiKey string httpClient *http.Client } // GeocodeResponse represents the response from Google Geocoding API type GeocodeResponse struct { Results []GeocodeResult `json:"results"` Status string `json:"status"` } // GeocodeResult represents a single geocoding result type GeocodeResult struct { AddressComponents []AddressComponent `json:"address_components"` FormattedAddress string `json:"formatted_address"` Geometry Geometry `json:"geometry"` PlaceID string `json:"place_id"` Types []string `json:"types"` } // AddressComponent represents an address component type AddressComponent struct { LongName string `json:"long_name"` ShortName string `json:"short_name"` Types []string `json:"types"` } // Geometry represents the geometry of a geocoding result type Geometry struct { Location Location `json:"location"` LocationType string `json:"location_type"` Viewport Bounds `json:"viewport"` Bounds Bounds `json:"bounds"` } // Location represents lat/lng coordinates type Location struct { Lat float64 `json:"lat"` Lng float64 `json:"lng"` } // Bounds represents a bounding box type Bounds struct { Northeast Location `json:"northeast"` Southwest Location `json:"southwest"` } // GeocodeResult represents the result of geocoding type GeocodeCoordinates struct { Latitude float64 Longitude float64 Address string PlaceID string } // NewGeocodingService creates a new geocoding service func NewGeocodingService(apiKey string) *GeocodingService { return &GeocodingService{ apiKey: apiKey, httpClient: &http.Client{ Timeout: 10 * time.Second, }, } } // GeocodeAddress geocodes an address string to coordinates func (s *GeocodingService) GeocodeAddress(ctx context.Context, address string) (*GeocodeCoordinates, error) { if s.apiKey == "" { return nil, fmt.Errorf("google maps API key not configured") } if address == "" { return nil, fmt.Errorf("address cannot be empty") } // Build request URL baseURL := "https://maps.googleapis.com/maps/api/geocode/json" params := url.Values{} params.Add("address", address) params.Add("key", s.apiKey) params.Add("language", "ru") // Russian language for results reqURL := fmt.Sprintf("%s?%s", baseURL, params.Encode()) // Create request req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } // Make request resp, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("geocoding API returned status %d: %s", resp.StatusCode, string(body)) } // Parse response var geocodeResp GeocodeResponse if err := json.NewDecoder(resp.Body).Decode(&geocodeResp); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } if geocodeResp.Status != "OK" && geocodeResp.Status != "ZERO_RESULTS" { return nil, fmt.Errorf("geocoding API error: %s", geocodeResp.Status) } if len(geocodeResp.Results) == 0 { return nil, fmt.Errorf("no results found for address: %s", address) } // Use first result result := geocodeResp.Results[0] return &GeocodeCoordinates{ Latitude: result.Geometry.Location.Lat, Longitude: result.Geometry.Location.Lng, Address: result.FormattedAddress, PlaceID: result.PlaceID, }, nil } // ReverseGeocode performs reverse geocoding (coordinates to address) func (s *GeocodingService) ReverseGeocode(ctx context.Context, lat, lng float64) (*GeocodeCoordinates, error) { if s.apiKey == "" { return nil, fmt.Errorf("google maps API key not configured") } // Build request URL baseURL := "https://maps.googleapis.com/maps/api/geocode/json" params := url.Values{} params.Add("latlng", fmt.Sprintf("%f,%f", lat, lng)) params.Add("key", s.apiKey) params.Add("language", "ru") // Russian language for results reqURL := fmt.Sprintf("%s?%s", baseURL, params.Encode()) // Create request req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } // Make request resp, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("reverse geocoding API returned status %d: %s", resp.StatusCode, string(body)) } // Parse response var geocodeResp GeocodeResponse if err := json.NewDecoder(resp.Body).Decode(&geocodeResp); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } if geocodeResp.Status != "OK" && geocodeResp.Status != "ZERO_RESULTS" { return nil, fmt.Errorf("reverse geocoding API error: %s", geocodeResp.Status) } if len(geocodeResp.Results) == 0 { return nil, fmt.Errorf("no results found for coordinates: %f,%f", lat, lng) } // Use first result result := geocodeResp.Results[0] return &GeocodeCoordinates{ Latitude: lat, Longitude: lng, Address: result.FormattedAddress, PlaceID: result.PlaceID, }, nil } // EnrichAddressWithCoordinates enriches an address with coordinates if missing func (s *GeocodingService) EnrichAddressWithCoordinates(ctx context.Context, address *domain.Address) error { // Skip if already has coordinates if address.Latitude != 0 && address.Longitude != 0 { return nil } // Build address string from components addressStr := address.FormattedRu if addressStr == "" { addressStr = address.FormattedEn } if addressStr == "" { // Build from components parts := []string{} if address.Street != "" { parts = append(parts, address.Street) } if address.City != "" { parts = append(parts, address.City) } if address.Region != "" { parts = append(parts, address.Region) } if len(parts) == 0 { return fmt.Errorf("address has no usable components for geocoding") } addressStr = fmt.Sprintf("%s, Россия", fmt.Sprintf("%s", parts[0])) if len(parts) > 1 { addressStr = fmt.Sprintf("%s, %s, Россия", parts[0], parts[1]) } } // Geocode coords, err := s.GeocodeAddress(ctx, addressStr) if err != nil { return fmt.Errorf("failed to geocode address: %w", err) } // Update address address.Latitude = coords.Latitude address.Longitude = coords.Longitude if address.FormattedRu == "" && coords.Address != "" { address.FormattedRu = coords.Address } return nil } // EnrichOrganizationWithCoordinates enriches an organization with coordinates from its addresses func (s *GeocodingService) EnrichOrganizationWithCoordinates(ctx context.Context, org *domain.Organization) error { // Skip if already has coordinates if org.Latitude != 0 && org.Longitude != 0 { return nil } // Try to get coordinates from primary address if len(org.Addresses) > 0 { // Find headquarters address first for i := range org.Addresses { addr := &org.Addresses[i] if addr.AddressType == domain.AddressTypeHeadquarters { if addr.Latitude != 0 && addr.Longitude != 0 { org.Latitude = addr.Latitude org.Longitude = addr.Longitude return nil } // Try to geocode it if err := s.EnrichAddressWithCoordinates(ctx, addr); err == nil { org.Latitude = addr.Latitude org.Longitude = addr.Longitude return nil } } } // Fallback to first address with coordinates for i := range org.Addresses { addr := &org.Addresses[i] if addr.Latitude != 0 && addr.Longitude != 0 { org.Latitude = addr.Latitude org.Longitude = addr.Longitude return nil } } // Try to geocode first address if len(org.Addresses) > 0 { addr := &org.Addresses[0] if err := s.EnrichAddressWithCoordinates(ctx, addr); err == nil { org.Latitude = addr.Latitude org.Longitude = addr.Longitude return nil } } } return fmt.Errorf("no addresses available for organization %s", org.ID) }