Files
doormile_backend/controllers/cxCatalogueController.go

564 lines
20 KiB
Go

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
}