package controllers import ( "context" "os" "strconv" "strings" "time" "doormile/constants" "doormile/db" "doormile/internal/storage" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" ) // The canonical booking object — §9.3. // // The tracking screen and the receipt are both rendered from this one shape, so // it is built in exactly one place and every read that returns a booking goes // through it. A list row and a detail read differing in shape is how a client // ends up with two parsers for one object. // // Two rules the client's type declarations depend on, and which will throw in // its parser if broken: // - pickup and slotId are present on EVERY booking, cancelled ones included. // - destinations[].stateName / districtName are always populated; the client // renders "Chennai, Tamil Nadu" from them and never looks a code up. // cxPhotoTTL is how long a parcel-photo link stays valid. Long enough to open // the receipt, read it and come back; short enough that a link forwarded on is // dead by the time it is opened. const cxPhotoTTL = 30 * time.Minute // cxBookingBundle is everything one or more bookings need in order to render, // loaded in batch. Building it per booking would put six queries behind a // tracking poll that runs every few seconds. type cxBookingBundle struct { destinations map[int][]models.BookingDestination events map[int][]models.BookingStageEvent photos map[int][]models.BookingParcelPhoto // keyed by destination id milers map[int]cxAgent // keyed by miler user id assignedTo map[int]int // booking id -> miler user id deliveryAgent map[int]int // destination id -> agent user id payments map[int]float64 // booking id -> settled rupees districts map[string]models.ServiceableDistrict hubNames map[int]string // single marks a one-booking read (the tracking screen or the receipt), as // opposed to a page of them. A couple of fields are worth a live lookup for // one booking and are not worth twenty of them for a list. single bool } type cxAgent struct { Name string Vehicle string Phone string Rating float64 Trips int VehicleType string Lat, Lng float64 } // renderCxBooking projects one booking into the customer contract. func renderCxBooking(b *models.PickupBooking, bundle *cxBookingBundle) fiber.Map { destinations := bundle.destinations[b.Bookingid] stage := b.Customerstage if stage == "" { // A booking written before this surface existed, or one created through // the console. Deriving a stage from the operational status is honest // about where the parcel is; inventing history for it is not, so its // timeline stays as short as the events actually recorded. stage = deriveStageFromStatus(b) } status := b.Customerstatus if status == "" { status = deriveStatus(b, stage) } // The OPERATIONAL status is the authority on cancellation, whatever the // stored customer status says. // // Three console paths cancel a booking by writing pickupbookings.status // directly — AdminCancelBooking, AdminBulkCancelBookings and // AdminUpdateBookingStatus (which accepts an arbitrary status string). None // of them knows this projection exists. Without this line a customer whose // pickup ops cancelled would keep seeing it as active and cancellable // forever, because customerstatus was written as "active" at booking time // and the empty-string fallback above never fires. // // Done here rather than only at those call sites so that a cancel path // added later cannot reintroduce the same divergence. if b.Status == constants.BookingCancelled { status = constants.CxStatusCancelled } out := fiber.Map{ "reference": b.Bookingno, "stage": stage, "status": status, "cancellable": status == constants.CxStatusActive && constants.CxCancellable(stage), "createdAt": utils.EpochMillis(b.Createdat), "pickup": fiber.Map{ "title": cxPickupTitle(b), "sub": cxPickupSub(b), "lat": b.Pickuplatitude, "lng": b.Pickuplongitude, }, "slotId": b.Slotid, "destinations": renderCxDestinations(destinations, bundle), "miler": nil, "deliveryAgent": nil, "milerDistanceKm": nil, "milerEtaMinutes": nil, "milersInZone": 0, "routeKm": b.Routekm, "expectedDelivery": cxExpectedDelivery(destinations), "fare": fiber.Map{ "min": b.Estimateminrupees, "max": b.Estimatemaxrupees, "paymentMethod": "UPI · Cash at doorstep", "parcel": describeParcels(totalPackages(destinations)), }, "amountPaid": nil, "deliveredAt": nil, "cancelReason": nil, "history": renderCxHistory(bundle.events[b.Bookingid]), } if b.Cancelreason != "" { out["cancelReason"] = b.Cancelreason } // The assigned miler, from the stage they are assigned onward. if milerUserID, ok := bundle.assignedTo[b.Bookingid]; ok { if agent, found := bundle.milers[milerUserID]; found { out["miler"] = renderCxAgent(agent) // Distance and ETA are live facts about a rider en route, so they // are computed from the rider's current position rather than // stored. Only meaningful while they are actually coming: after // pickup the number would describe a journey that already ended. if stage == constants.CxStageOnTheWay || stage == constants.CxStageArrived { km, eta := cxRiderApproach(agent, b, stage) out["milerDistanceKm"] = km out["milerEtaMinutes"] = eta } } } else if stage == constants.CxStageBooked && bundle.single { // Nobody assigned yet — the tracking screen shows how many riders are // in the zone instead, which is the only honest thing to say while // searching. // // Only on a single-booking read. This is a Redis GEOSEARCH per booking, // and running it across a 20-row Orders list would put twenty of them // behind one page load to fill a line the list does not render. out["milersInZone"] = milersWithin(b.Pickuplatitude, b.Pickuplongitude, cxMilersNearbyRadiusKM) } // The delivering rider, once any order is out for delivery. Taken from the // first destination that has one, since the booking-level field describes // the leg the customer is currently watching. for _, d := range destinations { if agentUserID, ok := bundle.deliveryAgent[d.Bookingdestinationid]; ok { if agent, found := bundle.milers[agentUserID]; found { out["deliveryAgent"] = renderCxAgent(agent) break } } } // amountPaid is present from picked_up. The fare block stays on the booking // forever alongside it — the receipt renders amountPaid − fare.min as the // weight adjustment, so losing the original estimate would lose the // explanation for the difference. if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStagePickedUp] { if paid, ok := bundle.payments[b.Bookingid]; ok { out["amountPaid"] = int(paid) } } if delivered := cxAllDeliveredAt(destinations); delivered != nil { out["deliveredAt"] = utils.EpochMillis(*delivered) } return out } func renderCxAgent(a cxAgent) fiber.Map { return fiber.Map{ "name": a.Name, "vehicle": a.Vehicle, "phone": a.Phone, "rating": a.Rating, "trips": a.Trips, "vehicleType": a.VehicleType, } } func renderCxDestinations(destinations []models.BookingDestination, bundle *cxBookingBundle) []fiber.Map { out := make([]fiber.Map, 0, len(destinations)) for i := range destinations { d := destinations[i] row := fiber.Map{ "stateCode": d.Statecode, "stateName": d.Statename, "districtCode": d.Districtcode, "districtName": d.Districtname, "packageCount": d.Packagecount, "district": nil, "details": renderCxDetails(&d), "trackingId": nil, "stage": nil, "verification": nil, } if district, ok := bundle.districts[d.Districtcode]; ok { card := fiber.Map{ "code": district.Districtcode, "name": district.Districtname, "available": district.Available, } if district.Hubid != nil { if name, found := bundle.hubNames[*district.Hubid]; found { card["hub"] = name } } if district.Promise != "" { card["promise"] = district.Promise } row["district"] = card } // Null until order_created — there is no order to track before the // parcels have actually been collected. if d.Trackingno != "" { row["trackingId"] = d.Trackingno } if d.Stage != "" { row["stage"] = d.Stage } // Null until picked_up: the weight and the photographs are what the // miler recorded at the door and cannot exist before they were there. if d.Verifiedweightkg != nil && d.Verifiedat != nil { verification := fiber.Map{ "weightKg": *d.Verifiedweightkg, "photos": cxPhotoURLs(bundle.photos[d.Bookingdestinationid]), "capturedAt": utils.EpochMillis(*d.Verifiedat), "capturedBy": "", } if d.Verifiedbyuserid != nil { if agent, ok := bundle.milers[*d.Verifiedbyuserid]; ok { verification["capturedBy"] = agent.Name } } row["verification"] = verification } out = append(out, row) } return out } // renderCxDetails returns only the fields that were actually filled in. The UI // renders a missing one as "Not added — the Miler can confirm this at pickup", // which is a real state and not an error: a customer may legitimately book with // nothing but a state and a district. func renderCxDetails(d *models.BookingDestination) fiber.Map { details := fiber.Map{} if d.Street != "" { details["street"] = d.Street } if d.Building != "" { details["building"] = d.Building } if d.Landmark != "" { details["landmark"] = d.Landmark } if d.Recipientname != "" { details["recipientName"] = d.Recipientname } if d.Recipientphone != "" { details["recipientPhone"] = d.Recipientphone } if d.Instructions != "" { details["instructions"] = d.Instructions } if d.Pinlatitude != nil && d.Pinlongitude != nil { details["pin"] = fiber.Map{"lat": *d.Pinlatitude, "lng": *d.Pinlongitude} } return details } // cxPhotoURLs signs each parcel photograph for the length of a receipt view. func cxPhotoURLs(photos []models.BookingParcelPhoto) []string { urls := make([]string, 0, len(photos)) for _, p := range photos { url, err := storage.PresignGet(p.Objectkey, cxPhotoTTL) if err != nil { utils.Warn("cxPhotoURLs: could not sign parcel photo", "key", p.Objectkey, "error", err) continue } urls = append(urls, url) } return urls } // renderCxHistory turns the event log into the timeline. Ordered oldest-first // and carrying only real timestamps — every entry on the customer's timeline // comes from a row that a real write created. func renderCxHistory(events []models.BookingStageEvent) []fiber.Map { // One entry per stage. A multi-destination pickup emits a per-order stage // once per destination, so those have to collapse — and WHICH of them the // timeline shows is not arbitrary: // // * Booking-level stages (booked..order_created) happen once for the whole // pickup, so the first event is the only event. // * Per-order stages (in_transit..delivered) are reached by the booking // when its SLOWEST order gets there, matching how cxstage rolls the // booking up. Taking the first would timestamp "Delivered" at the moment // the earliest parcel landed while deliveredAt reports the last one — // the same screen contradicting itself. at := map[string]time.Time{} order := make([]string, 0, len(events)) for _, e := range events { // Cancellation and release rows are audit records rather than progress — // they carry a stage key only because the table needs one. Putting them // on the timeline would show the customer "Pickup booked" a second time // when a rider handed their pickup back. if strings.HasPrefix(e.Remarks, "cancelled:") || strings.HasPrefix(e.Remarks, "released:") { continue } existing, seen := at[e.Stage] if !seen { at[e.Stage] = e.Occurredat order = append(order, e.Stage) continue } if constants.CxStageRank(e.Stage) >= constants.CxStageOrder[constants.CxStageInTransit] && e.Occurredat.After(existing) { at[e.Stage] = e.Occurredat } } out := make([]fiber.Map, 0, len(order)) for _, stage := range order { out = append(out, fiber.Map{ "stage": stage, "at": utils.EpochMillis(at[stage]), }) } return out } // ── Derivations ────────────────────────────────────────────────────────────── // deriveStageFromStatus gives a stage to a booking that has none: rows written // before this surface existed, and console-created express bookings that never // went through the customer flow. func deriveStageFromStatus(b *models.PickupBooking) string { switch b.Status { case constants.BookingCancelled: return constants.CxStageBooked case constants.BookingPickedUp: return constants.CxStagePickedUp case constants.BookingConvertedConsignment: return constants.CxStageOrderCreated case constants.BookingMilerAssigned, constants.BookingPickupScheduled: // reachedat is the only durable record that the rider actually got // there — the operational flow records arrival as a fact rather than a // status, so this is the one place it can be read from. if b.Arrivedat != nil { return constants.CxStageArrived } return constants.CxStageAssigned default: return constants.CxStageBooked } } func deriveStatus(b *models.PickupBooking, stage string) string { if b.Status == constants.BookingCancelled { return constants.CxStatusCancelled } if stage == constants.CxStageDelivered { return constants.CxStatusCompleted } return constants.CxStatusActive } // cxPickupTitle / cxPickupSub give the pickup block its two lines. The stored // title/sub pair is used when the booking came through the customer app; a // console-created booking only has one flat address string, so it is split // rather than left half-empty — the client types both as non-nullable. func cxPickupTitle(b *models.PickupBooking) string { if b.Pickuptitle != "" { return b.Pickuptitle } address := strings.TrimSpace(b.Pickupaddress) if address == "" { return "Pickup address" } if i := strings.Index(address, ","); i > 0 { return strings.TrimSpace(address[:i]) } if len([]rune(address)) > 32 { return string([]rune(address)[:32]) } return address } func cxPickupSub(b *models.PickupBooking) string { if b.Pickupsub != "" { return b.Pickupsub } sub := joinNonEmpty(", ", strings.TrimSpace(b.Pickupaddress), b.Pickuppincode) if sub == "" { return "Address not recorded" } return sub } // cxExpectedDelivery is a display string, formatted server-side in IST, so the // client never has to know the operating timezone. The latest promise across // the destinations is the one shown: a booking is not fully delivered until its // last parcel is. func cxExpectedDelivery(destinations []models.BookingDestination) string { var latest *time.Time for i := range destinations { d := destinations[i] if d.Expecteddeliveryat == nil { continue } if latest == nil || d.Expecteddeliveryat.After(*latest) { latest = d.Expecteddeliveryat } } if latest == nil { return "" } return utils.FormatISTDate(*latest) } // cxAllDeliveredAt returns when the LAST parcel landed, or nil while any is // still moving. func cxAllDeliveredAt(destinations []models.BookingDestination) *time.Time { if len(destinations) == 0 { return nil } var latest *time.Time for i := range destinations { d := destinations[i] if d.Deliveredat == nil { return nil } if latest == nil || d.Deliveredat.After(*latest) { latest = d.Deliveredat } } return latest } func totalPackages(destinations []models.BookingDestination) int { n := 0 for _, d := range destinations { n += d.Packagecount } if n == 0 { return 1 } return n } // cxRiderApproach reports how far the rider still is and roughly how long that // takes. At the door both are zero — a rider standing at the address is not // "0.4 km away", and the screen says Arrived. func cxRiderApproach(agent cxAgent, b *models.PickupBooking, stage string) (float64, int) { if stage == constants.CxStageArrived { return 0, 0 } lat, lng := agent.Lat, agent.Lng if lat == 0 && lng == 0 { return 0, 0 } km := calculateDistance(lat, lng, b.Pickuplatitude, b.Pickuplongitude) km = float64(int(km*10+0.5)) / 10 // 18 km/h is a two-wheeler in Indian city traffic, plus a two-minute floor // for parking and finding the door. Deliberately a rough number: the app // shows it as an approximation and a precise-looking ETA that slips reads // worse than an honest one. const avgSpeedKMH = 18.0 eta := int(km/avgSpeedKMH*60) + 2 return km, eta } // ── Bundle loading ─────────────────────────────────────────────────────────── // loadCxBundle fetches everything a page of bookings needs, in a fixed number // of queries regardless of how many bookings or destinations are involved. func loadCxBundle(bookings []models.PickupBooking) *cxBookingBundle { bundle := &cxBookingBundle{ destinations: map[int][]models.BookingDestination{}, events: map[int][]models.BookingStageEvent{}, photos: map[int][]models.BookingParcelPhoto{}, milers: map[int]cxAgent{}, assignedTo: map[int]int{}, deliveryAgent: map[int]int{}, payments: map[int]float64{}, districts: map[string]models.ServiceableDistrict{}, hubNames: map[int]string{}, } bundle.single = len(bookings) == 1 if len(bookings) == 0 { return bundle } bookingIDs := make([]int, 0, len(bookings)) for _, b := range bookings { bookingIDs = append(bookingIDs, b.Bookingid) } var destinations []models.BookingDestination if err := db.DB.Where("bookingid IN ?", bookingIDs). Order("bookingid ASC, seq ASC").Find(&destinations).Error; err != nil { utils.Error("loadCxBundle: destinations query failed", "error", err) } destIDs := make([]int, 0, len(destinations)) districtCodes := map[string]bool{} for _, d := range destinations { bundle.destinations[d.Bookingid] = append(bundle.destinations[d.Bookingid], d) destIDs = append(destIDs, d.Bookingdestinationid) if d.Districtcode != "" { districtCodes[d.Districtcode] = true } } var events []models.BookingStageEvent if err := db.DB.Where("bookingid IN ?", bookingIDs). Order("occurredat ASC, stageeventid ASC").Find(&events).Error; err != nil { utils.Error("loadCxBundle: stage events query failed", "error", err) } for _, e := range events { bundle.events[e.Bookingid] = append(bundle.events[e.Bookingid], e) } if len(destIDs) > 0 { var photos []models.BookingParcelPhoto if err := db.DB.Where("bookingdestinationid IN ?", destIDs). Order("capturedat ASC").Find(&photos).Error; err != nil { utils.Warn("loadCxBundle: parcel photos query failed", "error", err) } for _, p := range photos { if p.Bookingdestinationid != nil { bundle.photos[*p.Bookingdestinationid] = append(bundle.photos[*p.Bookingdestinationid], p) } } } milerIDs := map[int]bool{} // The live assignment, if any. Rejected and cancelled assignments are not // the current rider and must not be shown as one. var assignments []models.BookingAssignment if err := db.DB.Where("bookingid IN ? AND assignmentstatus IN ?", bookingIDs, []string{constants.AssignmentAssigned, constants.AssignmentAccepted, constants.AssignmentCompleted}). Order("assignedat ASC").Find(&assignments).Error; err != nil { utils.Warn("loadCxBundle: assignments query failed", "error", err) } for _, a := range assignments { bundle.assignedTo[a.Bookingid] = a.Mileruserid milerIDs[a.Mileruserid] = true } // Who is delivering each order — the rider who recorded the out-for-delivery // event on that consignment. consignmentToDest := map[int]int{} consignmentIDs := make([]int, 0, len(destinations)) for _, d := range destinations { if d.Consignmentid != nil { consignmentToDest[*d.Consignmentid] = d.Bookingdestinationid consignmentIDs = append(consignmentIDs, *d.Consignmentid) } } if len(consignmentIDs) > 0 { var history []models.ConsignmentHistory if err := db.DB.Where("consignmentid IN ? AND eventstatus = ?", consignmentIDs, constants.ConsignmentOutForDelivery). Order("createdat ASC").Find(&history).Error; err != nil { utils.Warn("loadCxBundle: consignment history query failed", "error", err) } for _, h := range history { if h.Userid == nil { continue } if destID, ok := consignmentToDest[h.Consignmentid]; ok { bundle.deliveryAgent[destID] = *h.Userid milerIDs[*h.Userid] = true } } } for _, d := range destinations { if d.Verifiedbyuserid != nil { milerIDs[*d.Verifiedbyuserid] = true } } bundle.milers = loadCxAgents(milerIDs) // What the customer actually paid. Summed from the payment rows rather than // stored on the booking, so money has one home and a second collection // cannot silently disagree with a cached total. type paidRow struct { Bookingid int Total float64 } var paid []paidRow if err := db.DB.Model(&models.BookingPayment{}). Select("bookingid, sum(amount) as total"). Where("bookingid IN ? AND paymentstatus = ?", bookingIDs, constants.PaymentStatusPaid). Group("bookingid").Scan(&paid).Error; err != nil { utils.Warn("loadCxBundle: payment totals query failed", "error", err) } for _, p := range paid { bundle.payments[p.Bookingid] = p.Total } if len(districtCodes) > 0 { codes := make([]string, 0, len(districtCodes)) for code := range districtCodes { codes = append(codes, code) } var districts []models.ServiceableDistrict if err := db.DB.Where("districtcode IN ?", codes).Find(&districts).Error; err != nil { utils.Warn("loadCxBundle: district query failed", "error", err) } for _, d := range districts { bundle.districts[d.Districtcode] = d } bundle.hubNames = hubNamesFor(districts) } return bundle } // loadCxAgents resolves rider display data — and their live position, which // comes from Redis because it changes every few seconds and has no business in // Postgres. func loadCxAgents(ids map[int]bool) map[int]cxAgent { out := map[int]cxAgent{} if len(ids) == 0 { return out } list := make([]int, 0, len(ids)) for id := range ids { list = append(list, id) } var profiles []models.MilerProfile if err := db.DB.Where("userid IN ?", list).Find(&profiles).Error; err != nil { utils.Warn("loadCxAgents: profile query failed", "error", err) return out } vehicleIDs := make([]int, 0, len(profiles)) for _, p := range profiles { if p.Vehicleid != nil { vehicleIDs = append(vehicleIDs, *p.Vehicleid) } } vehicles := map[int]models.Vehicle{} if len(vehicleIDs) > 0 { var rows []models.Vehicle if err := db.DB.Where("vehicleid IN ?", vehicleIDs).Find(&rows).Error; err == nil { for _, v := range rows { vehicles[v.Vehicleid] = v } } } for _, p := range profiles { agent := cxAgent{ Name: p.Displayname, Phone: cxMilerContact(p.Phone), Rating: p.Rating, Trips: p.Totalcompletedpickups, VehicleType: p.Defaultvehicletype, Lat: p.Currentlatitude, Lng: p.Currentlongitude, } if p.Vehicleid != nil { if v, ok := vehicles[*p.Vehicleid]; ok { agent.Vehicle = v.Vehicleno if agent.VehicleType == "" { agent.VehicleType = v.Vehicletype } } } if lat, lng, ok := cxLiveRiderPosition(p.Userid); ok { agent.Lat, agent.Lng = lat, lng } out[p.Userid] = agent } return out } // cxLiveRiderPosition reads the rider's current position from the same Redis // GEO index the assignment engine searches, so the distance the customer sees // and the distance the dispatcher used are the same number. Falls back to the // profile's last-known coordinates when Redis has nothing. func cxLiveRiderPosition(milerUserID int) (lat, lng float64, ok bool) { if db.Rdb == nil { return 0, 0, false } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() positions, err := db.Rdb.GeoPos(ctx, "milers:locations", cxRiderGeoMember(milerUserID)).Result() if err != nil || len(positions) == 0 || positions[0] == nil { return 0, 0, false } return positions[0].Latitude, positions[0].Longitude, true } // cxRiderGeoMember is the member name riders are stored under in the GEO index // (see UpdateMilerLocation, which GEOADDs under the rider's user id). func cxRiderGeoMember(milerUserID int) string { return strconv.Itoa(milerUserID) } // cxMilerContact decides what phone number the customer is given for their // rider. // // A masked-calling proxy is preferred, and MILER_CALL_PROXY configures one: // handing a customer a rider's personal mobile makes that number permanently // theirs, and riders on comparable platforms have been contacted long after the // delivery on numbers given out this way. With no proxy configured the real // number is returned, because a Call Miler button that dials nothing is worse // than one that dials a rider — but this is a setting to close before launch, // and §13 of the contract asks product to confirm which it is. func cxMilerContact(phone string) string { if proxy := strings.TrimSpace(os.Getenv("MILER_CALL_PROXY")); proxy != "" { return proxy } return phone }