From edb9c2803f398d3ec9740fec39191a5fbc343389 Mon Sep 17 00:00:00 2001 From: abhishek Date: Thu, 27 Aug 2026 13:59:16 +0530 Subject: [PATCH] lat and long --- services/tenantService.go | 62 ++++++++- utils/geocode.go | 271 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 utils/geocode.go diff --git a/services/tenantService.go b/services/tenantService.go index 800a99f..19e4226 100644 --- a/services/tenantService.go +++ b/services/tenantService.go @@ -2,9 +2,12 @@ package services import ( "errors" + "net/http" + "strings" + "nearle/models" "nearle/repositories" - "net/http" + "nearle/utils" ) type TenantService interface { @@ -107,6 +110,14 @@ func (s *tenantService) UpdateStaff(user models.User) error { } func (s *tenantService) CreateTenantLocation(data models.Tenantlocations) map[string]interface{} { + // A branch needs a location for the same reasons a tenant does — delivery + // radius is stored on this very row. Same rule: only when the caller sent + // nothing, and never fatal. + if strings.TrimSpace(data.Latitude) == "" && strings.TrimSpace(data.Longitude) == "" { + data.Latitude, data.Longitude = utils.GeocodeAddress( + data.Address, data.Suburb, data.City, data.State, data.Postcode) + } + created, err := s.repo.CreateTenantLocation(data) if err != nil { return map[string]interface{}{ @@ -151,6 +162,18 @@ func (s *tenantService) CreateTenantUser(data models.Tenants) (models.UserInfo, return models.UserInfo{}, errors.New("Tenant Already Exists") } + // Coordinates from the address, for the tenant and its primary outlet. + // + // `tenants.latitude` and `tenants.longitude` have existed since the schema + // was written and no caller has ever filled them, so every shop on the + // platform sits at no location at all. Resolved here rather than in the + // console because the key stays server-side and because a merchant created + // by any other client — the mobile app, a script — gets the same treatment. + // + // Only when the caller sent nothing: a console that lets an operator drag a + // pin is stating something a geocoder cannot know, and must win. + fillTenantCoordinates(&data) + // ✅ Create Tenant User status, err := s.repo.CreateTenantUser(data) if err != nil || !status { @@ -169,3 +192,40 @@ func (s *tenantService) GetTenantByID(tid int, locationid int, userid int) (mode func (s *tenantService) GetTenantByKeyword(keyword string) ([]models.TenantSearch, error) { return s.repo.GetTenantByKeyword(keyword) } + +// fillTenantCoordinates geocodes a tenant and its nested primary outlet. +// +// Never returns an error and never blocks creation. A geocoder that is down, +// slow or rate-limited leaves the columns exactly as they are today — empty — +// and a merchant is still onboarded. The alternative, refusing to create a shop +// because a third party did not answer, trades a fixable blank field for a +// person stuck on the phone. +func fillTenantCoordinates(data *models.Tenants) { + if strings.TrimSpace(data.Latitude) == "" && strings.TrimSpace(data.Longitude) == "" { + data.Latitude, data.Longitude = utils.GeocodeAddress( + data.Address, data.Suburb, data.City, data.State, data.Postcode) + } + + outlet := &data.Tenantlocations + if strings.TrimSpace(outlet.Latitude) != "" || strings.TrimSpace(outlet.Longitude) != "" { + return + } + + // The first outlet is almost always at the address just typed, and the + // console already copies it across. Reuse the tenant's answer rather than + // asking twice for the same string — that is a second network round trip + // inside a request someone is waiting on, for a guaranteed identical result. + if sameAddress(*data) { + outlet.Latitude, outlet.Longitude = data.Latitude, data.Longitude + return + } + outlet.Latitude, outlet.Longitude = utils.GeocodeAddress( + outlet.Address, outlet.Suburb, outlet.City, outlet.State, outlet.Postcode) +} + +func sameAddress(data models.Tenants) bool { + outlet := data.Tenantlocations + return strings.EqualFold(strings.TrimSpace(outlet.Address), strings.TrimSpace(data.Address)) && + strings.EqualFold(strings.TrimSpace(outlet.City), strings.TrimSpace(data.City)) && + strings.EqualFold(strings.TrimSpace(outlet.Postcode), strings.TrimSpace(data.Postcode)) +} diff --git a/utils/geocode.go b/utils/geocode.go new file mode 100644 index 0000000..4d206d9 --- /dev/null +++ b/utils/geocode.go @@ -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) +}