backend requirements onthe xustomer app
This commit is contained in:
350
controllers/cxPlacesController.go
Normal file
350
controllers/cxPlacesController.go
Normal file
@@ -0,0 +1,350 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user