package controllers import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "time" "doormile/config" "doormile/db" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" ) // Places — §6 of the contract. // // Both endpoints proxy a geocoder rather than handing the app a key. The legacy // rider app shipped a Google Maps key inside the binary; it was extracted and // had to be revoked, and that side now runs on OSM/OSRM with no key at all. The // customer app is never given one: it asks Doormile, Doormile asks the // geocoder, and the answer is cached so the same street typed a hundred times // costs one upstream call. const ( // cxGeocodeTimeout is short on purpose. The pickup point already has a // device-supplied coordinate; a slow geocoder should degrade the label, not // stall the booking form. cxGeocodeTimeout = 4 * time.Second // cxGeocodeCacheTTL — addresses do not move. A week is conservative. cxGeocodeCacheTTL = 7 * 24 * time.Hour cxSearchCacheTTL = 24 * time.Hour cxMaxSearchResults = 6 // cxRecentPlacesShown is what the search sheet opens on: the customer's own // recent and saved places, capped by the design. cxRecentPlacesShown = 4 ) // cxPlace is the one shape a place is returned in, from search, from reverse // geocode and from the saved-address list. title is a short label the UI puts // on one line; sub is the full address under it. Neither is ever null — the // client types both as non-nullable strings. type cxPlace struct { Title string `json:"title"` Sub string `json:"sub"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` } // ReverseGeocodeCx turns the device's coordinates into the pickup label. func ReverseGeocodeCx(cfg *config.Config) fiber.Handler { return func(c *fiber.Ctx) error { lat, latErr := strconv.ParseFloat(c.Query("lat"), 64) lng, lngErr := strconv.ParseFloat(c.Query("lng"), 64) if latErr != nil || lngErr != nil || (lat == 0 && lng == 0) { return utils.CxBadRequest(c, "We need a location to look up") } cacheKey := fmt.Sprintf("cx:geo:rev:%.5f:%.5f", lat, lng) if cached, ok := cxCacheGet(cacheKey); ok { var place cxPlace if json.Unmarshal([]byte(cached), &place) == nil { return utils.CxOK(c, place) } } place, err := cxReverseGeocode(cfg, lat, lng) if err != nil { utils.Warn("ReverseGeocodeCx: upstream failed", "error", err) // A label the customer can correct beats a blocked booking form. // The coordinates are what the rider actually navigates to; the // text is what the customer reads, and they can edit it. return utils.CxOK(c, cxPlace{ Title: "Selected location", Sub: fmt.Sprintf("%.5f, %.5f", lat, lng), Lat: lat, Lng: lng, }) } if data, merr := json.Marshal(place); merr == nil { cxCacheSet(cacheKey, string(data), cxGeocodeCacheTTL) } return utils.CxOK(c, place) } } // SearchCxPlaces backs the pickup-point search sheet. // // An empty query is not an error: the sheet opens on it, and answers with the // customer's own saved and recently used places. func SearchCxPlaces(cfg *config.Config) fiber.Handler { return func(c *fiber.Ctx) error { q := strings.TrimSpace(c.Query("q")) lat, _ := strconv.ParseFloat(c.Query("lat"), 64) lng, _ := strconv.ParseFloat(c.Query("lng"), 64) if q == "" { customerID, _ := c.Locals("userid").(int) places := cxRecentPlaces(customerID) return utils.CxList(c, places, len(places), nil) } cacheKey := fmt.Sprintf("cx:geo:q:%s:%.2f:%.2f", strings.ToLower(q), lat, lng) if cached, ok := cxCacheGet(cacheKey); ok { var places []cxPlace if json.Unmarshal([]byte(cached), &places) == nil { return utils.CxList(c, places, len(places), nil) } } places, err := cxSearchPlaces(cfg, q, lat, lng) if err != nil { utils.Warn("SearchCxPlaces: upstream failed", "error", err) // An empty list is a designed state in the sheet ("no matches"); // an error is a retry button in the middle of a booking. return utils.CxList(c, []cxPlace{}, 0, nil) } if data, merr := json.Marshal(places); merr == nil { cxCacheSet(cacheKey, string(data), cxSearchCacheTTL) } return utils.CxList(c, places, len(places), nil) } } // ── Upstream ───────────────────────────────────────────────────────────────── type nominatimPlace struct { DisplayName string `json:"display_name"` Lat string `json:"lat"` Lon string `json:"lon"` Name string `json:"name"` Address struct { Road string `json:"road"` HouseNumber string `json:"house_number"` Neighbourhood string `json:"neighbourhood"` Suburb string `json:"suburb"` City string `json:"city"` Town string `json:"town"` Village string `json:"village"` StateDistrict string `json:"state_district"` State string `json:"state"` Postcode string `json:"postcode"` } `json:"address"` } func cxReverseGeocode(cfg *config.Config, lat, lng float64) (cxPlace, error) { endpoint := fmt.Sprintf("%s/reverse?format=jsonv2&lat=%f&lon=%f&addressdetails=1&zoom=18", strings.TrimRight(cfg.GeocoderURL, "/"), lat, lng) var place nominatimPlace if err := cxGeocoderGet(cfg, endpoint, &place); err != nil { return cxPlace{}, err } return cxPlaceFrom(place, lat, lng), nil } func cxSearchPlaces(cfg *config.Config, q string, lat, lng float64) ([]cxPlace, error) { endpoint := fmt.Sprintf("%s/search?format=jsonv2&addressdetails=1&limit=%d&countrycodes=in&q=%s", strings.TrimRight(cfg.GeocoderURL, "/"), cxMaxSearchResults, url.QueryEscape(q)) // Bias to where the customer is. A "Brookefields" typed in Coimbatore must // not answer with one in another state first. if lat != 0 || lng != 0 { const box = 0.75 // degrees, roughly 80km endpoint += fmt.Sprintf("&viewbox=%f,%f,%f,%f&bounded=0", lng-box, lat+box, lng+box, lat-box) } var raw []nominatimPlace if err := cxGeocoderGet(cfg, endpoint, &raw); err != nil { return nil, err } places := make([]cxPlace, 0, len(raw)) for _, r := range raw { plat, _ := strconv.ParseFloat(r.Lat, 64) plng, _ := strconv.ParseFloat(r.Lon, 64) places = append(places, cxPlaceFrom(r, plat, plng)) } return places, nil } func cxGeocoderGet(cfg *config.Config, endpoint string, out interface{}) error { ctx, cancel := context.WithTimeout(context.Background(), cxGeocodeTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return err } // Nominatim's usage policy requires an identifiable caller; anonymous // traffic gets throttled or blocked outright. agent := "doormile-backend/1.0" if cfg.GeocoderEmail != "" { agent += " (" + cfg.GeocoderEmail + ")" } req.Header.Set("User-Agent", agent) req.Header.Set("Accept-Language", "en") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("geocoder returned %d", resp.StatusCode) } return json.NewDecoder(resp.Body).Decode(out) } // cxPlaceFrom builds the two-line label. title is capped at the 32 characters // the design allots it, and falls back through the parts of the address most // likely to be recognisable at a glance. func cxPlaceFrom(p nominatimPlace, lat, lng float64) cxPlace { title := strings.TrimSpace(p.Name) if title == "" { title = joinNonEmpty(" ", p.Address.HouseNumber, p.Address.Road) } if title == "" { title = firstNonEmpty(p.Address.Neighbourhood, p.Address.Suburb, p.Address.City, p.Address.Town, p.Address.Village) } if title == "" { // Last resort: the leading segment of the display name. if i := strings.Index(p.DisplayName, ","); i > 0 { title = strings.TrimSpace(p.DisplayName[:i]) } else { title = "Selected location" } } if r := []rune(title); len(r) > 32 { title = strings.TrimSpace(string(r[:32])) } sub := joinNonEmpty(", ", firstNonEmpty(p.Address.Neighbourhood, p.Address.Suburb), firstNonEmpty(p.Address.City, p.Address.Town, p.Address.Village, p.Address.StateDistrict), p.Address.Postcode) if sub == "" { sub = strings.TrimSpace(p.DisplayName) } if sub == "" { sub = fmt.Sprintf("%.5f, %.5f", lat, lng) } return cxPlace{Title: title, Sub: sub, Lat: lat, Lng: lng} } func firstNonEmpty(values ...string) string { for _, v := range values { if s := strings.TrimSpace(v); s != "" { return s } } return "" } // cxRecentPlaces answers an empty search with the places this customer already // uses — their saved addresses first, then the pickup points of their recent // bookings. Booking again from the same doorstep is the common case, and making // them re-type it is the friction this removes. func cxRecentPlaces(customerID int) []cxPlace { places := make([]cxPlace, 0, cxRecentPlacesShown) seen := map[string]bool{} add := func(p cxPlace) { if len(places) >= cxRecentPlacesShown { return } key := fmt.Sprintf("%.4f:%.4f", p.Lat, p.Lng) if seen[key] || (p.Lat == 0 && p.Lng == 0) { return } seen[key] = true places = append(places, p) } if customerID != 0 { var saved []models.AppCustomerLocation if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active"). Order("isdefault DESC, appcustomerlocationid DESC"). Limit(cxRecentPlacesShown).Find(&saved).Error; err == nil { for _, l := range saved { title := l.Label if title == "" { title = l.Address } add(cxPlace{ Title: title, Sub: joinNonEmpty(", ", l.Address, l.City, l.Pincode), Lat: l.Latitude, Lng: l.Longitude, }) } } var recent []models.PickupBooking if err := db.DB.Select("pickuptitle, pickupsub, pickupaddress, pickuppincode, pickuplatitude, pickuplongitude"). Where("appcustomerid = ?", customerID). Order("bookingid DESC").Limit(10).Find(&recent).Error; err == nil { for i := range recent { b := recent[i] add(cxPlace{ Title: cxPickupTitle(&b), Sub: cxPickupSub(&b), Lat: b.Pickuplatitude, Lng: b.Pickuplongitude, }) } } } return places } // ── Cache ──────────────────────────────────────────────────────────────────── // cxCacheGet / cxCacheSet are best-effort. A geocoder answer is a convenience, // never a correctness requirement, so a Redis outage costs latency and upstream // quota rather than the feature. func cxCacheGet(key string) (string, bool) { if db.Rdb == nil { return "", false } ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() value, err := db.Rdb.Get(ctx, key).Result() if err != nil { return "", false } return value, true } func cxCacheSet(key, value string, ttl time.Duration) { if db.Rdb == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() if err := db.Rdb.Set(ctx, key, value, ttl).Err(); err != nil { utils.Warn("cxCacheSet: failed", "key", key, "error", err) } }