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 }