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,760 @@
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
}