backend requirements onthe xustomer app

This commit is contained in:
2026-09-07 10:55:11 +05:30
parent 35675d8a9b
commit 1b2690b21a
56 changed files with 13342 additions and 1071 deletions

View 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)
}
}