lat and long
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
271
utils/geocode.go
Normal file
271
utils/geocode.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user