lat and long

This commit is contained in:
2026-08-27 13:59:16 +05:30
parent dc9c049bb9
commit edb9c2803f
2 changed files with 332 additions and 1 deletions

271
utils/geocode.go Normal file
View File

@@ -0,0 +1,271 @@
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)
}