package utils import ( "context" "encoding/json" "fmt" "log" "net/http" "net/url" "os" "strconv" "strings" "sync" "time" ) // Turning a typed address into coordinates. // // `tenants` and `tenantlocations` have carried `latitude` and `longitude` since // the schema was written, and nothing has ever filled them: the console sends // the strings it was given, which are empty, and they are stored empty. Every // feature that needs to know where a shop is — delivery radius, "shops near // me", a map of the estate — is waiting on this. // // ── Two providers, one switch ──────────────────────────────────────────────── // // With GEOCODER_API_KEY set, Google's Geocoding API. Without it, OpenStreetMap's // Nominatim, which needs no key and no account. Nominatim is good at city and // postcode level in India and patchier for one shop's street address, so the // key is worth setting eventually — but the absence of one must not be the // reason this ships never. Set the key and the next tenant onboarded is // geocoded by Google; nothing else changes. // // ── Never fatal, ever ──────────────────────────────────────────────────────── // // Every path here returns empty strings rather than an error the caller has to // decide about. Onboarding a merchant must not fail because a third-party // geocoder was slow, rate-limited or down — an outlet with no coordinates is a // row someone can fix later, and an outlet that could not be created at all is // a person on the phone. The caller writes whatever comes back and carries on. const ( // Nominatim's usage policy asks for at most one request a second and a // User-Agent that identifies the application. Both are honoured below. // Onboarding is a handful of merchants a day, so the ceiling is irrelevant // in practice — it is respected because it is the condition of use. nominatimMinInterval = 1100 * time.Millisecond // Short. This sits inside the request that creates a merchant, and a person // is watching a spinner: better to save without coordinates than to make // them wait on a geocoder having a bad minute. geocodeTimeout = 6 * time.Second ) var nominatimGate struct { mu sync.Mutex last time.Time } // GeocodeAddress resolves a postal address to latitude and longitude. // // Returns two empty strings when the address is too thin to be worth sending, // when the provider finds nothing, or when anything at all goes wrong. Callers // are expected to store the result unconditionally; empty means "not known", // which is exactly what the column already holds. // // Strings, not floats, because that is what the columns are — see // `models.Tenants`. Converting here would only mean converting back. func GeocodeAddress(address, suburb, city, state, postcode string) (lat, lng string) { query := buildQuery(address, suburb, city, state, postcode) if query == "" { return "", "" } ctx, cancel := context.WithTimeout(context.Background(), geocodeTimeout) defer cancel() if key := strings.TrimSpace(os.Getenv("GEOCODER_API_KEY")); key != "" { return geocodeGoogle(ctx, query, key) } return geocodeNominatim(ctx, query) } // buildQuery assembles the one-line address the providers want. // // Empty parts are dropped rather than left as stray commas, and a query with // nothing but a country in it is refused: "India" geocodes successfully to the // middle of the country, and a shop silently pinned to a field in Madhya // Pradesh is worse than a shop with no pin at all, because only one of the two // looks wrong. func buildQuery(address, suburb, city, state, postcode string) string { parts := make([]string, 0, 6) for _, part := range []string{address, suburb, city, state, postcode} { if trimmed := strings.TrimSpace(part); trimmed != "" { parts = append(parts, trimmed) } } // A postcode alone is enough; a city alone is enough. A single fragment // that is neither — a house number, say — is not. if len(parts) == 0 { return "" } if len(parts) == 1 && strings.TrimSpace(city) == "" && strings.TrimSpace(postcode) == "" { return "" } parts = append(parts, "India") return strings.Join(parts, ", ") } /* ── Google ───────────────────────────────────────────────────────────────── */ type googleGeocodeResponse struct { Status string `json:"status"` Results []struct { Geometry struct { Location struct { Lat float64 `json:"lat"` Lng float64 `json:"lng"` } `json:"location"` } `json:"geometry"` } `json:"results"` ErrorMessage string `json:"error_message"` } func geocodeGoogle(ctx context.Context, query, key string) (string, string) { endpoint := "https://maps.googleapis.com/maps/api/geocode/json?" + url.Values{ "address": {query}, // Biases results towards India without excluding anything, which // matters for a name like "Gandhipuram" that also exists elsewhere. "region": {"in"}, "key": {key}, }.Encode() body, err := fetch(ctx, endpoint, false) if err != nil { log.Printf("geocode: google request failed: %v", err) return "", "" } var payload googleGeocodeResponse if err := json.Unmarshal(body, &payload); err != nil { log.Printf("geocode: google returned unreadable JSON: %v", err) return "", "" } // ZERO_RESULTS is an answer, not a fault — do not log it as one. The others // are configuration problems a person has to fix, so they get named. switch payload.Status { case "OK": case "ZERO_RESULTS": return "", "" default: log.Printf("geocode: google says %s — %s", payload.Status, payload.ErrorMessage) return "", "" } if len(payload.Results) == 0 { return "", "" } loc := payload.Results[0].Geometry.Location return format(loc.Lat), format(loc.Lng) } /* ── Nominatim ────────────────────────────────────────────────────────────── */ type nominatimResult struct { Lat string `json:"lat"` Lon string `json:"lon"` } func geocodeNominatim(ctx context.Context, query string) (string, string) { endpoint := "https://nominatim.openstreetmap.org/search?" + url.Values{ "q": {query}, "format": {"json"}, "limit": {"1"}, "countrycodes": {"in"}, "addressdetails": {"0"}, }.Encode() // One request a second, across every goroutine. Held for the duration of // the call rather than just the gap, because two concurrent requests would // otherwise both pass a gate that only measures the time since the last // one STARTED. nominatimGate.mu.Lock() if wait := nominatimMinInterval - time.Since(nominatimGate.last); wait > 0 { select { case <-time.After(wait): case <-ctx.Done(): nominatimGate.mu.Unlock() return "", "" } } body, err := fetch(ctx, endpoint, true) nominatimGate.last = time.Now() nominatimGate.mu.Unlock() if err != nil { log.Printf("geocode: nominatim request failed: %v", err) return "", "" } var results []nominatimResult if err := json.Unmarshal(body, &results); err != nil { log.Printf("geocode: nominatim returned unreadable JSON: %v", err) return "", "" } if len(results) == 0 { return "", "" } // Returned as strings already, but parsed and reformatted so both providers // store the same shape and precision rather than whichever the upstream // happened to send. lat, errLat := strconv.ParseFloat(results[0].Lat, 64) lng, errLng := strconv.ParseFloat(results[0].Lon, 64) if errLat != nil || errLng != nil { return "", "" } return format(lat), format(lng) } /* ── Shared ───────────────────────────────────────────────────────────────── */ func fetch(ctx context.Context, endpoint string, identify bool) ([]byte, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } if identify { // Nominatim's policy requires an identifying User-Agent and rejects // requests without one. Fair: it is a free service run on donations. request.Header.Set("User-Agent", "NearleFiesta/1.0 (care@nearle.in)") } request.Header.Set("Accept", "application/json") response, err := http.DefaultClient.Do(request) if err != nil { return nil, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d", response.StatusCode) } buffer := make([]byte, 0, 4096) chunk := make([]byte, 4096) for { n, err := response.Body.Read(chunk) if n > 0 { buffer = append(buffer, chunk[:n]...) } // A geocoder answering with megabytes is a geocoder that is not // answering the question asked. if len(buffer) > 1<<20 { return nil, fmt.Errorf("response too large") } if err != nil { break } } return buffer, nil } // format renders a coordinate at six decimal places — about 11cm, which is // finer than any address-level geocoder is actually accurate to, and stops the // column holding `12.99999999999998`. func format(value float64) string { return strconv.FormatFloat(value, 'f', 6, 64) }