backend requirements onthe xustomer app
This commit is contained in:
563
controllers/cxCatalogueController.go
Normal file
563
controllers/cxCatalogueController.go
Normal file
@@ -0,0 +1,563 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Catalogue and configuration — §5 of the customer contract.
|
||||
//
|
||||
// These four reads drive the whole booking form; nothing else in the app works
|
||||
// without them. Every one of them degrades to something the app can still
|
||||
// render rather than to an error, because a customer staring at a retry button
|
||||
// on the state picker cannot book at all.
|
||||
|
||||
// ── Serviceability ───────────────────────────────────────────────────────────
|
||||
|
||||
// GetCxStates lists the states a destination may be sent to.
|
||||
//
|
||||
// districtCount counts AVAILABLE districts only, and the client hides any state
|
||||
// showing 0 — so a state that is listed but has nothing open still returns,
|
||||
// carrying the "Opening soon" transit tag. An empty list is a legitimate
|
||||
// answer: the app has a designed no-service state for it.
|
||||
func GetCxStates(c *fiber.Ctx) error {
|
||||
var states []models.ServiceableState
|
||||
if err := db.DB.Where("status = ?", "Active").
|
||||
Order("displayorder ASC, statename ASC").Find(&states).Error; err != nil {
|
||||
utils.Error("GetCxStates: query failed", "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
// One grouped count instead of a query per state.
|
||||
type stateCount struct {
|
||||
Statecode string
|
||||
N int
|
||||
}
|
||||
var counts []stateCount
|
||||
if err := db.DB.Model(&models.ServiceableDistrict{}).
|
||||
Select("statecode, count(*) as n").
|
||||
Where("available = ?", true).
|
||||
Group("statecode").Scan(&counts).Error; err != nil {
|
||||
utils.Warn("GetCxStates: district count failed, reporting zero", "error", err)
|
||||
}
|
||||
byState := make(map[string]int, len(counts))
|
||||
for _, sc := range counts {
|
||||
byState[sc.Statecode] = sc.N
|
||||
}
|
||||
|
||||
out := make([]fiber.Map, 0, len(states))
|
||||
for _, s := range states {
|
||||
out = append(out, fiber.Map{
|
||||
"code": s.Statecode,
|
||||
"name": s.Statename,
|
||||
"districtCount": byState[s.Statecode],
|
||||
"transitTag": s.Transittag,
|
||||
})
|
||||
}
|
||||
|
||||
if served := serveIfNotModified(c, out); served {
|
||||
return nil
|
||||
}
|
||||
return utils.CxList(c, out, len(out), nil)
|
||||
}
|
||||
|
||||
// GetCxDistricts lists every district in a state, unavailable ones included.
|
||||
//
|
||||
// The picker filters unavailable districts out, but their names still appear in
|
||||
// a quiet "coming soon" line — dropping them here would delete real copy from
|
||||
// the screen. `note` says why one is closed, so the app never has to invent a
|
||||
// reason.
|
||||
func GetCxDistricts(c *fiber.Ctx) error {
|
||||
stateCode := strings.ToUpper(strings.TrimSpace(c.Params("stateCode")))
|
||||
if stateCode == "" {
|
||||
return utils.CxBadRequest(c, "Pick a state first")
|
||||
}
|
||||
|
||||
var state models.ServiceableState
|
||||
if err := db.DB.Where("statecode = ? AND status = ?", stateCode, "Active").
|
||||
First(&state).Error; err != nil {
|
||||
return utils.CxNotFound(c, "That state is no longer serviceable")
|
||||
}
|
||||
|
||||
var districts []models.ServiceableDistrict
|
||||
if err := db.DB.Where("statecode = ?", stateCode).
|
||||
Order("available DESC, displayorder ASC, districtname ASC").
|
||||
Find(&districts).Error; err != nil {
|
||||
utils.Error("GetCxDistricts: query failed", "state", stateCode, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
hubNames := hubNamesFor(districts)
|
||||
|
||||
out := make([]fiber.Map, 0, len(districts))
|
||||
for _, d := range districts {
|
||||
row := fiber.Map{
|
||||
"code": d.Districtcode,
|
||||
"name": d.Districtname,
|
||||
"available": d.Available,
|
||||
}
|
||||
if d.Note != "" {
|
||||
row["note"] = d.Note
|
||||
}
|
||||
if d.Hubid != nil {
|
||||
if name, ok := hubNames[*d.Hubid]; ok {
|
||||
row["hub"] = name
|
||||
}
|
||||
}
|
||||
if d.Promise != "" {
|
||||
row["promise"] = d.Promise
|
||||
}
|
||||
// The district's centre. Only state and district are required at booking
|
||||
// time, so for most destinations this is the ONLY geography the parcel
|
||||
// has until the miler corrects it at the door — it is what places the
|
||||
// destination on a map and what the fare estimate is priced against.
|
||||
// Omitted rather than sent as 0,0 when unknown: null island is a real
|
||||
// coordinate and would render as a pin off the coast of Africa.
|
||||
if d.Centrelatitude != 0 || d.Centrelongitude != 0 {
|
||||
row["lat"] = d.Centrelatitude
|
||||
row["lng"] = d.Centrelongitude
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
|
||||
if served := serveIfNotModified(c, out); served {
|
||||
return nil
|
||||
}
|
||||
return utils.CxList(c, out, len(out), nil)
|
||||
}
|
||||
|
||||
// hubNamesFor resolves the serving-hub names for a page of districts in one
|
||||
// query rather than one per row.
|
||||
func hubNamesFor(districts []models.ServiceableDistrict) map[int]string {
|
||||
ids := make([]int, 0, len(districts))
|
||||
seen := map[int]bool{}
|
||||
for _, d := range districts {
|
||||
if d.Hubid != nil && !seen[*d.Hubid] {
|
||||
seen[*d.Hubid] = true
|
||||
ids = append(ids, *d.Hubid)
|
||||
}
|
||||
}
|
||||
names := make(map[int]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return names
|
||||
}
|
||||
var hubs []models.Hub
|
||||
if err := db.DB.Select("hubid, hubname").Where("hubid IN ?", ids).Find(&hubs).Error; err != nil {
|
||||
utils.Warn("hubNamesFor: hub lookup failed, omitting hub names", "error", err)
|
||||
return names
|
||||
}
|
||||
for _, h := range hubs {
|
||||
names[h.Hubid] = h.Hubname
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// serveIfNotModified implements ETag/If-None-Match for the serviceability
|
||||
// reads. Both change perhaps weekly and are fetched on every cold start of the
|
||||
// booking form, so a 304 is the difference between a full round trip and a
|
||||
// header exchange. Returns true when it has already answered.
|
||||
func serveIfNotModified(c *fiber.Ctx, payload interface{}) bool {
|
||||
body, err := c.App().Config().JSONEncoder(payload)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
etag := `"` + hex.EncodeToString(sum[:16]) + `"`
|
||||
c.Set("ETag", etag)
|
||||
c.Set("Cache-Control", "max-age=300")
|
||||
|
||||
// A client may legitimately send several etags, or the weak form.
|
||||
for _, candidate := range strings.Split(c.Get("If-None-Match"), ",") {
|
||||
candidate = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(candidate), "W/"))
|
||||
if candidate == etag || candidate == "*" {
|
||||
c.Status(fiber.StatusNotModified)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Pickup slots ─────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// cxSlotLeadMinutes is how far ahead of a window's start the app may still
|
||||
// offer it. A window that starts in four minutes cannot be staffed, and
|
||||
// offering it produces a booking nobody can reach on time.
|
||||
cxSlotLeadMinutes = 45
|
||||
// cxSlotDays is how far ahead slots are offered: today and tomorrow, which
|
||||
// is what the design lays out.
|
||||
cxSlotDays = 2
|
||||
// cxSlotZoneRadiusKM bounds "the customer's zone" when counting how full a
|
||||
// window already is. Capacity is a property of an area's riders, not of the
|
||||
// whole city.
|
||||
cxSlotZoneRadiusKM = 12.0
|
||||
// cxMilersNearbyRadiusKM is the radius for the reassuring "4 milers nearby"
|
||||
// line — deliberately tighter than the capacity radius, because it is a
|
||||
// statement about who could arrive shortly.
|
||||
cxMilersNearbyRadiusKM = 6.0
|
||||
)
|
||||
|
||||
// GetCxPickupSlots returns the pickup windows offered at a location.
|
||||
//
|
||||
// Slots are capacity- and location-aware: the id resolves to a real
|
||||
// preferredpickupfrom/to on the booking, which is what the assignment engine
|
||||
// consumes, so a slot the customer can pick is a slot ops can staff. Windows
|
||||
// already past, or too close to start, are not returned at all rather than
|
||||
// returned as unavailable — a greyed-out 8am slot at 6pm is noise.
|
||||
func GetCxPickupSlots(c *fiber.Ctx) error {
|
||||
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
|
||||
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
|
||||
|
||||
appLocationID := appLocationForPoint(lat, lng)
|
||||
|
||||
var templates []models.PickupSlotTemplate
|
||||
q := db.DB.Where("status = ?", "Active")
|
||||
if appLocationID != nil {
|
||||
q = q.Where("applocationid IS NULL OR applocationid = ?", *appLocationID)
|
||||
} else {
|
||||
q = q.Where("applocationid IS NULL")
|
||||
}
|
||||
if err := q.Order("displayorder ASC, starthour ASC").Find(&templates).Error; err != nil {
|
||||
utils.Error("GetCxPickupSlots: template query failed", "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
now := utils.ISTNow()
|
||||
cutoff := now.Add(cxSlotLeadMinutes * time.Minute)
|
||||
|
||||
candidates := make([]cxSlotCandidate, 0, len(templates)*cxSlotDays)
|
||||
for day := 0; day < cxSlotDays; day++ {
|
||||
d := now.AddDate(0, 0, day)
|
||||
for _, tpl := range templates {
|
||||
from := time.Date(d.Year(), d.Month(), d.Day(), tpl.Starthour, tpl.Startminute, 0, 0, utils.ISTLocation())
|
||||
to := time.Date(d.Year(), d.Month(), d.Day(), tpl.Endhour, tpl.Endminute, 0, 0, utils.ISTLocation())
|
||||
if !from.After(cutoff) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, cxSlotCandidate{
|
||||
id: cxSlotID(from, tpl.Code),
|
||||
from: from,
|
||||
to: to,
|
||||
tpl: tpl,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
booked := slotLoad(candidates, lat, lng)
|
||||
milersNearby := milersWithin(lat, lng, cxMilersNearbyRadiusKM)
|
||||
|
||||
out := make([]fiber.Map, 0, len(candidates))
|
||||
taggedOne := false
|
||||
for _, cand := range candidates {
|
||||
remaining := cand.tpl.Capacity - booked[cand.id]
|
||||
available := remaining > 0
|
||||
|
||||
row := fiber.Map{
|
||||
"id": cand.id,
|
||||
"day": utils.FormatISTDay(cand.from),
|
||||
"window": utils.FormatISTWindow(cand.from, cand.to),
|
||||
"available": available,
|
||||
}
|
||||
if !available {
|
||||
row["note"] = "Fully booked"
|
||||
}
|
||||
// At most one slot carries the tag, and only if it can actually be
|
||||
// booked — labelling a full window "Fastest pickup" is worse than
|
||||
// labelling nothing.
|
||||
if available && !taggedOne && cand.tpl.Tag != "" {
|
||||
row["tag"] = cand.tpl.Tag
|
||||
taggedOne = true
|
||||
}
|
||||
if milersNearby > 0 {
|
||||
row["milersNearby"] = milersNearby
|
||||
}
|
||||
if available && cand.tpl.Caption != "" {
|
||||
row["caption"] = cand.tpl.Caption
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
|
||||
// Slots are volatile; a stale slot list is a booking that 409s on confirm.
|
||||
c.Set("Cache-Control", "max-age=30")
|
||||
return utils.CxList(c, out, len(out), nil)
|
||||
}
|
||||
|
||||
// cxSlotID mints the opaque slot id the client sends back. It encodes the date
|
||||
// and the template code so the server can resolve it to a real window without
|
||||
// keeping per-request state — and so a slot id from yesterday's cached list
|
||||
// resolves to yesterday and is rejected, rather than silently booking today.
|
||||
func cxSlotID(from time.Time, code string) string {
|
||||
return fmt.Sprintf("slot_%s_%s", from.Format("20060102"), code)
|
||||
}
|
||||
|
||||
// ResolveCxSlot turns a slot id back into the window it names, checking the
|
||||
// template still exists and is active. Returns ok=false for an unknown,
|
||||
// malformed or retired slot.
|
||||
func ResolveCxSlot(slotID string) (from, to time.Time, ok bool) {
|
||||
parts := strings.SplitN(slotID, "_", 3)
|
||||
if len(parts) != 3 || parts[0] != "slot" {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
day, err := time.ParseInLocation("20060102", parts[1], utils.ISTLocation())
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
|
||||
var tpl models.PickupSlotTemplate
|
||||
if err := db.DB.Where("code = ? AND status = ?", parts[2], "Active").
|
||||
First(&tpl).Error; err != nil {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
|
||||
from = time.Date(day.Year(), day.Month(), day.Day(), tpl.Starthour, tpl.Startminute, 0, 0, utils.ISTLocation())
|
||||
to = time.Date(day.Year(), day.Month(), day.Day(), tpl.Endhour, tpl.Endminute, 0, 0, utils.ISTLocation())
|
||||
return from, to, true
|
||||
}
|
||||
|
||||
// CxSlotDateIsPast reports whether a slot id names a day that is already over.
|
||||
//
|
||||
// Pure: it reads the date out of the id and compares it to today, with no
|
||||
// template lookup and no database. That matters because it is the cheapest
|
||||
// validation in the booking path and it catches the most likely stale-slot
|
||||
// case — an app left open across midnight, or one that cached the slot list for
|
||||
// a whole session, sending yesterday's window in good faith.
|
||||
//
|
||||
// Only a whole day in the past is decided here. Whether one of TODAY's windows
|
||||
// has already started needs the template's hours, which ResolveCxSlot loads.
|
||||
func CxSlotDateIsPast(slotID string) bool {
|
||||
parts := strings.SplitN(slotID, "_", 3)
|
||||
if len(parts) != 3 || parts[0] != "slot" {
|
||||
return false
|
||||
}
|
||||
day, err := time.ParseInLocation("20060102", parts[1], utils.ISTLocation())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
now := utils.ISTNow()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, utils.ISTLocation())
|
||||
return day.Before(today)
|
||||
}
|
||||
|
||||
// CxSlotHasCapacity re-checks a window at confirm time. The list read is
|
||||
// advisory and up to 30 seconds stale; this is the authority, and it is what
|
||||
// turns a race into a clean 409 rather than an overbooked window.
|
||||
func CxSlotHasCapacity(slotID string, lat, lng float64) bool {
|
||||
from, to, ok := ResolveCxSlot(slotID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
var tpl models.PickupSlotTemplate
|
||||
parts := strings.SplitN(slotID, "_", 3)
|
||||
if err := db.DB.Where("code = ?", parts[2]).First(&tpl).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return countBookingsInWindow(from, to, lat, lng) < tpl.Capacity
|
||||
}
|
||||
|
||||
// cxSlotCandidate is one concrete window on one concrete day, before capacity
|
||||
// is applied — a template plus the date it was expanded onto.
|
||||
type cxSlotCandidate struct {
|
||||
id string
|
||||
from, to time.Time
|
||||
tpl models.PickupSlotTemplate
|
||||
}
|
||||
|
||||
// slotLoad counts how many live bookings already sit in each candidate window
|
||||
// near this point. Done per window rather than in one grouped query because the
|
||||
// windows overlap across days and the zone filter is geometric, not indexable
|
||||
// here; the list is at most a dozen rows.
|
||||
func slotLoad(candidates []cxSlotCandidate, lat, lng float64) map[string]int {
|
||||
load := make(map[string]int, len(candidates))
|
||||
for _, cand := range candidates {
|
||||
load[cand.id] = countBookingsInWindow(cand.from, cand.to, lat, lng)
|
||||
}
|
||||
return load
|
||||
}
|
||||
|
||||
// countBookingsInWindow counts pickups already committed to a window inside the
|
||||
// customer's zone. Cancelled and completed bookings do not consume capacity —
|
||||
// only work still to be done does.
|
||||
func countBookingsInWindow(from, to time.Time, lat, lng float64) int {
|
||||
// Stored timestamps are IST wall clock (see utils.DBNow), so the bounds are
|
||||
// sent as those digits rather than as a UTC instant. Comparing a UTC clock
|
||||
// against IST-stamped rows is what made date-range reports undercount.
|
||||
fromDB := time.Date(from.Year(), from.Month(), from.Day(), from.Hour(), from.Minute(), 0, 0, time.UTC)
|
||||
toDB := time.Date(to.Year(), to.Month(), to.Day(), to.Hour(), to.Minute(), 0, 0, time.UTC)
|
||||
|
||||
type row struct {
|
||||
Pickuplatitude float64
|
||||
Pickuplongitude float64
|
||||
}
|
||||
var rows []row
|
||||
err := db.DB.Model(&models.PickupBooking{}).
|
||||
Select("pickuplatitude, pickuplongitude").
|
||||
Where("preferredpickupfrom >= ? AND preferredpickupfrom < ?", fromDB, toDB).
|
||||
Where("status NOT IN ?", []string{constants.BookingCancelled, constants.BookingConvertedConsignment}).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
// Failing open keeps the form usable. An over-filled window is an ops
|
||||
// problem; a booking form that cannot offer any slot is a dead app.
|
||||
utils.Warn("countBookingsInWindow: query failed, treating window as open", "error", err)
|
||||
return 0
|
||||
}
|
||||
|
||||
if lat == 0 && lng == 0 {
|
||||
return len(rows)
|
||||
}
|
||||
n := 0
|
||||
for _, r := range rows {
|
||||
if r.Pickuplatitude == 0 && r.Pickuplongitude == 0 {
|
||||
continue
|
||||
}
|
||||
if calculateDistance(lat, lng, r.Pickuplatitude, r.Pickuplongitude) <= cxSlotZoneRadiusKM {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// milersWithin counts riders currently reporting a position inside a radius.
|
||||
// Reads the same Redis GEO index the assignment engine searches, so the number
|
||||
// the customer is shown is the pool the dispatcher would actually draw from.
|
||||
// Returns 0 when Redis is unavailable, and the client hides the line on 0.
|
||||
func milersWithin(lat, lng, radiusKM float64) int {
|
||||
if db.Rdb == nil || (lat == 0 && lng == 0) {
|
||||
return 0
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
locs, err := db.Rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{
|
||||
GeoSearchQuery: redis.GeoSearchQuery{
|
||||
Longitude: lng,
|
||||
Latitude: lat,
|
||||
Radius: radiusKM,
|
||||
RadiusUnit: "km",
|
||||
Sort: "ASC",
|
||||
Count: 50,
|
||||
},
|
||||
}).Result()
|
||||
if err != nil {
|
||||
utils.Warn("milersWithin: geo search failed", "error", err)
|
||||
return 0
|
||||
}
|
||||
return len(locs)
|
||||
}
|
||||
|
||||
// appLocationForPoint resolves which city a coordinate belongs to, via the
|
||||
// nearest active hub. Nil when nothing is close enough to claim it, in which
|
||||
// case only city-agnostic configuration applies.
|
||||
func appLocationForPoint(lat, lng float64) *int {
|
||||
if lat == 0 && lng == 0 {
|
||||
return nil
|
||||
}
|
||||
var hubs []models.Hub
|
||||
if err := db.DB.Select("hubid, applocationid, latitude, longitude").
|
||||
Where("status = ? AND deletedat IS NULL", "Active").Find(&hubs).Error; err != nil {
|
||||
utils.Warn("appLocationForPoint: hub query failed", "error", err)
|
||||
return nil
|
||||
}
|
||||
best := -1.0
|
||||
var bestID *int
|
||||
for i := range hubs {
|
||||
h := hubs[i]
|
||||
if h.Latitude == 0 && h.Longitude == 0 {
|
||||
continue
|
||||
}
|
||||
d := calculateDistance(lat, lng, h.Latitude, h.Longitude)
|
||||
if best < 0 || d < best {
|
||||
best = d
|
||||
id := h.Applocationid
|
||||
bestID = &id
|
||||
}
|
||||
}
|
||||
// A hub 200km away says nothing about which city this is.
|
||||
if bestID == nil || best > 60 {
|
||||
return nil
|
||||
}
|
||||
return bestID
|
||||
}
|
||||
|
||||
// ── Booking limits ───────────────────────────────────────────────────────────
|
||||
|
||||
// cxDefaultMaxPackages / cxDefaultMaxDestinations are the last-resort values,
|
||||
// used only when no configuration row exists at all. They can never be 0 —
|
||||
// a zero cap would reject every booking on the platform.
|
||||
//
|
||||
// cxDefaultMaxDestinations is deliberately **1**, not the 5 the design allows.
|
||||
//
|
||||
// This is the fail-safe half of the multi-destination gate. The gate itself is a
|
||||
// database value (customerbookinglimits.maxdestinations), and a gate that opens
|
||||
// when its configuration is missing is not a gate: a migration that ran without
|
||||
// the seed, a wiped table, or a fresh environment would silently permit
|
||||
// multi-destination bookings that the deployed rider app cannot complete,
|
||||
// stranding parcels with no stop and no way to close them.
|
||||
//
|
||||
// So "no configuration" resolves to the safest behaviour, not the most
|
||||
// permissive. Ops raises it to 5 by inserting the row — an explicit act — once
|
||||
// a rider build keying on consignmentid is live. The client's own 20/5 fallback
|
||||
// is UI guidance only; this is the authority.
|
||||
const (
|
||||
cxDefaultMaxPackages = 20
|
||||
cxDefaultMaxDestinations = 1
|
||||
)
|
||||
|
||||
// GetCxBookingLimits returns the caps on a single pickup.
|
||||
//
|
||||
// Nothing in the UI hardcodes these; they live here so ops can vary them by
|
||||
// city without an app release. Keyed off the pickup location when one is
|
||||
// supplied, so the client can re-fetch when the pickup point moves.
|
||||
func GetCxBookingLimits(c *fiber.Ctx) error {
|
||||
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
|
||||
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
|
||||
|
||||
maxPackages, maxDestinations := CxBookingLimits(appLocationForPoint(lat, lng))
|
||||
return utils.CxOK(c, fiber.Map{
|
||||
"maxPackages": maxPackages,
|
||||
"maxDestinations": maxDestinations,
|
||||
})
|
||||
}
|
||||
|
||||
// CxBookingLimits resolves the caps for a city, falling back to the global row
|
||||
// and then to the built-in defaults. Never returns 0 for either: a zero cap
|
||||
// rejects every booking, and a configuration mistake must not be able to take
|
||||
// the product offline.
|
||||
func CxBookingLimits(appLocationID *int) (maxPackages, maxDestinations int) {
|
||||
maxPackages, maxDestinations = cxDefaultMaxPackages, cxDefaultMaxDestinations
|
||||
|
||||
var limit models.CustomerBookingLimit
|
||||
found := false
|
||||
if appLocationID != nil {
|
||||
if err := db.DB.Where("applocationid = ?", *appLocationID).First(&limit).Error; err == nil {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if err := db.DB.Where("applocationid IS NULL").First(&limit).Error; err == nil {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
if limit.Maxpackages > 0 {
|
||||
maxPackages = limit.Maxpackages
|
||||
}
|
||||
if limit.Maxdestinations > 0 {
|
||||
maxDestinations = limit.Maxdestinations
|
||||
}
|
||||
return
|
||||
}
|
||||
Reference in New Issue
Block a user