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

@@ -15,6 +15,7 @@ import (
"doormile/db"
"doormile/dto"
"doormile/internal/assignment"
"doormile/internal/cxstage"
"doormile/internal/notify"
"doormile/models"
"doormile/utils"
@@ -2770,6 +2771,18 @@ func AdminCancelBooking(c *fiber.Ctx) error {
return utils.Internal(c, "failed to cancel booking")
}
// Tell the customer's projection too. Without this the pickup keeps
// rendering as active and cancellable in the customer app, because
// customerstatus was written as "active" at booking time and nothing here
// ever moved it. Best-effort and outside the save above: an ops cancel that
// has already committed must not be reported as failed because the
// customer-side write did not land. No-op for console-created bookings.
if err := cxstage.Cancel(db.DB, booking.Bookingid, "Cancelled by Doormile operations",
constants.CxActorOps, opsActorID(c), "POST /admin/bookings/{id}/cancel"); err != nil {
utils.Error("AdminCancelBooking: could not update the customer projection",
"booking_id", booking.Bookingid, "error", err)
}
if booking.Assignedmileruserid != nil {
db.DB.Model(&models.MilerProfile{}).
Where("userid = ?", *booking.Assignedmileruserid).
@@ -2849,6 +2862,15 @@ func AdminBulkCancelBookings(c *fiber.Ctx) error {
continue
}
// Same reason as AdminCancelBooking: without this the pickup keeps
// rendering as active and cancellable in the customer app. No-op for
// console-created bookings.
if err := cxstage.Cancel(db.DB, booking.Bookingid, "Cancelled by Doormile operations",
constants.CxActorOps, opsActorID(c), "POST /admin/bookings/bulk-cancel"); err != nil {
utils.Error("AdminBulkCancelBookings: could not update the customer projection",
"booking_id", booking.Bookingid, "error", err)
}
if booking.Assignedmileruserid != nil {
db.DB.Model(&models.MilerProfile{}).
Where("userid = ?", *booking.Assignedmileruserid).
@@ -3895,3 +3917,13 @@ func InternalReassign(c *fiber.Ctx) error {
"booking_id": booking.Bookingid,
})
}
// opsActorID returns the console user behind an ops action, for the customer's
// audit trail. Nil when the request carries no user id, which is a legitimate
// state for an internal caller rather than something to fail on.
func opsActorID(c *fiber.Ctx) *int {
if uid, ok := c.Locals("userid").(int); ok && uid != 0 {
return &uid
}
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"doormile/constants"
"doormile/db"
"doormile/internal/cxstage"
"doormile/internal/notify"
"doormile/internal/routing"
"doormile/models"
@@ -71,6 +72,21 @@ func assignMilerTx(tx *gorm.DB, bookingID, milerUserID int, assignedByUserID *in
return nil, fmt.Errorf("failed to update miler availability: %w", err)
}
// The customer's "Miler assigned" milestone, recorded where the assignment
// is actually created rather than where a rider taps Accept. A rider who
// never opens the app would otherwise leave the customer watching "finding
// a Miler" while ops has the booking down as assigned — two surfaces
// disagreeing about the same fact.
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
Stage: constants.CxStageAssigned,
ActorType: constants.CxActorOps,
ActorID: assignedByUserID,
Source: "assignMilerTx",
}); err != nil {
return nil, fmt.Errorf("failed to record the assigned stage: %w", err)
}
return &booking, nil
}

View File

@@ -1,29 +1,26 @@
package controllers
import (
"crypto/rand"
"encoding/json"
"fmt"
"math"
"strconv"
"time"
"doormile/config"
"doormile/constants"
"doormile/db"
"doormile/dto"
"doormile/internal/assignment"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
func generateBookingNo() string {
b := make([]byte, 4)
rand.Read(b)
return fmt.Sprintf("DM-BK-%X-%d", b, time.Now().Unix()%100000)
}
// Customer profile and saved addresses.
//
// The rest of the customer surface — auth, catalogue, estimate, bookings,
// tracking, places, devices — lives in the cx*Controller.go files and answers
// in the customer envelope (utils.CxOK / utils.CxFail). The PIN login,
// single-destination booking create/list/detail/cancel and the /customer/track
// read that used to live here were replaced by that surface, not moved: a
// customer books a pickup with 1..N destinations now, and there is no shape in
// which the old single-address request is still a valid booking.
func calculateDistance(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371.0
@@ -40,190 +37,15 @@ func calculateVolumetricWeight(length, width, height float64) float64 {
return (length * width * height) / 5000.0
}
func RegisterCustomer(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.CustomerRegisterRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Phone == "" || req.Firstname == "" || req.Pin == "" {
return utils.BadRequest(c, "phone, firstname, and pin are required")
}
pinHash, err := utils.HashPassword(req.Pin)
if err != nil {
return utils.Internal(c, "failed to process registration")
}
configID := req.Configid
if configID == 0 {
configID = 1001
}
var existing models.AppCustomer
if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&existing).Error; err == nil {
return utils.Conflict(c, "a customer with this phone number already exists")
}
customer := models.AppCustomer{
Firstname: req.Firstname,
Lastname: req.Lastname,
Phone: req.Phone,
Email: req.Email,
Loginpinhash: pinHash,
Status: "Active",
Configid: configID,
}
if err := db.DB.Create(&customer).Error; err != nil {
return utils.Internal(c, "failed to register customer")
}
token, err := utils.GenerateToken(customer.Appcustomerid, customer.Phone, 9, 0, customer.Configid, cfg.JWTSecret)
if err != nil {
return utils.Internal(c, "registration successful but failed to generate token")
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"success": true,
"token": token,
"user": customer,
})
}
}
func LoginCustomer(c *fiber.Ctx) error {
req := new(dto.CustomerLoginRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Phone == "" {
return utils.BadRequest(c, "phone is required")
}
configID := req.Configid
if configID == 0 {
configID = 1001
}
var customer models.AppCustomer
if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil {
return utils.NotFound(c, "no account found for this phone number")
}
if customer.Status == "Blocked" {
return utils.Forbidden(c, "this account has been blocked")
}
return c.JSON(fiber.Map{
"success": true,
"message": "PIN verification required",
"phone": req.Phone,
})
}
func VerifyCustomerPin(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.CustomerPinVerifyRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Phone == "" || req.Pin == "" {
return utils.BadRequest(c, "phone and pin are required")
}
configID := req.Configid
if configID == 0 {
configID = 1001
}
var customer models.AppCustomer
if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil {
return utils.NotFound(c, "customer not found")
}
if !utils.CheckPasswordHash(req.Pin, customer.Loginpinhash) {
return utils.Unauthorized(c, "incorrect PIN")
}
now := time.Now()
customer.Lastloginat = &now
if req.DeviceToken != "" {
customer.Devicetoken = req.DeviceToken
}
db.DB.Save(&customer)
token, err := utils.GenerateToken(customer.Appcustomerid, customer.Phone, 9, 0, customer.Configid, cfg.JWTSecret)
if err != nil {
return utils.Internal(c, "failed to generate token")
}
return c.JSON(fiber.Map{
"success": true,
"token": token,
"user": customer,
})
}
}
func ResetCustomerPin(c *fiber.Ctx) error {
req := new(dto.CustomerResetPinRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Phone == "" || req.NewPin == "" {
return utils.BadRequest(c, "phone and new_pin are required")
}
configID := req.Configid
if configID == 0 {
configID = 1001
}
var customer models.AppCustomer
if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil {
return utils.NotFound(c, "customer not found")
}
// Proof of identity is required before overwriting a login credential.
// Without it this endpoint reset any customer's PIN from their phone number
// alone — and phone numbers are the login identifier, not a secret — so
// reset-pin followed by verify-pin was a complete account takeover.
// The caller must first pass /customer/send-email-otp and
// /customer/verify-email-otp for this account's registered address.
if customer.Email == "" {
return utils.Forbidden(c, "this account has no registered email to verify against — contact support to reset the PIN")
}
if !ConsumeEmailVerification(customer.Email) {
return utils.Forbidden(c, "verify your registered email first via /customer/send-email-otp and /customer/verify-email-otp")
}
pinHash, err := utils.HashPassword(req.NewPin)
if err != nil {
return utils.Internal(c, "failed to process PIN reset")
}
customer.Loginpinhash = pinHash
if err := db.DB.Save(&customer).Error; err != nil {
return utils.Internal(c, "failed to reset PIN")
}
return utils.Message(c, "PIN reset successfully")
}
func GetCustomerProfile(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var customer models.AppCustomer
if err := db.DB.First(&customer, customerID).Error; err != nil {
return utils.NotFound(c, "profile not found")
return utils.CxNotFound(c, "We could not find your profile")
}
return utils.OK(c, customer)
return utils.CxOK(c, renderCustomer(&customer))
}
func UpdateCustomerProfile(c *fiber.Ctx) error {
@@ -231,76 +53,91 @@ func UpdateCustomerProfile(c *fiber.Ctx) error {
var customer models.AppCustomer
if err := db.DB.First(&customer, customerID).Error; err != nil {
return utils.NotFound(c, "profile not found")
return utils.CxNotFound(c, "We could not find your profile")
}
type ProfileUpdate struct {
Firstname string `json:"firstname"`
Lastname string `json:"lastname"`
Email string `json:"email"`
Defaultlatitude float64 `json:"defaultlatitude"`
Defaultlongitude float64 `json:"defaultlongitude"`
Defaultpincode string `json:"defaultpincode"`
// Pointer fields: an omitted key leaves the stored value alone, an explicit
// value overwrites it. The previous version cleared email and lastname on
// every call that did not resend them, which quietly wiped a customer's
// email the first time they edited their name.
var req struct {
Name *string `json:"name"`
Email *string `json:"email"`
Defaultlatitude *float64 `json:"defaultLatitude"`
Defaultlongitude *float64 `json:"defaultLongitude"`
Defaultpincode *string `json:"defaultPincode"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
req := new(ProfileUpdate)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
if req.Name != nil {
first, last := splitName(*req.Name)
if first == "" {
return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name")
}
customer.Firstname, customer.Lastname = first, last
}
if req.Firstname != "" {
customer.Firstname = req.Firstname
if req.Email != nil {
customer.Email = *req.Email
}
customer.Lastname = req.Lastname
customer.Email = req.Email
if req.Defaultlatitude != 0 {
customer.Defaultlatitude = req.Defaultlatitude
if req.Defaultlatitude != nil {
customer.Defaultlatitude = *req.Defaultlatitude
}
if req.Defaultlongitude != 0 {
customer.Defaultlongitude = req.Defaultlongitude
if req.Defaultlongitude != nil {
customer.Defaultlongitude = *req.Defaultlongitude
}
if req.Defaultpincode != "" {
customer.Defaultpincode = req.Defaultpincode
if req.Defaultpincode != nil {
customer.Defaultpincode = *req.Defaultpincode
}
if err := db.DB.Save(&customer).Error; err != nil {
return utils.Internal(c, "failed to update profile")
utils.Error("UpdateCustomerProfile: save failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
return utils.OK(c, customer)
return utils.CxOK(c, renderCustomer(&customer))
}
func GetCustomerLocations(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var locations []models.AppCustomerLocation
if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active").Find(&locations).Error; err != nil {
return utils.Internal(c, "failed to fetch locations")
if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active").
Order("isdefault DESC, appcustomerlocationid DESC").Find(&locations).Error; err != nil {
utils.Error("GetCustomerLocations: query failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
return utils.List(c, locations, int64(len(locations)))
out := make([]fiber.Map, 0, len(locations))
for i := range locations {
out = append(out, renderSavedAddress(&locations[i]))
}
return utils.CxList(c, out, len(out), nil)
}
func CreateCustomerLocation(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var count int64
db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ? AND status = ?", customerID, "Active").Count(&count)
db.DB.Model(&models.AppCustomerLocation{}).
Where("appcustomerid = ? AND status = ?", customerID, "Active").Count(&count)
if count >= 10 {
return utils.BadRequest(c, "maximum of 10 saved locations allowed")
return utils.CxBadRequest(c, "You can save up to 10 addresses")
}
req := new(dto.LocationCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
return utils.CxBadRequest(c, "We could not read that request")
}
if req.Address == "" || req.Pincode == "" || req.Latitude == 0 || req.Longitude == 0 {
return utils.BadRequest(c, "address, pincode, latitude, and longitude are required")
return utils.CxBadRequest(c, "An address needs a street, a pincode and a map location")
}
if req.Isdefault {
db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ?", customerID).Update("isdefault", false)
db.DB.Model(&models.AppCustomerLocation{}).
Where("appcustomerid = ?", customerID).Update("isdefault", false)
}
location := models.AppCustomerLocation{
@@ -320,27 +157,29 @@ func CreateCustomerLocation(c *fiber.Ctx) error {
}
if err := db.DB.Create(&location).Error; err != nil {
return utils.Internal(c, "failed to save location")
utils.Error("CreateCustomerLocation: insert failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
return utils.Created(c, location)
return utils.CxCreated(c, renderSavedAddress(&location))
}
func UpdateCustomerLocation(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
locationID, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid location ID")
return utils.CxBadRequest(c, "That address could not be found")
}
var location models.AppCustomerLocation
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).First(&location).Error; err != nil {
return utils.NotFound(c, "location not found")
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).
First(&location).Error; err != nil {
return utils.CxNotFound(c, "That address could not be found")
}
req := new(dto.LocationCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
return utils.CxBadRequest(c, "We could not read that request")
}
if req.Label != "" {
@@ -366,392 +205,58 @@ func UpdateCustomerLocation(c *fiber.Ctx) error {
location.Isdefault = req.Isdefault
if req.Isdefault {
db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ?", customerID).Update("isdefault", false)
db.DB.Model(&models.AppCustomerLocation{}).
Where("appcustomerid = ?", customerID).Update("isdefault", false)
}
if err := db.DB.Save(&location).Error; err != nil {
return utils.Internal(c, "failed to update location")
utils.Error("UpdateCustomerLocation: save failed", "location_id", locationID, "error", err)
return utils.CxInternal(c)
}
return utils.OK(c, location)
return utils.CxOK(c, renderSavedAddress(&location))
}
func DeleteCustomerLocation(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
locationID, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid location ID")
return utils.CxBadRequest(c, "That address could not be found")
}
var location models.AppCustomerLocation
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).First(&location).Error; err != nil {
return utils.NotFound(c, "location not found")
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).
First(&location).Error; err != nil {
return utils.CxNotFound(c, "That address could not be found")
}
location.Status = "InActive"
db.DB.Save(&location)
if err := db.DB.Save(&location).Error; err != nil {
utils.Error("DeleteCustomerLocation: save failed", "location_id", locationID, "error", err)
return utils.CxInternal(c)
}
return utils.Message(c, "location deleted successfully")
return utils.CxOK(c, fiber.Map{"id": strconv.Itoa(location.Appcustomerlocationid), "deleted": true})
}
func CreateCustomerBooking(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
req := new(dto.PickupBookingRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
// renderSavedAddress is the one shape a saved address is returned in, matching
// the two-line title/sub the pickup search and the booking pickup block use —
// so an address picked from Saved and one picked from search are the same
// object to the client.
func renderSavedAddress(l *models.AppCustomerLocation) fiber.Map {
title := l.Label
if title == "" {
title = l.Address
}
if req.Pickupaddress == "" || req.Pickuppincode == "" {
return utils.BadRequest(c, "pickup address and pincode are required")
}
if len(req.Parcels) == 0 {
return utils.BadRequest(c, "at least one parcel is required")
}
// Geocode delivery pincode to lat/lon when the app doesn't supply coordinates.
if req.Deliverylatitude == 0 && req.Deliverylongitude == 0 && req.Deliverypincode != "" {
if lat, lon, ok := pincodeToLatLon(req.Deliverypincode); ok {
req.Deliverylatitude = lat
req.Deliverylongitude = lon
}
}
tx := db.DB.Begin()
booking := models.PickupBooking{
Bookingno: generateBookingNo(),
Appcustomerid: customerID,
Pickuplocationid: req.Pickuplocationid,
Pickupaddress: req.Pickupaddress,
Pickuppincode: req.Pickuppincode,
Pickuplatitude: req.Pickuplatitude,
Pickuplongitude: req.Pickuplongitude,
Deliveryaddress: req.Deliveryaddress,
Deliverypincode: req.Deliverypincode,
Deliverylatitude: req.Deliverylatitude,
Deliverylongitude: req.Deliverylongitude,
Bookingsource: "Customer_App",
// A B2C booking is always collected at the sender's own door, so the source
// type is recorded rather than left blank — the rider app titles the stop
// with the sender's name and address instead of grouping it under the
// rider's own base.
Pickupsourcetype: constants.PickupSourceCustomer,
Status: constants.BookingPendingPickup,
Preferredpickupfrom: req.Preferredpickupfrom,
Preferredpickupto: req.Preferredpickupto,
}
if err := tx.Create(&booking).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to create booking")
}
var totalWeight float64
var totalVolume float64
var requiresLargeVehicle bool
for _, p := range req.Parcels {
volumetric := calculateVolumetricWeight(p.Length, p.Width, p.Height)
totalWeight += math.Max(p.Weight, volumetric)
totalVolume += p.Length * p.Width * p.Height
parcel := models.BookingParcel{
Bookingid: booking.Bookingid,
Itemcategory: p.Itemcategory,
Itemdescription: p.Itemdescription,
Declaredvalue: p.Declaredvalue,
Weight: p.Weight,
Length: p.Length,
Width: p.Width,
Height: p.Height,
Isfragile: p.Isfragile,
Needsinsurance: p.Needsinsurance,
Requireslargevehicle: p.Requireslargevehicle,
}
if p.Requireslargevehicle {
requiresLargeVehicle = true
}
if p.Needsinsurance {
parcel.Insuranceamount = p.Declaredvalue * 0.01
}
if err := tx.Create(&parcel).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save parcel details")
}
}
serviceType := req.ServiceOption
if serviceType == "" {
serviceType = "Normal"
}
zone := resolveZone(req.Pickuppincode, req.Deliverypincode)
itemCategory := normalizePricingCategory(req.Parcels[0].Itemcategory)
var estimatedPrice float64
var pricingID *int
if price, pid, found := lookupDoormilePrice(zone, mapServiceTypeToPricing(serviceType), totalWeight, itemCategory); found {
estimatedPrice = price
pricingID = pid
} else {
var distance float64
if booking.Deliverylatitude != 0 && booking.Deliverylongitude != 0 {
distance = calculateDistance(booking.Pickuplatitude, booking.Pickuplongitude, booking.Deliverylatitude, booking.Deliverylongitude)
}
estimatedPrice = 50.0 + (distance * 5.0) + (totalWeight * 10.0)
}
now := time.Now()
estDelivery := now.Add(24 * time.Hour)
slaDue := now.Add(36 * time.Hour)
if serviceType == "Fast" {
estDelivery = now.Add(12 * time.Hour)
slaDue = now.Add(18 * time.Hour)
} else if serviceType == "Superfast" {
estDelivery = now.Add(6 * time.Hour)
slaDue = now.Add(9 * time.Hour)
}
srvOption := models.BookingServiceOption{
Bookingid: booking.Bookingid,
Servicetype: serviceType,
Estimatedprice: estimatedPrice,
Pricingid: pricingID,
Estimateddeliveryat: &estDelivery,
Sladueat: &slaDue,
}
if err := tx.Create(&srvOption).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save service option")
}
if requiresLargeVehicle || totalVolume > 0 && totalWeight > 20.0 {
reqVeh := models.BookingVehicleRequirement{
Bookingid: booking.Bookingid,
Requiredvehicletype: "truck",
Reason: "Oversized package / heavy weight",
Status: "Required",
}
if err := tx.Create(&reqVeh).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save vehicle requirement")
}
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to create booking")
}
go assignment.AssignCustomerMiler(booking.Bookingid)
if db.Js != nil {
payload := map[string]interface{}{
"booking_id": booking.Bookingid,
"booking_no": booking.Bookingno,
"customer_id": booking.Appcustomerid,
"pickup_address": booking.Pickupaddress,
"pickup_pincode": booking.Pickuppincode,
"delivery_address": booking.Deliveryaddress,
"delivery_pincode": booking.Deliverypincode,
"status": constants.BookingPendingPickup,
"created_at": time.Now().UnixMilli(),
}
if data, err := json.Marshal(payload); err == nil {
if _, err := db.Js.Publish("api.v1.bookings.create", data); err != nil {
utils.Warn("Failed to publish booking.create to NATS", "booking_id", booking.Bookingid, "error", err)
}
}
}
db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, booking.Bookingid)
return utils.Created(c, booking)
}
// fillConsignmentFacts attaches the consignment's tracking number and live
// status to each booking that has one.
//
// A booking freezes at Converted_To_Consignment the moment it is collected,
// while the parcel keeps moving on the consignment — so without this a customer
// sees a status that stopped updating the instant their parcel was picked up.
//
// The tracking number matters more: GET /customer/track/:trackingno is keyed on
// it, and nothing else the customer app can read exposes it. Tracking was
// unreachable from the app not because the endpoint was missing but because the
// number never travelled to it.
//
// Batched — one query for the whole page, not one per row.
func fillConsignmentFacts(bookings []models.PickupBooking) {
ids := make([]int, 0, len(bookings))
for _, b := range bookings {
if b.Consignmentid != nil {
ids = append(ids, *b.Consignmentid)
}
}
if len(ids) == 0 {
return
}
var consignments []models.Consignment
if err := db.DB.Select("consignmentid, trackingno, status").
Where("consignmentid IN ?", ids).Find(&consignments).Error; err != nil {
utils.Warn("fillConsignmentFacts: could not load consignments", "error", err)
return
}
byID := make(map[int]models.Consignment, len(consignments))
for _, cn := range consignments {
byID[cn.Consignmentid] = cn
}
for i := range bookings {
if bookings[i].Consignmentid == nil {
continue
}
if cn, ok := byID[*bookings[i].Consignmentid]; ok {
bookings[i].Trackingno = cn.Trackingno
bookings[i].Consignmentstatus = cn.Status
}
return fiber.Map{
"id": strconv.Itoa(l.Appcustomerlocationid),
"label": l.Label,
"title": title,
"sub": joinNonEmpty(", ", l.Address, l.Landmark, l.City, l.Pincode),
"recipientName": l.Receivername,
"recipientPhone": l.Receiverphone,
"lat": l.Latitude,
"lng": l.Longitude,
"isDefault": l.Isdefault,
}
}
func GetCustomerBookings(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var bookings []models.PickupBooking
if err := db.DB.Preload("Parcels").Preload("ServiceOptions").Where("appcustomerid = ?", customerID).Order("createdat DESC").Find(&bookings).Error; err != nil {
return utils.Internal(c, "failed to fetch bookings")
}
fillConsignmentFacts(bookings)
return utils.List(c, bookings, int64(len(bookings)))
}
func GetCustomerBookingDetails(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
bookingID, err := strconv.Atoi(c.Params("bookingid"))
if err != nil {
return utils.BadRequest(c, "invalid booking ID")
}
var booking models.PickupBooking
if err := db.DB.Preload("Parcels").Preload("ServiceOptions").Preload("Payments").Where("bookingid = ? AND appcustomerid = ?", bookingID, customerID).First(&booking).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
one := []models.PickupBooking{booking}
fillConsignmentFacts(one)
booking = one[0]
return utils.OK(c, booking)
}
func CancelCustomerBooking(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
bookingID, err := strconv.Atoi(c.Params("bookingid"))
if err != nil {
return utils.BadRequest(c, "invalid booking ID")
}
var booking models.PickupBooking
if err := db.DB.Where("bookingid = ? AND appcustomerid = ?", bookingID, customerID).First(&booking).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
if booking.Status == constants.BookingPickedUp || booking.Status == constants.BookingConvertedConsignment {
return utils.BadRequest(c, "booking cannot be cancelled after the package has been picked up")
}
booking.Status = constants.BookingCancelled
booking.Updatedat = time.Now()
db.DB.Save(&booking)
if db.Js != nil {
payload := map[string]interface{}{
"booking_id": booking.Bookingid,
"booking_no": booking.Bookingno,
"customer_id": booking.Appcustomerid,
"status": "Cancelled",
"cancelled_at": time.Now().UnixMilli(),
}
if data, err := json.Marshal(payload); err == nil {
if _, err := db.Js.Publish("api.v1.bookings.cancel", data); err != nil {
utils.Warn("Failed to publish booking.cancel to NATS", "booking_id", booking.Bookingid, "error", err)
}
}
}
return utils.OK(c, booking)
}
func GetCustomerBookingQuote(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
bookingID, err := strconv.Atoi(c.Params("bookingid"))
if err != nil {
return utils.BadRequest(c, "invalid booking ID")
}
// Ownership is checked here as it is on the other booking routes — without
// it any signed-in customer could read the price quoted on anyone else's
// booking just by walking the id.
var booking models.PickupBooking
if err := db.DB.Select("bookingid").
Where("bookingid = ? AND appcustomerid = ?", bookingID, customerID).
First(&booking).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
var serviceOpt models.BookingServiceOption
if err := db.DB.Where("bookingid = ?", bookingID).Order("createdat DESC").First(&serviceOpt).Error; err != nil {
return utils.NotFound(c, "price quote not found for this booking")
}
return utils.OK(c, serviceOpt)
}
func TrackConsignment(c *fiber.Ctx) error {
trackingNo := c.Params("trackingno")
if trackingNo == "" {
return utils.BadRequest(c, "tracking number is required")
}
var consignment models.Consignment
if err := db.DB.Where("trackingno = ?", trackingNo).First(&consignment).Error; err != nil {
return utils.NotFound(c, "no shipment found for this tracking number")
}
var history []models.ConsignmentHistory
db.DB.Where("consignmentid = ?", consignment.Consignmentid).Order("createdat DESC").Find(&history)
return utils.OK(c, fiber.Map{
"consignment": consignment,
"history": history,
})
}
func SaveCustomerDeviceToken(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var req struct {
DeviceToken string `json:"device_token"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.DeviceToken == "" {
return utils.BadRequest(c, "device_token is required")
}
if err := db.DB.Model(&models.AppCustomer{}).
Where("appcustomerid = ?", customerID).
Update("device_token", req.DeviceToken).Error; err != nil {
return utils.Internal(c, "failed to save device token")
}
return utils.Message(c, "device token saved")
}

View File

@@ -0,0 +1,614 @@
package controllers
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"strings"
"time"
"doormile/config"
"doormile/constants"
"doormile/db"
"doormile/internal/mail"
"doormile/internal/sms"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
goredis "github.com/redis/go-redis/v9"
)
// Customer authentication — §4 of the contract.
//
// A 4-digit code to a phone or an email address, and no password anywhere. This
// is deliberately NOT the miler's phone+PIN flow: /miler/verify-pin exists and
// is not reused. A PIN is a stored secret a rider sets once and a console can
// reset, which is appropriate for a fleet of known employees; a customer base is
// not that, and the previous customer PIN flow shipped a reset endpoint that
// took over any account from a phone number alone.
//
// Every response here goes in `data`, auth included. /miler/verify-pin returns
// its payload outside the envelope and that inconsistency cost the miler client
// a release to discover — it is not repeated.
const (
cxOtpTTL = 5 * time.Minute
cxOtpLength = 4
cxResendWait = 30 * time.Second
// cxOtpMaxVerify is attempts per issued code. Three, then the code dies —
// a 4-digit code is 10,000 combinations and generous retries make it
// walkable.
cxOtpMaxVerify = 3
// cxOtpMaxRequests is codes per identifier per hour, so an attacker cannot
// mint fresh codes to reset the attempt counter, and a victim cannot be
// flooded with texts.
cxOtpMaxRequests = 5
cxOtpRequestWin = time.Hour
cxAccessTTL = time.Hour
cxRefreshTTL = 60 * 24 * time.Hour
// cxCustomerRoleID is role 9 across this codebase.
cxCustomerRoleID = 9
cxDefaultConfig = 1001
)
// ── Identifier handling ──────────────────────────────────────────────────────
// normalizeIdentifier canonicalises what the customer typed into either an
// E.164 phone number or a lowercased email address.
//
// The app currently sends "+91 98765 43210" with spaces and is being tightened
// to send E.164; both are accepted, and both must land on the SAME stored
// value, or a customer signing in from a newer build gets a second account.
func normalizeIdentifier(raw string) (identifier, kind string, ok bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", false
}
if strings.Contains(raw, "@") {
email := strings.ToLower(raw)
// Cheap structural check only. Deliverability is proven by the code
// arriving, not by a regex.
at := strings.Index(email, "@")
if at < 1 || at == len(email)-1 || !strings.Contains(email[at:], ".") {
return "", "", false
}
return email, "email", true
}
return normalizePhone(raw)
}
// normalizePhone reduces any of the shapes the app and support staff use to
// E.164 for India.
func normalizePhone(raw string) (phone, kind string, ok bool) {
var digits strings.Builder
plus := strings.HasPrefix(strings.TrimSpace(raw), "+")
for _, r := range raw {
if r >= '0' && r <= '9' {
digits.WriteRune(r)
}
}
d := digits.String()
switch {
case plus && len(d) >= 11 && len(d) <= 15:
// Already international, spaces and dashes removed.
return "+" + d, "phone", true
case len(d) == 10:
// Bare national number, the common case from the keypad.
return "+91" + d, "phone", true
case len(d) == 12 && strings.HasPrefix(d, "91"):
return "+" + d, "phone", true
case len(d) == 11 && strings.HasPrefix(d, "0"):
return "+91" + d[1:], "phone", true
}
return "", "", false
}
// splitName turns the single name field the app collects into the first/last
// columns appcustomers already has. A one-word name keeps an empty last name
// rather than being rejected — plenty of people have one.
func splitName(full string) (first, last string) {
full = strings.Join(strings.Fields(full), " ")
if len([]rune(full)) < 2 {
return "", ""
}
if i := strings.LastIndex(full, " "); i > 0 {
return full[:i], full[i+1:]
}
return full, ""
}
func fullName(c *models.AppCustomer) string {
return strings.TrimSpace(c.Firstname + " " + c.Lastname)
}
// renderCustomer is the one shape the customer object is returned in — from
// verify, from refresh and from /auth/me — so a cold-start session restore
// cannot disagree with what sign-in returned.
//
// email is never null. The client types it as a non-nullable String and a null
// throws in the parser; an unknown address is the empty string.
func renderCustomer(c *models.AppCustomer) fiber.Map {
return fiber.Map{
"id": fmt.Sprintf("cust_%d", c.Appcustomerid),
"name": fullName(c),
"phone": c.Phone,
"email": c.Email,
}
}
// ── OTP storage ──────────────────────────────────────────────────────────────
func cxOtpKey(id string) string { return "cx:otp:" + id }
func cxOtpTriesKey(id string) string { return "cx:otp:" + id + ":tries" }
func cxOtpSentKey(id string) string { return "cx:otp:" + id + ":sent" }
func cxOtpRateKey(id string) string { return "cx:otp:" + id + ":requests" }
func cxGenerateCode() string {
max := big.NewInt(1)
for i := 0; i < cxOtpLength; i++ {
max.Mul(max, big.NewInt(10))
}
n, err := rand.Int(rand.Reader, max)
if err != nil {
return ""
}
return fmt.Sprintf("%0*d", cxOtpLength, n.Int64())
}
// issueCxOtp mints, stores and delivers a code, enforcing both the per-hour
// request cap and the resend cooldown. Returns the seconds the client must wait
// before it may ask again — the countdown is server-driven so it can be changed
// without an app release.
func issueCxOtp(cfg *config.Config, identifier, kind string) (resendAfter int, err error) {
if db.Rdb == nil {
return 0, fmt.Errorf("verification service unavailable")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Cooldown first: a customer hammering Resend should be told to wait, not
// spend one of their five hourly codes on a request that sends nothing.
if ttl, terr := db.Rdb.TTL(ctx, cxOtpSentKey(identifier)).Result(); terr == nil && ttl > 0 {
return int(ttl.Seconds()) + 1, errCxResendTooSoon
}
count, ierr := db.Rdb.Incr(ctx, cxOtpRateKey(identifier)).Result()
if ierr == nil && count == 1 {
db.Rdb.Expire(ctx, cxOtpRateKey(identifier), cxOtpRequestWin)
}
if count > cxOtpMaxRequests {
return int(cxOtpRequestWin.Seconds()), errCxTooManyRequests
}
code := sms.StagingCode()
if code == "" {
code = cxGenerateCode()
}
if code == "" {
return 0, fmt.Errorf("could not generate a verification code")
}
if serr := db.Rdb.Set(ctx, cxOtpKey(identifier), code, cxOtpTTL).Err(); serr != nil {
return 0, serr
}
db.Rdb.Del(ctx, cxOtpTriesKey(identifier))
db.Rdb.Set(ctx, cxOtpSentKey(identifier), "1", cxResendWait)
if kind == "email" {
if merr := mail.SendOTPEmail(cfg, identifier, code); merr != nil {
utils.Warn("cx auth: failed to send OTP email", "error", merr)
return 0, merr
}
} else {
if serr := sms.SendOTP(identifier, code); serr != nil {
utils.Warn("cx auth: failed to send OTP sms", "error", serr)
return 0, serr
}
}
return int(cxResendWait.Seconds()), nil
}
var (
errCxResendTooSoon = fmt.Errorf("resend too soon")
errCxTooManyRequests = fmt.Errorf("too many requests")
)
// consumeCxOtp checks a submitted code and burns it. A code is single-use, and
// a wrong answer costs one of three attempts before the code is destroyed
// outright — otherwise a 4-digit space is walkable.
func consumeCxOtp(identifier, submitted string) bool {
if db.Rdb == nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
stored, err := db.Rdb.Get(ctx, cxOtpKey(identifier)).Result()
if err == goredis.Nil || err != nil {
return false
}
if stored != submitted {
tries, _ := db.Rdb.Incr(ctx, cxOtpTriesKey(identifier)).Result()
db.Rdb.Expire(ctx, cxOtpTriesKey(identifier), cxOtpTTL)
if tries >= cxOtpMaxVerify {
db.Rdb.Del(ctx, cxOtpKey(identifier), cxOtpTriesKey(identifier))
}
return false
}
db.Rdb.Del(ctx, cxOtpKey(identifier), cxOtpTriesKey(identifier))
return true
}
// ── Handlers ─────────────────────────────────────────────────────────────────
// CxRequestOtp sends a sign-in code to a phone or an email address.
//
// It answers the same way whether or not the identifier has an account. Telling
// an anonymous caller "no account found" — which the old /customer/login did —
// turns this endpoint into a directory of who is registered.
func CxRequestOtp(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Identifier string `json:"identifier"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
identifier, kind, ok := normalizeIdentifier(req.Identifier)
if !ok {
return utils.CxBadRequest(c, "Enter a valid phone number or email address")
}
resendAfter, err := issueCxOtp(cfg, identifier, kind)
switch {
case err == errCxResendTooSoon:
// Not an error to the customer — they simply have to wait, and the
// screen already renders a countdown.
return utils.CxOK(c, fiber.Map{
"sent": false,
"resendAfterSeconds": resendAfter,
"codeLength": cxOtpLength,
})
case err == errCxTooManyRequests:
c.Set("Retry-After", fmt.Sprintf("%d", resendAfter))
return utils.CxFail(c, fiber.StatusTooManyRequests, utils.CxErrRateLimited,
"Too many attempts. Try again in a minute")
case err != nil:
utils.Error("CxRequestOtp: could not issue code", "error", err)
return utils.CxInternal(c)
}
return utils.CxOK(c, fiber.Map{
"sent": true,
"resendAfterSeconds": resendAfter,
"codeLength": cxOtpLength,
})
}
}
// CxSignup creates the account and sends the code in one call.
//
// An existing phone number is NOT an error: it is treated as a sign-in and a
// code is sent. The app has no "account already exists" screen, and inventing
// one here would strand a returning customer who tapped Sign up out of habit.
func CxSignup(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Name string `json:"name"`
Phone string `json:"phone"`
Email string `json:"email"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
first, last := splitName(req.Name)
if first == "" {
return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name")
}
phone, _, ok := normalizePhone(req.Phone)
if !ok {
return utils.CxBadRequest(c, "Enter a valid phone number")
}
email := strings.ToLower(strings.TrimSpace(req.Email))
var existing models.AppCustomer
err := db.DB.Where("phone = ?", phone).First(&existing).Error
if err != nil {
// New account. It is created unverified in the sense that nothing
// is signed in yet — the token is only issued once the code comes
// back, so an unfinished signup leaves a row and no session.
customer := models.AppCustomer{
Firstname: first,
Lastname: last,
Phone: phone,
Email: email,
Status: constants.CustomerStatusActive,
Configid: cxDefaultConfig,
}
if cerr := db.DB.Create(&customer).Error; cerr != nil {
utils.Error("CxSignup: could not create customer", "error", cerr)
return utils.CxInternal(c)
}
} else if existing.Status == constants.CustomerStatusBlocked {
return utils.CxForbidden(c, "You do not have access to this")
}
resendAfter, ierr := issueCxOtp(cfg, phone, "phone")
switch {
case ierr == errCxResendTooSoon:
return utils.CxOK(c, fiber.Map{"sent": false, "resendAfterSeconds": resendAfter})
case ierr == errCxTooManyRequests:
c.Set("Retry-After", fmt.Sprintf("%d", resendAfter))
return utils.CxFail(c, fiber.StatusTooManyRequests, utils.CxErrRateLimited,
"Too many attempts. Try again in a minute")
case ierr != nil:
utils.Error("CxSignup: could not issue code", "error", ierr)
return utils.CxInternal(c)
}
return utils.CxOK(c, fiber.Map{"sent": true, "resendAfterSeconds": resendAfter})
}
}
// CxVerifyOtp exchanges a code for a session.
func CxVerifyOtp(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Identifier string `json:"identifier"`
Code string `json:"code"`
Name string `json:"name"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
identifier, kind, ok := normalizeIdentifier(req.Identifier)
if !ok || strings.TrimSpace(req.Code) == "" {
return utils.CxBadRequest(c, "Enter the code we sent you")
}
if !consumeCxOtp(identifier, strings.TrimSpace(req.Code)) {
return utils.CxFail(c, fiber.StatusUnauthorized, utils.CxErrInvalidOtp, "That code did not match")
}
var customer models.AppCustomer
column := "phone"
if kind == "email" {
column = "email"
}
lookupErr := db.DB.Where(column+" = ?", identifier).First(&customer).Error
if lookupErr != nil {
// Verified an identifier with no account behind it. That is a
// signup completing, and it needs a name — the account is worth
// nothing without one and the app collects it on the same screen.
if kind != "phone" {
return utils.CxNotFound(c, "We could not find an account for that address")
}
first, last := splitName(req.Name)
if first == "" {
return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name")
}
customer = models.AppCustomer{
Firstname: first,
Lastname: last,
Phone: identifier,
Status: constants.CustomerStatusActive,
Configid: cxDefaultConfig,
}
if cerr := db.DB.Create(&customer).Error; cerr != nil {
utils.Error("CxVerifyOtp: could not create customer", "error", cerr)
return utils.CxInternal(c)
}
}
if customer.Status == constants.CustomerStatusBlocked {
return utils.CxForbidden(c, "You do not have access to this")
}
// A name supplied on a verify for an existing account that has none
// (possible for a row created by ops or migrated in) is accepted; it is
// never allowed to overwrite a name already on file from a request that
// only proves possession of the phone.
if first, last := splitName(req.Name); first != "" && customer.Firstname == "" {
customer.Firstname, customer.Lastname = first, last
}
now := time.Now()
customer.Lastloginat = &now
if err := db.DB.Save(&customer).Error; err != nil {
utils.Warn("CxVerifyOtp: could not stamp last login", "error", err)
}
return issueCxSession(c, cfg, &customer)
}
}
// CxRefresh rotates a refresh token for a new pair.
//
// Rotation, not reuse: the presented token is revoked and a new one issued, so
// a token captured from an old device stops working the moment the real device
// refreshes. The chain is recorded via Replacedbyid, which is what makes a
// replayed old token identifiable rather than merely rejected.
func CxRefresh(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
RefreshToken string `json:"refreshToken"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
presented := strings.TrimSpace(req.RefreshToken)
if presented == "" {
return utils.CxUnauthorized(c, "Please sign in again")
}
var row models.CustomerRefreshToken
if err := db.DB.Where("tokenhash = ?", hashToken(presented)).First(&row).Error; err != nil {
return utils.CxUnauthorized(c, "Please sign in again")
}
if row.Revokedat != nil {
// A revoked token coming back means either a stale client or a
// stolen one, and there is no way to tell them apart. Killing the
// whole chain costs the honest customer one sign-in and costs an
// attacker the session.
utils.Warn("cx auth: revoked refresh token replayed — revoking the customer's sessions",
"customer_id", row.Appcustomerid)
revokeCxSessions(row.Appcustomerid)
return utils.CxUnauthorized(c, "Please sign in again")
}
if utils.IST(row.Expiresat).Before(time.Now()) {
return utils.CxUnauthorized(c, "Please sign in again")
}
var customer models.AppCustomer
if err := db.DB.First(&customer, row.Appcustomerid).Error; err != nil {
return utils.CxUnauthorized(c, "Please sign in again")
}
if customer.Status == constants.CustomerStatusBlocked {
return utils.CxForbidden(c, "You do not have access to this")
}
revoked := time.Now()
row.Revokedat = &revoked
if err := db.DB.Save(&row).Error; err != nil {
utils.Error("CxRefresh: could not revoke the presented token", "error", err)
return utils.CxInternal(c)
}
return issueCxSession(c, cfg, &customer)
}
}
// CxLogout revokes the session and unregisters the device's push token, so a
// signed-out phone stops receiving another person's parcel updates.
func CxLogout(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var req struct {
RefreshToken string `json:"refreshToken"`
DeviceToken string `json:"deviceToken"`
}
_ = c.BodyParser(&req)
now := time.Now()
if strings.TrimSpace(req.RefreshToken) != "" {
db.DB.Model(&models.CustomerRefreshToken{}).
Where("tokenhash = ? AND appcustomerid = ?", hashToken(req.RefreshToken), customerID).
Update("revokedat", now)
} else {
// No token supplied — sign out everywhere rather than leave a session
// the customer believes they ended.
revokeCxSessions(customerID)
}
if strings.TrimSpace(req.DeviceToken) != "" {
db.DB.Where("appcustomerid = ? AND token = ?", customerID, req.DeviceToken).
Delete(&models.CustomerDevice{})
}
return utils.CxOK(c, fiber.Map{"signedOut": true})
}
// CxMe returns the signed-in customer, for cold-start session restore.
func CxMe(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var customer models.AppCustomer
if err := db.DB.First(&customer, customerID).Error; err != nil {
return utils.CxUnauthorized(c, "Please sign in again")
}
if customer.Status == constants.CustomerStatusBlocked {
return utils.CxForbidden(c, "You do not have access to this")
}
return utils.CxOK(c, renderCustomer(&customer))
}
// ── Session issuing ──────────────────────────────────────────────────────────
// issueCxSession mints the access/refresh pair and answers in the shape verify
// and refresh both promise.
func issueCxSession(c *fiber.Ctx, cfg *config.Config, customer *models.AppCustomer) error {
// The JWT carries tenantid like every other token in this system. B2C
// bookings are not attributed to a tenant yet (that is an open business
// decision, not something to guess), so it is 0 here — but the claim is
// present, and every customer read is scoped by appcustomerid regardless.
access, err := utils.GenerateTokenWithTTL(
customer.Appcustomerid, customer.Phone, cxCustomerRoleID, 0,
customer.Configid, cfg.JWTSecret, cxAccessTTL)
if err != nil {
utils.Error("issueCxSession: could not mint access token", "error", err)
return utils.CxInternal(c)
}
refresh, err := newRefreshToken()
if err != nil {
utils.Error("issueCxSession: could not mint refresh token", "error", err)
return utils.CxInternal(c)
}
row := models.CustomerRefreshToken{
Appcustomerid: customer.Appcustomerid,
Tokenhash: hashToken(refresh),
Expiresat: utils.DBNow().Add(cxRefreshTTL),
}
if err := db.DB.Create(&row).Error; err != nil {
utils.Error("issueCxSession: could not store refresh token", "error", err)
return utils.CxInternal(c)
}
return utils.CxOK(c, fiber.Map{
"accessToken": access,
"refreshToken": refresh,
"expiresIn": int(cxAccessTTL.Seconds()),
"customer": renderCustomer(customer),
})
}
// newRefreshToken returns 32 bytes of entropy, hex encoded. Long enough that
// guessing is not a threat model.
func newRefreshToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// hashToken is what actually lands in the database. A refresh token is a
// bearer credential valid for sixty days; storing it in plaintext would make a
// database read equivalent to sixty days of account access.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func revokeCxSessions(customerID int) {
if err := db.DB.Model(&models.CustomerRefreshToken{}).
Where("appcustomerid = ? AND revokedat IS NULL", customerID).
Update("revokedat", time.Now()).Error; err != nil {
utils.Error("revokeCxSessions: failed", "customer_id", customerID, "error", err)
}
}
// joinNonEmpty builds a display line from the parts that actually exist, so a
// missing landmark does not leave ", , " in the middle of an address.
func joinNonEmpty(sep string, parts ...string) string {
kept := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
kept = append(kept, s)
}
}
return strings.Join(kept, sep)
}

View File

@@ -0,0 +1,895 @@
package controllers
import (
"encoding/json"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/assignment"
"doormile/internal/cxstage"
"doormile/middlewares"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Bookings — §9 of the contract.
//
// A customer books a PICKUP: one visit, 1..N destinations, and no tracking
// number anywhere in this file. Tracking numbers are minted per destination
// when the miler completes the pickup, because until the parcels are in
// someone's hands there is no shipment to track — only an intention to collect
// one.
const (
cxDefaultPageSize = 20
cxMaxPageSize = 50
// cxAbsoluteMaxDestinations is a hard ceiling checked BEFORE any database
// work, independent of the configured per-city cap.
//
// The configured cap (customerbookinglimits.maxdestinations) is the real
// policy and stays authoritative — but reading it costs two queries, and
// the district lookup above it builds a `WHERE districtcode IN (...)` from
// however many entries the caller sent. Without a ceiling, a request
// carrying ten thousand destinations does all of that work before anything
// says no.
//
// Set well above any plausible policy so it never masks the real cap: this
// is a sanity guard on unbounded input, not a product limit.
cxAbsoluteMaxDestinations = 25
)
type cxDetailsInput struct {
Street *string `json:"street"`
Building *string `json:"building"`
Landmark *string `json:"landmark"`
RecipientName *string `json:"recipientName"`
RecipientPhone *string `json:"recipientPhone"`
Instructions *string `json:"instructions"`
Pin *struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
} `json:"pin"`
// CodAmount is money the customer wants collected at this door on their
// behalf. Doormile is the carrier, not the seller.
CodAmount *float64 `json:"codAmount"`
}
type cxCreateBookingRequest struct {
Pickup struct {
Title string `json:"title"`
Sub string `json:"sub"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
} `json:"pickup"`
SlotID string `json:"slotId"`
Destinations []struct {
StateCode string `json:"stateCode"`
DistrictCode string `json:"districtCode"`
PackageCount int `json:"packageCount"`
Details *cxDetailsInput `json:"details"`
} `json:"destinations"`
// Estimate is what the customer was shown on Review. Recorded for dispute
// audit — when the settled price is questioned months later, the number on
// the screen is the fact that matters, not a re-run of today's pricing.
Estimate *struct {
Min int `json:"min"`
Max int `json:"max"`
} `json:"estimate"`
}
// CreateCxBooking creates the pickup.
func CreateCxBooking(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var req cxCreateBookingRequest
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
// "No destinations at all" and "a destination is missing its state or
// district" are different problems with different fixes, and the customer
// is shown this message verbatim. Telling someone who added nothing that
// "every destination needs a serviceable state and district" describes a
// problem they do not have and hides the one they do — they need to add a
// destination, not correct one. The estimate endpoint already says this
// correctly; these two now agree.
if len(req.Destinations) == 0 {
return utils.CxBadRequest(c, "Add at least one destination")
}
if strings.TrimSpace(req.SlotID) == "" {
return utils.CxBadRequest(c, "Pick a pickup slot")
}
// Cheapest validation first, before anything reaches the database. A slot id
// carries its own date, so a stale one is provably stale without a query —
// and this is the case an app left open across midnight actually hits.
if CxSlotDateIsPast(req.SlotID) {
return utils.CxBadRequest(c, "That pickup time has passed — pick a new slot")
}
// Unbounded input, bounded before it costs anything. The configured cap
// below is the real policy; this only stops a caller making the server do
// three queries and build an arbitrarily long IN clause to be told no.
if len(req.Destinations) > cxAbsoluteMaxDestinations {
return utils.CxBadRequest(c, "That is more destinations than one pickup can carry")
}
// The pickup point has to be somewhere Doormile actually collects from.
// CityGateMiddleware sniffs the body for a `pickuppincode`, which this
// request shape does not have — its pickup is a title/sub/lat/lng from the
// place search — so it waves every customer booking through. The check is
// done here, against the pincode resolved from the coordinates.
pickupPincode := pincodeForPoint(req.Pickup.Lat, req.Pickup.Lng)
if _, served := middlewares.PincodeInOperatingCity(pickupPincode); !served {
return utils.CxFail(c, fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable,
"We are not collecting from that area yet")
}
appLocationID := appLocationForPoint(req.Pickup.Lat, req.Pickup.Lng)
maxPackages, maxDestinations := CxBookingLimits(appLocationID)
if len(req.Destinations) > maxDestinations {
return utils.CxBadRequest(c, "Up to "+strconv.Itoa(maxDestinations)+" destinations per pickup")
}
// Resolve every district in one query, then validate. Names are copied onto
// the destination rather than joined at read time — the client renders
// "Chennai, Tamil Nadu" straight from the booking.
codes := make([]string, 0, len(req.Destinations))
for _, d := range req.Destinations {
codes = append(codes, strings.ToUpper(strings.TrimSpace(d.DistrictCode)))
}
var districtRows []models.ServiceableDistrict
if err := db.DB.Where("districtcode IN ?", codes).Find(&districtRows).Error; err != nil {
utils.Error("CreateCxBooking: district lookup failed", "error", err)
return utils.CxInternal(c)
}
districts := make(map[string]models.ServiceableDistrict, len(districtRows))
for _, d := range districtRows {
districts[d.Districtcode] = d
}
stateNames, err := cxStateNames(districtRows)
if err != nil {
utils.Error("CreateCxBooking: state lookup failed", "error", err)
return utils.CxInternal(c)
}
totalPackages := 0
for _, d := range req.Destinations {
code := strings.ToUpper(strings.TrimSpace(d.DistrictCode))
district, ok := districts[code]
if !ok || strings.TrimSpace(d.StateCode) == "" {
return utils.CxBadRequest(c, "Every destination needs a serviceable state and district")
}
if !district.Available {
// The district was open when the customer picked it and closed
// before they confirmed. A distinct code, because the app has a
// specific recovery for it: send them back to change that one
// destination rather than to a generic retry.
return utils.CxFail(c, fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable,
"That district is no longer available")
}
packages := d.PackageCount
if packages < 1 {
packages = 1
}
totalPackages += packages
}
if totalPackages > maxPackages {
return utils.CxBadRequest(c, "Up to "+strconv.Itoa(maxPackages)+" packages per pickup")
}
slotFrom, slotTo, slotOK := ResolveCxSlot(req.SlotID)
if !slotOK {
return utils.CxBadRequest(c, "Pick a pickup slot")
}
// An EXPIRED slot and a FULL slot are different failures and must not share
// a message. A slot id encodes its own date, so an app left open across
// midnight — or one that cached the slot list for a session — sends
// yesterday's window in good faith. Telling that customer the window "just
// filled up" is untrue and points them at the wrong recovery: they need to
// re-fetch the slot list, not try again for a place in a queue.
//
// 400 rather than 409 for the same reason. The contract maps 400/invalid to
// "Pick a pickup slot", which is exactly the action required, while 409 is
// the capacity race below.
if !slotFrom.After(utils.ISTNow()) {
return utils.CxBadRequest(c, "That pickup time has passed — pick a new slot")
}
if !CxSlotHasCapacity(req.SlotID, req.Pickup.Lat, req.Pickup.Lng) {
return utils.CxConflict(c, "That pickup window just filled up")
}
// Price the pickup as one visit. A failed estimate must never block a
// booking, so a zero range is stored rather than an error returned — the
// receipt settles on what the miler weighs regardless.
estimateDestinations := make([]cxEstimateDestination, 0, len(req.Destinations))
for _, d := range req.Destinations {
estimateDestinations = append(estimateDestinations, cxEstimateDestination{
StateCode: d.StateCode,
DistrictCode: d.DistrictCode,
PackageCount: d.PackageCount,
})
}
quote := quoteCxPickup(req.Pickup.Lat, req.Pickup.Lng, estimateDestinations)
estimateMin, estimateMax := quote.Min, quote.Max
if req.Estimate != nil && req.Estimate.Max > 0 {
// The customer's number wins. They agreed to what was on their screen,
// and re-pricing at confirm time would quietly change the deal.
estimateMin, estimateMax = req.Estimate.Min, req.Estimate.Max
}
now := utils.DBNow()
pickupFromDB := cxToDBTime(slotFrom)
pickupToDB := cxToDBTime(slotTo)
first := req.Destinations[0]
firstDistrict := districts[strings.ToUpper(strings.TrimSpace(first.DistrictCode))]
booking := models.PickupBooking{
Bookingno: generateBookingNo(),
Appcustomerid: customerID,
Pickupaddress: joinNonEmpty(", ", req.Pickup.Title, req.Pickup.Sub),
Pickuptitle: req.Pickup.Title,
Pickupsub: req.Pickup.Sub,
Pickuppincode: pickupPincode,
Pickuplatitude: req.Pickup.Lat,
Pickuplongitude: req.Pickup.Lng,
// The flat delivery columns mirror destination 0. They are NOT the
// destination list — that lives in bookingdestinations — but the miler
// app, the hub console, the routing code and the hyperlocal check all
// read them, and leaving them empty would make a customer-app booking
// invisible to every one of those. Destination 0 is the one the rider
// is told about first, so it is the one that mirrors.
Deliveryaddress: cxDestinationAddress(first.Details, firstDistrict),
Deliverypincode: firstDistrict.Pincodeprefix,
Deliverylatitude: firstDistrict.Centrelatitude,
Deliverylongitude: firstDistrict.Centrelongitude,
Deliverycity: firstDistrict.Districtname,
Bookingsource: constants.BookingSourceCustomerApp,
Pickupsourcetype: constants.PickupSourceCustomer,
Status: constants.BookingPendingPickup,
Preferredpickupfrom: &pickupFromDB,
Preferredpickupto: &pickupToDB,
Slotid: req.SlotID,
Customerstage: constants.CxStageBooked,
Customerstatus: constants.CxStatusActive,
Estimateminrupees: estimateMin,
Estimatemaxrupees: estimateMax,
Routekm: quote.RouteKM,
Createdat: now,
Updatedat: now,
}
if pin := cxFirstPin(first.Details); pin != nil {
booking.Deliverylatitude, booking.Deliverylongitude = pin[0], pin[1]
}
tx := db.DB.Begin()
if err := tx.Create(&booking).Error; err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not create booking", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
for i, d := range req.Destinations {
code := strings.ToUpper(strings.TrimSpace(d.DistrictCode))
district := districts[code]
packages := d.PackageCount
if packages < 1 {
packages = 1
}
dest := models.BookingDestination{
Bookingid: booking.Bookingid,
Seq: i,
Statecode: district.Statecode,
Statename: stateNames[district.Statecode],
Districtcode: district.Districtcode,
Districtname: district.Districtname,
Packagecount: packages,
Pincode: district.Pincodeprefix,
Createdat: now,
Updatedat: now,
}
applyCxDetails(&dest, d.Details)
if err := tx.Create(&dest).Error; err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not create destination", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
// One parcel row per package, linked to its destination. The miler
// weighs and photographs each package at the door, so each needs a row
// to be weighed into — and each has to know which order it belongs to.
// Weight is deliberately left at zero: it is never collected from the
// customer, and a guess here would look like a measurement on the
// receipt.
for p := 0; p < packages; p++ {
parcel := models.BookingParcel{
Bookingid: booking.Bookingid,
Bookingdestinationid: &dest.Bookingdestinationid,
Itemcategory: "General",
Createdat: now,
Updatedat: now,
}
if err := tx.Create(&parcel).Error; err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not create parcel", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
}
}
// The service option is what the REST of the platform reads a booking's
// value from, and it is not optional just because the customer surface
// keeps its own estimate columns:
//
// * MilerDeliverConsignment and MilerInwardConsignmentAtHub copy
// Estimatedprice onto BookingAssignment.ridercharges when a leg closes,
// and GET /miler/earnings sums that column — so with no row here every
// customer-app job a rider completes would show ₹0 on their Earnings
// screen.
// * The admin console's Orders list renders serviceoptions[0].estimatedprice
// as the Price column, which would read "N/A" for every customer booking.
//
// The midpoint of the band the customer was shown is the honest figure
// before the miler weighs anything, and it is what lookupDoormilePrice
// returns everywhere else in this codebase.
slaDue := cxToDBTime(slotTo.Add(48 * time.Hour))
estimatedDelivery := cxToDBTime(slotTo.Add(24 * time.Hour))
srvOption := models.BookingServiceOption{
Bookingid: booking.Bookingid,
Servicetype: "Normal",
Estimatedprice: float64(estimateMin+estimateMax) / 2,
Estimateddeliveryat: &estimatedDelivery,
Sladueat: &slaDue,
Createdat: now,
}
if err := tx.Create(&srvOption).Error; err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not create service option", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
Stage: constants.CxStageBooked,
ActorType: constants.CxActorCustomer,
ActorID: &customerID,
Source: "POST /customer/bookings",
At: now,
}); err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not record booked stage", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
if err := tx.Commit().Error; err != nil {
utils.Error("CreateCxBooking: commit failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
// Fire-and-forget, after commit, exactly as the express path does.
go assignment.AssignCustomerMiler(booking.Bookingid)
publishCxBookingCreated(&booking, len(req.Destinations))
return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusCreated)
}
// GetCxBookings backs the Orders tabs, Home's recent list and pull-to-refresh.
func GetCxBookings(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
limit := cxDefaultPageSize
if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 {
limit = v
}
if limit > cxMaxPageSize {
limit = cxMaxPageSize
}
// One filter, applied to both the page and the count, so `total` is the size
// of the tab the customer is actually looking at. Counting every booking
// they have ever made would put "48" above a Cancelled tab holding two rows.
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
scoped := func() *gorm.DB {
q := db.DB.Model(&models.PickupBooking{}).Where("appcustomerid = ?", customerID)
switch status {
case constants.CxStatusActive:
// Rows written before this surface existed carry no customerstatus.
// Treating a null as active keeps a live booking visible rather
// than hiding it from the customer who is waiting on it.
// Parenthesised explicitly rather than relying on AND binding
// tighter than OR — the two readings differ by "shows every
// cancelled booking in the active tab", which is not a thing to
// leave to operator precedence.
q = q.Where("(customerstatus = ?) OR ((customerstatus IS NULL OR customerstatus = '') AND status <> ?)",
constants.CxStatusActive, constants.BookingCancelled)
case constants.CxStatusCompleted:
q = q.Where("customerstatus = ?", constants.CxStatusCompleted)
case constants.CxStatusCancelled:
q = q.Where("customerstatus = ? OR status = ?", constants.CxStatusCancelled, constants.BookingCancelled)
}
return q
}
q := scoped()
// Keyset pagination on the primary key. Offsets drift when a new booking
// lands mid-scroll, which shows the customer the same row twice.
if cursor := strings.TrimSpace(c.Query("cursor")); cursor != "" {
if after, err := strconv.Atoi(cursor); err == nil {
q = q.Where("bookingid < ?", after)
}
}
var bookings []models.PickupBooking
if err := q.Order("bookingid DESC").Limit(limit + 1).Find(&bookings).Error; err != nil {
utils.Error("GetCxBookings: query failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
var nextCursor *string
if len(bookings) > limit {
bookings = bookings[:limit]
next := strconv.Itoa(bookings[len(bookings)-1].Bookingid)
nextCursor = &next
}
bundle := loadCxBundle(bookings)
out := make([]fiber.Map, 0, len(bookings))
for i := range bookings {
out = append(out, renderCxBooking(&bookings[i], bundle))
}
var total int64
if err := scoped().Count(&total).Error; err != nil {
utils.Warn("GetCxBookings: count failed, reporting the page size", "customer_id", customerID, "error", err)
total = int64(len(out))
}
return utils.CxList(c, out, int(total), nextCursor)
}
// GetCxBookingDetail is the canonical read — the tracking screen and the
// receipt are both rendered from it, and it is polled while tracking is open.
func GetCxBookingDetail(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
reference := strings.TrimSpace(c.Params("reference"))
booking, err := cxLoadBooking(customerID, reference)
if err != nil {
return utils.CxNotFound(c, "We could not find that pickup")
}
bundle := loadCxBundle([]models.PickupBooking{*booking})
payload := renderCxBooking(booking, bundle)
// Polled every few seconds while the tracking screen is open. A 304 turns
// most of those polls into a header exchange instead of a full render.
if served := serveIfNotModified(c, payload); served {
return nil
}
c.Set("Cache-Control", "no-cache")
return utils.CxOK(c, payload)
}
// GetCxOrder returns one order by tracking number, for push deep links.
//
// It answers with the whole booking object rather than a slimmer order shape —
// the client already parses this one, and a second shape for the same data is
// a second parser to keep in step.
func GetCxOrder(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
trackingID := strings.TrimSpace(c.Params("trackingId"))
if trackingID == "" {
return utils.CxNotFound(c, "We could not find that order")
}
var dest models.BookingDestination
if err := db.DB.Where("trackingno = ?", trackingID).First(&dest).Error; err != nil {
return utils.CxNotFound(c, "We could not find that order")
}
var booking models.PickupBooking
if err := db.DB.Where("bookingid = ? AND appcustomerid = ?", dest.Bookingid, customerID).
First(&booking).Error; err != nil {
// The tracking number exists but belongs to someone else. Answered as
// not-found rather than forbidden: confirming that a tracking number is
// real tells an enumerating caller something they should not learn.
return utils.CxNotFound(c, "We could not find that order")
}
bundle := loadCxBundle([]models.PickupBooking{booking})
return utils.CxOK(c, renderCxBooking(&booking, bundle))
}
// CancelCxBooking cancels the whole pickup.
//
// Allowed through arrived and refused from picked_up onward. `cancellable` on
// the booking mirrors the same policy so the UI can hide the button, but this
// re-checks — the button state is a hint the client renders from a response
// that may be seconds old, never the authority.
func CancelCxBooking(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
reference := strings.TrimSpace(c.Params("reference"))
var req struct {
Reason string `json:"reason"`
}
_ = c.BodyParser(&req)
booking, err := cxLoadBooking(customerID, reference)
if err != nil {
return utils.CxNotFound(c, "We could not find that pickup")
}
if booking.Customerstatus == constants.CxStatusCancelled ||
booking.Status == constants.BookingCancelled {
// Already cancelled. Answered as success rather than as a conflict: the
// customer asked for a state the booking is already in, and a retry
// over a flaky network must not read as a failure.
return utils.CxOK(c, fiber.Map{
"reference": booking.Bookingno,
"status": constants.CxStatusCancelled,
"cancelReason": booking.Cancelreason,
})
}
stage := booking.Customerstage
if stage == "" {
stage = deriveStageFromStatus(booking)
}
if !constants.CxCancellable(stage) {
return utils.CxConflict(c, "This pickup can no longer be cancelled")
}
reason := strings.TrimSpace(req.Reason)
tx := db.DB.Begin()
if err := cxstage.Cancel(tx, booking.Bookingid, reason,
constants.CxActorCustomer, &customerID, "POST /customer/bookings/{reference}/cancel"); err != nil {
tx.Rollback()
utils.Error("CancelCxBooking: cancel failed", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
// Release the rider. Without this the assignment stays open, the rider
// keeps a stop they must not attempt, and MilerEndDuty refuses to let them
// go off duty while any assignment is still Assigned or Accepted.
if err := tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND assignmentstatus IN ?", booking.Bookingid,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentCancelled,
"remarks": "cancelled by customer",
}).Error; err != nil {
tx.Rollback()
utils.Error("CancelCxBooking: could not release assignment", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
if booking.Assignedmileruserid != nil {
if err := tx.Model(&models.MilerProfile{}).
Where("userid = ? AND availabilitystatus IN ?", *booking.Assignedmileruserid,
[]string{constants.MilerAssigned, constants.MilerOnPickup, constants.MilerAtCustomer}).
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
tx.Rollback()
utils.Error("CancelCxBooking: could not free the rider", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
}
if err := tx.Commit().Error; err != nil {
utils.Error("CancelCxBooking: commit failed", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
publishCxBookingCancelled(booking, reason)
return utils.CxOK(c, fiber.Map{
"reference": booking.Bookingno,
"status": constants.CxStatusCancelled,
"cancelReason": reason,
})
}
// PatchCxDestination fills in the parts of an address the customer left out.
//
// Accepted until the parcels are collected. After that the shipment's addresses
// are frozen on the consignment and an edit here would change what the customer
// sees without changing where the parcel is going — which is worse than
// refusing.
//
// Writes land on the same booking row the miler app reads addresses from, so a
// correction made while the rider is on their way reaches them.
func PatchCxDestination(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
reference := strings.TrimSpace(c.Params("reference"))
index, err := strconv.Atoi(c.Params("index"))
if err != nil || index < 0 {
return utils.CxNotFound(c, "We could not find that destination")
}
booking, err := cxLoadBooking(customerID, reference)
if err != nil {
return utils.CxNotFound(c, "We could not find that pickup")
}
stage := booking.Customerstage
if stage == "" {
stage = deriveStageFromStatus(booking)
}
if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStagePickedUp] {
return utils.CxConflict(c, "Your packages have been collected — these details can no longer be changed")
}
if booking.Customerstatus == constants.CxStatusCancelled || booking.Status == constants.BookingCancelled {
return utils.CxConflict(c, "This pickup was cancelled")
}
var dest models.BookingDestination
if err := db.DB.Where("bookingid = ? AND seq = ?", booking.Bookingid, index).
First(&dest).Error; err != nil {
return utils.CxNotFound(c, "We could not find that destination")
}
var req cxDetailsInput
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
applyCxDetails(&dest, &req)
dest.Updatedat = utils.DBNow()
tx := db.DB.Begin()
if err := tx.Save(&dest).Error; err != nil {
tx.Rollback()
utils.Error("PatchCxDestination: save failed", "destination_id", dest.Bookingdestinationid, "error", err)
return utils.CxInternal(c)
}
// Destination 0 mirrors onto the booking's flat delivery columns, which is
// where the miler app and the routing code look. An edit that only landed
// on bookingdestinations would be invisible to the rider standing at the
// door, which is precisely who it was made for.
if dest.Seq == 0 {
updates := map[string]interface{}{
"deliveryaddress": cxDestinationAddressFromRow(&dest),
"updatedat": utils.DBNow(),
}
if dest.Pinlatitude != nil && dest.Pinlongitude != nil {
updates["deliverylatitude"] = *dest.Pinlatitude
updates["deliverylongitude"] = *dest.Pinlongitude
}
if err := tx.Model(&models.PickupBooking{}).
Where("bookingid = ?", booking.Bookingid).Updates(updates).Error; err != nil {
tx.Rollback()
utils.Error("PatchCxDestination: could not mirror onto booking", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
}
if err := tx.Commit().Error; err != nil {
utils.Error("PatchCxDestination: commit failed", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusOK)
}
// ── Helpers ──────────────────────────────────────────────────────────────────
// cxLoadBooking finds a booking by its customer-facing reference, scoped to the
// caller. Ownership is part of the lookup, not a check afterwards — a query
// that can return someone else's row is one forgotten `if` away from leaking it.
func cxLoadBooking(customerID int, reference string) (*models.PickupBooking, error) {
if reference == "" {
return nil, gorm.ErrRecordNotFound
}
var booking models.PickupBooking
if err := db.DB.Where("bookingno = ? AND appcustomerid = ?", reference, customerID).
First(&booking).Error; err != nil {
return nil, err
}
return &booking, nil
}
// cxRespondWithBooking re-reads and renders, so a create or a patch answers in
// exactly the shape a later GET will.
func cxRespondWithBooking(c *fiber.Ctx, bookingID, status int) error {
var booking models.PickupBooking
if err := db.DB.First(&booking, bookingID).Error; err != nil {
utils.Error("cxRespondWithBooking: reload failed", "booking_id", bookingID, "error", err)
return utils.CxInternal(c)
}
bundle := loadCxBundle([]models.PickupBooking{booking})
payload := renderCxBooking(&booking, bundle)
if status == fiber.StatusCreated {
return utils.CxCreated(c, payload)
}
return utils.CxOK(c, payload)
}
// applyCxDetails writes only the fields actually present in the request. An
// omitted key leaves the stored value alone; an explicit null clears it, which
// is how the customer removes a landmark they no longer want the rider to use.
func applyCxDetails(dest *models.BookingDestination, in *cxDetailsInput) {
if in == nil {
return
}
if in.Street != nil {
dest.Street = strings.TrimSpace(*in.Street)
}
if in.Building != nil {
dest.Building = strings.TrimSpace(*in.Building)
}
if in.Landmark != nil {
dest.Landmark = strings.TrimSpace(*in.Landmark)
}
if in.RecipientName != nil {
dest.Recipientname = strings.TrimSpace(*in.RecipientName)
}
if in.RecipientPhone != nil {
if phone, _, ok := normalizePhone(*in.RecipientPhone); ok {
dest.Recipientphone = phone
} else {
dest.Recipientphone = strings.TrimSpace(*in.RecipientPhone)
}
}
if in.Instructions != nil {
dest.Instructions = strings.TrimSpace(*in.Instructions)
}
if in.Pin != nil {
lat, lng := in.Pin.Lat, in.Pin.Lng
if lat == 0 && lng == 0 {
dest.Pinlatitude, dest.Pinlongitude = nil, nil
} else {
dest.Pinlatitude, dest.Pinlongitude = &lat, &lng
}
}
if in.CodAmount != nil {
dest.Codamount = *in.CodAmount
}
}
func cxFirstPin(in *cxDetailsInput) *[2]float64 {
if in == nil || in.Pin == nil {
return nil
}
if in.Pin.Lat == 0 && in.Pin.Lng == 0 {
return nil
}
return &[2]float64{in.Pin.Lat, in.Pin.Lng}
}
// cxDestinationAddress builds the flat address string the rest of the system
// stores, from whatever the customer supplied. It always resolves to something
// non-empty — pickupbookings.deliveryaddress is NOT NULL, and a booking with
// only a state and a district is a legitimate booking.
func cxDestinationAddress(in *cxDetailsInput, district models.ServiceableDistrict) string {
parts := []string{}
if in != nil {
if in.Building != nil {
parts = append(parts, *in.Building)
}
if in.Street != nil {
parts = append(parts, *in.Street)
}
if in.Landmark != nil {
parts = append(parts, *in.Landmark)
}
}
parts = append(parts, district.Districtname)
address := joinNonEmpty(", ", parts...)
if address == "" {
return district.Districtcode
}
return address
}
func cxDestinationAddressFromRow(d *models.BookingDestination) string {
address := joinNonEmpty(", ", d.Building, d.Street, d.Landmark, d.Districtname)
if address == "" {
return d.Districtcode
}
return address
}
// cxStateNames resolves display names for the states a set of districts belong
// to, in one query.
func cxStateNames(districts []models.ServiceableDistrict) (map[string]string, error) {
codes := make([]string, 0, len(districts))
seen := map[string]bool{}
for _, d := range districts {
if d.Statecode != "" && !seen[d.Statecode] {
seen[d.Statecode] = true
codes = append(codes, d.Statecode)
}
}
names := make(map[string]string, len(codes))
if len(codes) == 0 {
return names, nil
}
var states []models.ServiceableState
if err := db.DB.Where("statecode IN ?", codes).Find(&states).Error; err != nil {
return names, err
}
for _, s := range states {
names[s.Statecode] = s.Statename
}
return names, nil
}
// cxToDBTime converts an IST wall clock into the shape this database stores —
// the same digits, tagged UTC so the driver writes them verbatim. See
// utils.DBNow: comparing a container's UTC clock against IST-stamped rows is
// what made date-range reports undercount.
func cxToDBTime(t time.Time) time.Time {
ist := t.In(utils.ISTLocation())
return time.Date(ist.Year(), ist.Month(), ist.Day(), ist.Hour(), ist.Minute(), ist.Second(), 0, time.UTC)
}
// ── Events ───────────────────────────────────────────────────────────────────
// publishCxBookingCreated and publishCxBookingCancelled mirror the existing
// booking events onto NATS. Best-effort and nil-checked, like every other
// publish in this codebase: the event bus is never allowed to fail a booking.
func publishCxBookingCreated(b *models.PickupBooking, destinationCount int) {
if db.Js == nil {
return
}
payload := map[string]interface{}{
"booking_id": b.Bookingid,
"booking_no": b.Bookingno,
"customer_id": b.Appcustomerid,
"pickup_address": b.Pickupaddress,
"pickup_pincode": b.Pickuppincode,
"destination_count": destinationCount,
"slot_id": b.Slotid,
"status": constants.BookingPendingPickup,
"created_at": utils.EpochMillis(b.Createdat),
}
data, err := json.Marshal(payload)
if err != nil {
return
}
if _, err := db.Js.Publish("api.v1.bookings.create", data); err != nil {
utils.Warn("Failed to publish booking.create to NATS", "booking_id", b.Bookingid, "error", err)
}
}
func publishCxBookingCancelled(b *models.PickupBooking, reason string) {
if db.Js == nil {
return
}
payload := map[string]interface{}{
"booking_id": b.Bookingid,
"booking_no": b.Bookingno,
"customer_id": b.Appcustomerid,
"status": "Cancelled",
"reason": reason,
"cancelled_at": time.Now().UnixMilli(),
}
data, err := json.Marshal(payload)
if err != nil {
return
}
if _, err := db.Js.Publish("api.v1.bookings.cancel", data); err != nil {
utils.Warn("Failed to publish booking.cancel to NATS", "booking_id", b.Bookingid, "error", err)
}
}

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
}

View 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
}

View File

@@ -0,0 +1,100 @@
package controllers
import (
"doormile/constants"
"doormile/internal/cxstage"
"doormile/utils"
"gorm.io/gorm"
)
// Per-order stages — the second half of §8.3.
//
// Stages 6-8 belong to each order rather than to the booking, and may differ
// between destinations of the same pickup: one parcel out for delivery in
// Chennai while another is still at a hub in Kerala. Each of the operational
// writes that moves a consignment records its customer stage against the
// destination that consignment belongs to, and the booking's own stage falls
// back to the least-advanced of them.
// cxStageForConsignmentStatus maps an operational consignment status onto the
// customer stage it means. Not every status has one: a parcel sitting on a
// tripsheet, or one flagged missing, is still "in transit" as far as the
// customer's seven milestones go, and inventing a stage for it would put a key
// on the wire the client silently falls back to `booked` for.
func cxStageForConsignmentStatus(status string) (string, bool) {
switch status {
case constants.ConsignmentInwardedAtHub,
constants.ConsignmentTripsheetLoaded,
constants.ConsignmentInTransit:
return constants.CxStageInTransit, true
case constants.ConsignmentOutForDelivery:
return constants.CxStageOutForDelivery, true
case constants.ConsignmentDelivered:
return constants.CxStageDelivered, true
default:
// Created and Collected_By_Miler both mean "the order exists and is in
// the rider's hands", which is order_created — already recorded at
// pickup-complete, so there is nothing new to say.
return "", false
}
}
// recordCxConsignmentStage records the customer stage for one consignment,
// inside the caller's transaction, and returns the notification to fire once
// that transaction commits.
//
// The consignment is resolved to its destination through bookingdestinations,
// not through pickupbookings.consignmentid. That column names only the FIRST
// order of a multi-destination pickup, so a lookup through it finds nothing for
// destinations 2..N — which would mean no stage advance and no notification on
// every order after the first.
//
// A consignment that belongs to no customer booking at all — a console-created
// express shipment — is a no-op, not an error. Those have no customer app
// watching them.
func recordCxConsignmentStage(tx *gorm.DB, consignmentID int, status string, actorType string, actorID *int, source string) (afterCommit func(), err error) {
noop := func() {}
stage, ok := cxStageForConsignmentStatus(status)
if !ok {
return noop, nil
}
dest, booking, found := cxDestinationForConsignment(consignmentID)
if !found || booking == nil {
return noop, nil
}
if booking.Bookingsource != constants.BookingSourceCustomerApp {
return noop, nil
}
destinationID := cxDestinationIDFor(dest)
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
DestinationID: destinationID,
Stage: stage,
ActorType: actorType,
ActorID: actorID,
Source: source,
At: utils.DBNow(),
}); err != nil {
return noop, err
}
bookingID := booking.Bookingid
return func() { go cxstage.Notify(bookingID, destinationID, stage) }, nil
}
// cancelCxBookingFromOps stands a pickup down on behalf of a rider or an ops
// user, recording who did it and why.
//
// A customer whose pickup disappears with no explanation has no way to tell a
// cancellation from a bug, and support has no way to answer them — so the
// actor and the reason are part of the write, not an afterthought.
func cancelCxBookingFromOps(tx *gorm.DB, bookingID int, reason, actorType string, actorID *int, source string) error {
if reason == "" {
reason = "Cancelled by Doormile"
}
return cxstage.Cancel(tx, bookingID, reason, actorType, actorID, source)
}

View File

@@ -0,0 +1,856 @@
package controllers
import (
"strings"
"testing"
"time"
"doormile/constants"
"doormile/models"
"doormile/utils"
)
// The app currently sends "+91 98765 43210" with spaces and is being tightened
// to send E.164. Both shapes must land on the SAME stored value: if they do
// not, a customer signing in from the newer build gets a second account and
// loses every booking they have made.
func TestNormalizePhoneCollapsesEveryShapeToOne(t *testing.T) {
want := "+919876543210"
inputs := []string{
"+91 98765 43210", // what the app sends today
"+919876543210", // what it is being tightened to send
"9876543210", // bare keypad entry
"09876543210", // with the national trunk prefix
"919876543210", // country code, no plus
"+91-98765-43210", // typed with dashes by support
" 9876543210 ", // pasted with whitespace
}
for _, in := range inputs {
got, kind, ok := normalizePhone(in)
if !ok {
t.Errorf("normalizePhone(%q) rejected a valid number", in)
continue
}
if kind != "phone" {
t.Errorf("normalizePhone(%q) kind = %q, want \"phone\"", in, kind)
}
if got != want {
t.Errorf("normalizePhone(%q) = %q, want %q — two spellings of one number must not make two accounts",
in, got, want)
}
}
}
func TestNormalizePhoneRejectsNonsense(t *testing.T) {
for _, in := range []string{"", " ", "12345", "abcdef", "+1"} {
if got, _, ok := normalizePhone(in); ok {
t.Errorf("normalizePhone(%q) = %q, ok — want rejected", in, got)
}
}
}
func TestNormalizeIdentifierSplitsPhoneFromEmail(t *testing.T) {
cases := []struct {
in string
want string
wantKind string
wantOK bool
}{
{"Joe@Example.COM", "joe@example.com", "email", true},
{"+91 98765 43210", "+919876543210", "phone", true},
{"joe@example", "", "", false}, // no dot in the domain
{"@example.com", "", "", false}, // no local part
{"joe@", "", "", false}, // no domain
{"", "", "", false},
}
for _, tc := range cases {
got, kind, ok := normalizeIdentifier(tc.in)
if ok != tc.wantOK || got != tc.want || kind != tc.wantKind {
t.Errorf("normalizeIdentifier(%q) = (%q, %q, %v), want (%q, %q, %v)",
tc.in, got, kind, ok, tc.want, tc.wantKind, tc.wantOK)
}
}
}
func TestSplitNameKeepsOneWordNames(t *testing.T) {
cases := []struct {
in string
first, last string
}{
{"Joe Oommen", "Joe", "Oommen"},
{"Meera", "Meera", ""}, // plenty of people have one name
{" Arun Kumar ", "Arun", "Kumar"}, // collapses whitespace
{"Vijay Raghav Menon", "Vijay Raghav", "Menon"}, // last token is the surname
{"J", "", ""}, // under two characters is invalid_name
{"", "", ""},
}
for _, tc := range cases {
first, last := splitName(tc.in)
if first != tc.first || last != tc.last {
t.Errorf("splitName(%q) = (%q, %q), want (%q, %q)", tc.in, first, last, tc.first, tc.last)
}
}
}
// The slot id is opaque to the client but has to round-trip: it encodes the
// date so a slot id from yesterday's cached list resolves to yesterday and gets
// rejected, rather than silently booking today's window.
func TestSlotIDCarriesItsDate(t *testing.T) {
from := time.Date(2026, 9, 5, 14, 0, 0, 0, time.UTC)
if got, want := cxSlotID(from, "t1"), "slot_20260905_t1"; got != want {
t.Errorf("cxSlotID = %q, want %q", got, want)
}
// Two days must never produce the same id for the same template.
other := time.Date(2026, 9, 6, 14, 0, 0, 0, time.UTC)
if cxSlotID(from, "t1") == cxSlotID(other, "t1") {
t.Error("cxSlotID collides across days — a stale slot would book today's window")
}
}
func TestDescribeParcelsMatchesTheReceiptCopy(t *testing.T) {
cases := []struct {
packages int
want string
}{
{0, "Standard box (up to 3 kg)"},
{1, "Standard box (up to 3 kg)"},
{3, "3 boxes (up to 3 kg each)"},
}
for _, tc := range cases {
if got := describeParcels(tc.packages); got != tc.want {
t.Errorf("describeParcels(%d) = %q, want %q", tc.packages, got, tc.want)
}
}
}
// pickup.title and pickup.sub are typed non-nullable by the client and will
// throw in its parser on a null. A console-created booking has only one flat
// address string, so both have to be derivable from it.
func TestPickupTitleAndSubAreNeverEmpty(t *testing.T) {
cases := []struct {
name string
booking models.PickupBooking
}{
{"customer-app booking", models.PickupBooking{
Pickuptitle: "12 Nehru Street", Pickupsub: "Gandhipuram, Coimbatore 641012"}},
{"console booking with a flat address", models.PickupBooking{
Pickupaddress: "12 Nehru Street, Gandhipuram, Coimbatore", Pickuppincode: "641012"}},
{"address with no comma", models.PickupBooking{
Pickupaddress: "Brookefields", Pickuppincode: "641001"}},
{"nothing recorded at all", models.PickupBooking{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
b := tc.booking
if title := cxPickupTitle(&b); title == "" {
t.Error("cxPickupTitle returned empty — the client types it non-nullable")
}
if sub := cxPickupSub(&b); sub == "" {
t.Error("cxPickupSub returned empty — the client types it non-nullable")
}
})
}
}
func TestPickupTitleIsCappedForTheDesign(t *testing.T) {
b := models.PickupBooking{
Pickupaddress: "A very long single-line address with no commas at all in it anywhere",
}
if got := cxPickupTitle(&b); len([]rune(got)) > 32 {
t.Errorf("cxPickupTitle = %q (%d runes), want at most 32", got, len([]rune(got)))
}
}
// deliveredAt is the moment the LAST parcel landed. Reporting the first would
// tell a customer their whole pickup completed while parcels were still moving.
func TestAllDeliveredAtWaitsForTheLastParcel(t *testing.T) {
early := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
late := time.Date(2026, 9, 6, 16, 0, 0, 0, time.UTC)
partly := []models.BookingDestination{
{Deliveredat: &early},
{Deliveredat: nil},
}
if got := cxAllDeliveredAt(partly); got != nil {
t.Errorf("cxAllDeliveredAt with one parcel still moving = %v, want nil", got)
}
all := []models.BookingDestination{
{Deliveredat: &early},
{Deliveredat: &late},
}
got := cxAllDeliveredAt(all)
if got == nil || !got.Equal(late) {
t.Errorf("cxAllDeliveredAt = %v, want the last delivery %v", got, late)
}
if got := cxAllDeliveredAt(nil); got != nil {
t.Errorf("cxAllDeliveredAt(no destinations) = %v, want nil", got)
}
}
// A booking written before this surface existed carries no stored stage, and a
// console-created one never will. Both still have to render, so the stage is
// derived from the operational status rather than left blank.
func TestStageDerivationForBookingsWithNoStoredStage(t *testing.T) {
arrived := time.Date(2026, 9, 5, 14, 0, 0, 0, time.UTC)
cases := []struct {
name string
booking models.PickupBooking
want string
}{
{"pending pickup", models.PickupBooking{Status: constants.BookingPendingPickup}, constants.CxStageBooked},
{"assigned, not yet arrived", models.PickupBooking{Status: constants.BookingMilerAssigned}, constants.CxStageAssigned},
{"scheduled and arrived", models.PickupBooking{Status: constants.BookingPickupScheduled, Arrivedat: &arrived}, constants.CxStageArrived},
{"picked up", models.PickupBooking{Status: constants.BookingPickedUp}, constants.CxStagePickedUp},
{"converted", models.PickupBooking{Status: constants.BookingConvertedConsignment}, constants.CxStageOrderCreated},
{"cancelled", models.PickupBooking{Status: constants.BookingCancelled}, constants.CxStageBooked},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
b := tc.booking
if got := deriveStageFromStatus(&b); got != tc.want {
t.Errorf("deriveStageFromStatus = %q, want %q", got, tc.want)
}
})
}
}
// The timeline is built from the event log. Cancellation and release rows 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.
func TestHistoryExcludesAuditRowsAndDeduplicates(t *testing.T) {
at := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
events := []models.BookingStageEvent{
{Stage: constants.CxStageBooked, Occurredat: at},
{Stage: constants.CxStageAssigned, Occurredat: at.Add(time.Minute)},
{Stage: constants.CxStageBooked, Remarks: "released: rider unavailable", Occurredat: at.Add(2 * time.Minute)},
{Stage: constants.CxStageAssigned, Occurredat: at.Add(3 * time.Minute)}, // second rider, same stage
{Stage: constants.CxStageBooked, Remarks: "cancelled: Package not ready", Occurredat: at.Add(4 * time.Minute)},
}
history := renderCxHistory(events)
if len(history) != 2 {
t.Fatalf("renderCxHistory returned %d entries, want 2 (booked, assigned)", len(history))
}
if history[0]["stage"] != constants.CxStageBooked || history[1]["stage"] != constants.CxStageAssigned {
t.Errorf("renderCxHistory = %v, want booked then assigned in order", history)
}
}
// A multi-destination pickup emits each per-order stage once per destination.
// The booking reaches that stage when its SLOWEST order does, so the timeline
// must carry the LAST of them — otherwise "Delivered" is timestamped at the
// moment the earliest parcel landed while deliveredAt reports the last one, and
// the same screen contradicts itself.
func TestHistoryTimestampsPerOrderStagesAtTheSlowestOrder(t *testing.T) {
base := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
firstParcel := base.Add(2 * time.Hour)
lastParcel := base.Add(30 * time.Hour)
events := []models.BookingStageEvent{
{Stage: constants.CxStageBooked, Occurredat: base},
{Stage: constants.CxStageDelivered, Occurredat: firstParcel},
{Stage: constants.CxStageDelivered, Occurredat: lastParcel},
}
history := renderCxHistory(events)
if len(history) != 2 {
t.Fatalf("renderCxHistory returned %d entries, want 2", len(history))
}
if got, want := history[1]["at"], utils.EpochMillis(lastParcel); got != want {
t.Errorf("delivered timestamped at %v, want the last parcel %v", got, want)
}
}
// Booking-level stages happen once for the whole pickup, so a duplicate is a
// re-assertion (Record dedupes, but a release can bring one back) and the
// original moment is the true one.
func TestHistoryKeepsTheFirstBookingLevelTimestamp(t *testing.T) {
first := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
later := first.Add(3 * time.Hour)
events := []models.BookingStageEvent{
{Stage: constants.CxStageAssigned, Occurredat: first},
{Stage: constants.CxStageAssigned, Occurredat: later},
}
history := renderCxHistory(events)
if len(history) != 1 {
t.Fatalf("renderCxHistory returned %d entries, want 1", len(history))
}
if got, want := history[0]["at"], utils.EpochMillis(first); got != want {
t.Errorf("assigned timestamped at %v, want the original %v", got, want)
}
}
// Every stage the timeline can carry has to be one the client parses. An
// unknown key is silently rendered as `booked`, so a mapping that invents one
// makes a moving parcel look un-started.
func TestConsignmentStatusMapsOnlyToRealStages(t *testing.T) {
mapped := map[string]string{
constants.ConsignmentInwardedAtHub: constants.CxStageInTransit,
constants.ConsignmentTripsheetLoaded: constants.CxStageInTransit,
constants.ConsignmentInTransit: constants.CxStageInTransit,
constants.ConsignmentOutForDelivery: constants.CxStageOutForDelivery,
constants.ConsignmentDelivered: constants.CxStageDelivered,
}
for status, want := range mapped {
got, ok := cxStageForConsignmentStatus(status)
if !ok || got != want {
t.Errorf("cxStageForConsignmentStatus(%q) = (%q, %v), want (%q, true)", status, got, ok, want)
}
if constants.CxStageRank(got) < 0 {
t.Errorf("cxStageForConsignmentStatus(%q) produced %q, which is not one of the nine stage keys", status, got)
}
}
// Statuses that mean "the order exists and is in the rider's hands" are
// already covered by order_created and must not emit a second stage.
for _, status := range []string{
constants.ConsignmentCreated,
constants.ConsignmentCollectedByMiler,
constants.ConsignmentMissing,
"Cancelled",
} {
if got, ok := cxStageForConsignmentStatus(status); ok {
t.Errorf("cxStageForConsignmentStatus(%q) = %q, want no stage", status, got)
}
}
}
// cxLegWeights is what the price settles on. A leg whose packages were never
// weighed must still produce a chargeable consignment, or an unweighed pickup
// bills at zero.
func TestLegWeightsFallBackWhenNothingWasWeighed(t *testing.T) {
empty := cxPickupLeg{}
dead, chargeable, _, _, _ := cxLegWeights(empty)
if dead != 0.5 || chargeable != 0.5 {
t.Errorf("cxLegWeights(no parcels) = (%v, %v), want the 0.5 kg placeholder", dead, chargeable)
}
// Volumetric weight wins when the box is bulky and light — that is the
// whole reason the column exists.
bulky := cxPickupLeg{Parcels: []models.BookingParcel{
{Weight: 1.0, Length: 40, Width: 40, Height: 40}, // volumetric = 64000/5000 = 12.8
}}
_, chargeable, maxL, _, _ := cxLegWeights(bulky)
if chargeable != 12.8 {
t.Errorf("cxLegWeights chargeable = %v, want the volumetric 12.8", chargeable)
}
if maxL != 40 {
t.Errorf("cxLegWeights maxL = %v, want 40", maxL)
}
}
// How many stops a booking is worth to the rider decides whether a parcel is
// deliverable at all. The queue used to emit one row per booking keyed on
// pickupbookings.consignmentid — a column that names only the FIRST order — so
// on a three-destination pickup two parcels sat in the rider's bag with no
// stop, no deliver button and no way to close them.
func TestMilerStopsBeforeCollectionAreOneVisit(t *testing.T) {
booking := models.PickupBooking{
Bookingid: 7,
Deliveryaddress: "12th Main, Chennai",
Deliverylatitude: 13.08,
Deliverylongitude: 80.27,
}
// Booked, not yet collected: no destination carries a consignment.
destinations := []models.BookingDestination{
{Bookingdestinationid: 1, Bookingid: 7, Seq: 0, Districtname: "Chennai"},
{Bookingdestinationid: 2, Bookingid: 7, Seq: 1, Districtname: "Ernakulam"},
{Bookingdestinationid: 3, Bookingid: 7, Seq: 2, Districtname: "Bengaluru Urban"},
}
stops := milerStopsForBooking(&booking, destinations)
if len(stops) != 1 {
t.Fatalf("got %d stops before collection, want 1 — the rider makes ONE visit to the door", len(stops))
}
if stops[0].deliveryLat != 13.08 {
t.Errorf("pre-pickup stop lost the booking's mirrored coordinates: %v", stops[0].deliveryLat)
}
}
func TestMilerStopsAfterCollectionAreOnePerOrder(t *testing.T) {
c1, c2, c3 := 101, 102, 103
pinLat, pinLng := 9.98, 76.29
booking := models.PickupBooking{
Bookingid: 7,
Consignmentid: &c1,
Deliverylatitude: 13.08,
Deliverylongitude: 80.27,
Parcels: []models.BookingParcel{
{Bookingparcelid: 1, Bookingdestinationid: intPtr(1)},
{Bookingparcelid: 2, Bookingdestinationid: intPtr(1)},
{Bookingparcelid: 3, Bookingdestinationid: intPtr(2)},
{Bookingparcelid: 4, Bookingdestinationid: intPtr(3)},
},
}
destinations := []models.BookingDestination{
{Bookingdestinationid: 1, Bookingid: 7, Seq: 0, Districtname: "Chennai",
Statename: "Tamil Nadu", Consignmentid: &c1, Trackingno: "DMX10000001"},
{Bookingdestinationid: 2, Bookingid: 7, Seq: 1, Districtname: "Ernakulam",
Statename: "Kerala", Consignmentid: &c2, Trackingno: "DMX10000002",
Pinlatitude: &pinLat, Pinlongitude: &pinLng},
{Bookingdestinationid: 3, Bookingid: 7, Seq: 2, Districtname: "Bengaluru Urban",
Statename: "Karnataka", Consignmentid: &c3, Trackingno: "DMX10000003"},
}
stops := milerStopsForBooking(&booking, destinations)
if len(stops) != 3 {
t.Fatalf("got %d stops after collection, want 3 — one per order, or the other parcels are undeliverable", len(stops))
}
// Each stop names its own order. Sharing one consignment id would have the
// rider close the same parcel three times.
seen := map[int]bool{}
for i, s := range stops {
if s.consignmentID == nil {
t.Fatalf("stop %d has no consignment id", i)
}
if seen[*s.consignmentID] {
t.Errorf("stop %d repeats consignment %d", i, *s.consignmentID)
}
seen[*s.consignmentID] = true
if s.trackingNo == "" {
t.Errorf("stop %d has no tracking number", i)
}
if s.deliveryAddress == "" {
t.Errorf("stop %d has no delivery address — the rider has nowhere to go", i)
}
}
// Parcels follow their own destination, so each order settles and is handed
// over with the packages that actually belong to it.
if len(stops[0].parcels) != 2 || len(stops[1].parcels) != 1 || len(stops[2].parcels) != 1 {
t.Errorf("parcels split as %d/%d/%d, want 2/1/1",
len(stops[0].parcels), len(stops[1].parcels), len(stops[2].parcels))
}
// A destination with its own pin uses it; destination 0 falls back to the
// coordinates mirrored onto the booking, which the rider may have corrected.
if stops[1].deliveryLat != pinLat {
t.Errorf("stop 1 lat = %v, want the customer's pin %v", stops[1].deliveryLat, pinLat)
}
if stops[0].deliveryLat != 13.08 {
t.Errorf("stop 0 lat = %v, want the booking's mirrored 13.08", stops[0].deliveryLat)
}
}
// A console-created express booking has no destination rows at all and must
// keep producing exactly the one stop it always did.
func TestMilerStopsForConsoleBookingAreUnchanged(t *testing.T) {
cid := 55
booking := models.PickupBooking{
Bookingid: 9,
Consignmentid: &cid,
Deliveryaddress: "Kitchen 4, Nagercoil",
Deliverylatitude: 8.17,
Deliverylongitude: 77.43,
Parcels: []models.BookingParcel{{Bookingparcelid: 1}},
}
stops := milerStopsForBooking(&booking, nil)
if len(stops) != 1 {
t.Fatalf("got %d stops, want 1", len(stops))
}
if stops[0].consignmentID == nil || *stops[0].consignmentID != cid {
t.Errorf("console stop lost its consignment id: %v", stops[0].consignmentID)
}
if stops[0].deliveryAddress != "Kitchen 4, Nagercoil" || len(stops[0].parcels) != 1 {
t.Errorf("console stop changed shape: %+v", stops[0])
}
}
// Three console paths cancel a booking by writing pickupbookings.status
// directly and none of them knows the customer projection exists. The customer
// must never keep seeing a cancelled pickup as active and cancellable, so the
// operational status is the authority here regardless of what customerstatus
// holds.
func TestOpsCancellationAlwaysReachesTheCustomer(t *testing.T) {
bundle := loadCxBundleForTest()
booking := models.PickupBooking{
Bookingid: 1,
Bookingno: "DM-482913",
// Written as active at booking time — this is the value that used to
// win and keep a cancelled pickup looking live.
Customerstatus: constants.CxStatusActive,
Customerstage: constants.CxStageAssigned,
Status: constants.BookingCancelled,
Createdat: time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC),
}
out := renderCxBooking(&booking, bundle)
if out["status"] != constants.CxStatusCancelled {
t.Errorf("status = %v, want %q — an ops cancel must reach the customer",
out["status"], constants.CxStatusCancelled)
}
if out["cancellable"] != false {
t.Errorf("cancellable = %v on a cancelled pickup, want false", out["cancellable"])
}
}
// A booking that is genuinely still live must not be swept up by that rule.
func TestActiveBookingStaysActiveAndCancellable(t *testing.T) {
bundle := loadCxBundleForTest()
booking := models.PickupBooking{
Bookingid: 2,
Bookingno: "DM-482914",
Customerstatus: constants.CxStatusActive,
Customerstage: constants.CxStageArrived,
Status: constants.BookingPickupScheduled,
Createdat: time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC),
}
out := renderCxBooking(&booking, bundle)
if out["status"] != constants.CxStatusActive {
t.Errorf("status = %v, want %q", out["status"], constants.CxStatusActive)
}
// arrived is the last cancellable stage.
if out["cancellable"] != true {
t.Errorf("cancellable = %v at arrived, want true", out["cancellable"])
}
}
// loadCxBundleForTest builds an empty bundle, so the projection can be
// exercised without a database.
func loadCxBundleForTest() *cxBookingBundle {
return &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{},
}
}
// ─── Fan-out row-level proof ─────────────────────────────────────────────────
//
// One pickup, three genuinely different destinations. Every assertion below
// exists because the failure it guards against is silent: the rows would still
// render, the rider would still see stops, and the parcels would go to the
// wrong doors. Booking-level destination-0 data leaking into rows 2 and 3 is
// the specific defect this locks down.
// cxThreeDestinationFixture builds a collected pickup bound for Chennai,
// Ernakulam and Bengaluru — different addresses, different coordinates,
// different recipients, different tracking numbers, different COD.
func cxThreeDestinationFixture() (models.PickupBooking, []models.BookingDestination) {
c1, c2, c3 := 901, 902, 903
chennaiLat, chennaiLng := 13.082680, 80.270718
kochiLat, kochiLng := 9.981636, 76.299881
blrLat, blrLng := 12.971599, 77.594566
booking := models.PickupBooking{
Bookingid: 70,
Bookingno: "DM-482913",
Consignmentid: &c1,
// Booking-level delivery data mirrors destination 0 ONLY. If any of it
// leaks onto stops 1 or 2, the assertions below catch it.
Deliveryaddress: "3B, 12th Main, Chennai, Tamil Nadu",
Deliverylatitude: chennaiLat,
Deliverylongitude: chennaiLng,
Parcels: []models.BookingParcel{
{Bookingparcelid: 1, Bookingdestinationid: intPtr(11)},
{Bookingparcelid: 2, Bookingdestinationid: intPtr(11)},
{Bookingparcelid: 3, Bookingdestinationid: intPtr(12)},
{Bookingparcelid: 4, Bookingdestinationid: intPtr(13)},
},
}
destinations := []models.BookingDestination{
{
Bookingdestinationid: 11, Bookingid: 70, Seq: 0,
Statecode: "TN", Statename: "Tamil Nadu",
Districtcode: "TN-MAA", Districtname: "Chennai",
Building: "3B", Street: "12th Main",
Recipientname: "Meera S", Recipientphone: "+919884412210",
Packagecount: 2, Codamount: 1200,
Consignmentid: &c1, Trackingno: "DMX10482913",
Pinlatitude: &chennaiLat, Pinlongitude: &chennaiLng,
},
{
Bookingdestinationid: 12, Bookingid: 70, Seq: 1,
Statecode: "KL", Statename: "Kerala",
Districtcode: "KL-EKM", Districtname: "Ernakulam",
Street: "Marine Drive", Landmark: "Near the ferry",
Recipientname: "Joe Oommen", Recipientphone: "+919847011223",
Packagecount: 1, Codamount: 0,
Consignmentid: &c2, Trackingno: "DMX10559120",
Pinlatitude: &kochiLat, Pinlongitude: &kochiLng,
},
{
Bookingdestinationid: 13, Bookingid: 70, Seq: 2,
Statecode: "KA", Statename: "Karnataka",
Districtcode: "KA-BLR", Districtname: "Bengaluru Urban",
Street: "Brigade Road",
Recipientname: "Arun Kumar", Recipientphone: "+919000011223",
Packagecount: 1, Codamount: 450,
Consignmentid: &c3, Trackingno: "DMX10662004",
Pinlatitude: &blrLat, Pinlongitude: &blrLng,
},
}
return booking, destinations
}
// Each generated stop carries a DIFFERENT, correct address — none of them
// inherits the booking-level destination-0 string.
func TestFanoutStopsHaveDistinctAddresses(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
stops := milerStopsForBooking(&booking, destinations)
if len(stops) != 3 {
t.Fatalf("got %d stops, want 3", len(stops))
}
seen := map[string]bool{}
for i, s := range stops {
if s.deliveryAddress == "" {
t.Fatalf("stop %d has no delivery address — the rider has nowhere to go", i)
}
if seen[s.deliveryAddress] {
t.Errorf("stop %d repeats an address already used: %q", i, s.deliveryAddress)
}
seen[s.deliveryAddress] = true
}
wantDistrict := []string{"Chennai", "Ernakulam", "Bengaluru Urban"}
for i, want := range wantDistrict {
if !strings.Contains(stops[i].deliveryAddress, want) {
t.Errorf("stop %d address %q does not name %q", i, stops[i].deliveryAddress, want)
}
}
// The specific leak this guards: destination 0's address on a later stop.
for i := 1; i < 3; i++ {
if strings.Contains(stops[i].deliveryAddress, "Chennai") {
t.Errorf("stop %d inherited destination 0's address: %q", i, stops[i].deliveryAddress)
}
}
}
// Coordinates are distinct and correct per stop. A shared coordinate sends
// every parcel to one map pin, which is how a rider drives to the wrong city.
func TestFanoutStopsHaveDistinctCoordinates(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
stops := milerStopsForBooking(&booking, destinations)
type point struct{ lat, lng float64 }
want := []point{{13.082680, 80.270718}, {9.981636, 76.299881}, {12.971599, 77.594566}}
seen := map[point]bool{}
for i, s := range stops {
got := point{s.deliveryLat, s.deliveryLng}
if got.lat == 0 && got.lng == 0 {
t.Errorf("stop %d sits at 0,0 — route sequencing skips those", i)
}
if seen[got] {
t.Errorf("stop %d repeats coordinates %v", i, got)
}
seen[got] = true
if got != want[i] {
t.Errorf("stop %d coordinates = %v, want %v", i, got, want[i])
}
}
}
// Recipient details stay attached to their own stop. Delivering Meera's parcel
// while showing Joe's phone number is a handover to the wrong person.
func TestFanoutRecipientsStayWithTheirStop(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
stops := milerStopsForBooking(&booking, destinations)
want := []struct{ name, phone string }{
{"Meera S", "+919884412210"},
{"Joe Oommen", "+919847011223"},
{"Arun Kumar", "+919000011223"},
}
for i, w := range want {
if stops[i].recipientName != w.name {
t.Errorf("stop %d recipientName = %q, want %q", i, stops[i].recipientName, w.name)
}
if stops[i].recipientPhone != w.phone {
t.Errorf("stop %d recipientPhone = %q, want %q", i, stops[i].recipientPhone, w.phone)
}
}
}
// Tracking and consignment ids stay attached to their own stop. A crossed id
// means the rider closes the wrong order and the customer is told the wrong
// parcel arrived.
func TestFanoutTrackingAndConsignmentIdsStayWithTheirStop(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
stops := milerStopsForBooking(&booking, destinations)
wantTracking := []string{"DMX10482913", "DMX10559120", "DMX10662004"}
wantConsignment := []int{901, 902, 903}
seenTracking := map[string]bool{}
seenConsignment := map[int]bool{}
for i := range stops {
if stops[i].trackingNo != wantTracking[i] {
t.Errorf("stop %d trackingNo = %q, want %q", i, stops[i].trackingNo, wantTracking[i])
}
if stops[i].consignmentID == nil {
t.Fatalf("stop %d has no consignment id", i)
}
if *stops[i].consignmentID != wantConsignment[i] {
t.Errorf("stop %d consignmentID = %d, want %d", i, *stops[i].consignmentID, wantConsignment[i])
}
if seenTracking[stops[i].trackingNo] || seenConsignment[*stops[i].consignmentID] {
t.Errorf("stop %d repeats an identifier already used by another stop", i)
}
seenTracking[stops[i].trackingNo] = true
seenConsignment[*stops[i].consignmentID] = true
}
}
// COD is never copied across destinations. Money is the one field where a leak
// is not a display bug: a rider shown 1200 at a door that owes nothing collects
// it, and a door owing 450 shown 0 goes uncollected.
func TestFanoutCodIsNotCopiedAcrossDestinations(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
stops := milerStopsForBooking(&booking, destinations)
want := []float64{1200, 0, 450}
for i, w := range want {
if stops[i].codAmount != w {
t.Errorf("stop %d codAmount = %v, want %v", i, stops[i].codAmount, w)
}
}
for i := 1; i < len(stops); i++ {
if stops[i].codAmount == 1200 {
t.Errorf("stop %d carries destination 0's COD of 1200", i)
}
}
}
// Parcels follow their own destination, so each order settles and is handed
// over with the packages that actually belong to it.
func TestFanoutParcelsFollowTheirDestination(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
stops := milerStopsForBooking(&booking, destinations)
want := []int{2, 1, 1}
for i, w := range want {
if len(stops[i].parcels) != w {
t.Errorf("stop %d has %d parcels, want %d", i, len(stops[i].parcels), w)
}
}
seen := map[int]int{}
for i, s := range stops {
for _, p := range s.parcels {
if prev, dup := seen[p.Bookingparcelid]; dup {
t.Errorf("parcel %d appears on stops %d and %d", p.Bookingparcelid, prev, i)
}
seen[p.Bookingparcelid] = i
}
}
}
// Route order follows destinationseq. The stops are emitted in seq order so an
// app that renders them as received shows "Stop 1, 2, 3" without sorting.
func TestFanoutRouteOrderFollowsDestinationSeq(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
// Deliberately handed over out of order — the ordering must come from seq,
// not from however the rows happened to arrive.
shuffled := []models.BookingDestination{destinations[2], destinations[0], destinations[1]}
stops := milerStopsForBooking(&booking, shuffled)
if len(stops) != 3 {
t.Fatalf("got %d stops, want 3", len(stops))
}
for i := 1; i < len(stops); i++ {
if stops[i].seq <= stops[i-1].seq {
t.Errorf("stop %d has seq %d, which does not follow stop %d's seq %d",
i, stops[i].seq, i-1, stops[i-1].seq)
}
}
// And seq must still name the right destination after ordering.
if stops[0].trackingNo != "DMX10482913" || stops[2].trackingNo != "DMX10662004" {
t.Errorf("ordering by seq detached a stop from its order: %q ... %q",
stops[0].trackingNo, stops[2].trackingNo)
}
}
// Every one of the ten row-level fields the rider app reads is present and
// destination-specific. This is the field-by-field audit, asserted rather than
// described: consignmentid, trackingno, deliveryaddress, deliverylatitude,
// deliverylongitude, recipientname, recipientphone, collectionamt (codAmount),
// destinationseq (seq) and destinationcount (len).
func TestFanoutEveryRowLevelFieldIsDestinationSpecific(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
stops := milerStopsForBooking(&booking, destinations)
if len(stops) != 3 {
t.Fatalf("got %d stops, want 3", len(stops))
}
for i, s := range stops {
if s.consignmentID == nil {
t.Errorf("stop %d: consignmentid missing", i)
}
if s.trackingNo == "" {
t.Errorf("stop %d: trackingno missing", i)
}
if s.deliveryAddress == "" {
t.Errorf("stop %d: deliveryaddress missing", i)
}
if s.deliveryLat == 0 && s.deliveryLng == 0 {
t.Errorf("stop %d: delivery coordinates missing", i)
}
if s.recipientName == "" {
t.Errorf("stop %d: recipientname missing", i)
}
if s.recipientPhone == "" {
t.Errorf("stop %d: recipientphone missing", i)
}
if s.seq != i {
t.Errorf("stop %d: destinationseq = %d, want %d", i, s.seq, i)
}
}
// codAmount is exempt from the non-empty check — 0 is a legitimate value
// (destination 1 owes nothing) and is asserted exactly in the COD test.
}
// maxDestinations is enforced HERE, not in the app. The Flutter limit is UI
// guidance that a modified client, a stale build, or a failed
// /config/booking-limits fetch can all bypass; this is the authority.
//
// The default matters as much as the check: with no configuration row at all
// the cap must resolve to the SAFEST value, not the most permissive. A gate
// that opens when its config is missing is not a gate — a migration that ran
// without the seed would silently admit multi-destination bookings the rider
// app cannot complete.
func TestMaxDestinationsDefaultsToTheSafestValue(t *testing.T) {
if cxDefaultMaxDestinations != 1 {
t.Errorf("cxDefaultMaxDestinations = %d, want 1 — a missing config row must not open the gate",
cxDefaultMaxDestinations)
}
if cxDefaultMaxPackages <= 0 {
t.Errorf("cxDefaultMaxPackages = %d — a zero cap rejects every booking on the platform",
cxDefaultMaxPackages)
}
}

View File

@@ -0,0 +1,85 @@
package controllers
import (
"strings"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Device registration — §10 of the contract.
//
// One row per device token rather than one column on the customer. A customer
// with a phone and a tablet has to get the delivery notification on both, and
// appcustomers.device_token could only ever hold whichever registered last —
// so the older device silently stopped receiving anything.
// RegisterCxDevice records a push token for the signed-in customer.
func RegisterCxDevice(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var req struct {
Token string `json:"token"`
Platform string `json:"platform"`
AppVersion string `json:"appVersion"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
token := strings.TrimSpace(req.Token)
if token == "" {
return utils.CxBadRequest(c, "A device token is required")
}
platform := strings.ToLower(strings.TrimSpace(req.Platform))
if platform != "android" && platform != "ios" {
platform = ""
}
device := models.CustomerDevice{
Appcustomerid: customerID,
Token: token,
Platform: platform,
Appversion: strings.TrimSpace(req.AppVersion),
Lastseenat: utils.DBNow(),
}
// A token can migrate between accounts — a shared handset, or a customer
// signing out and a second one signing in. Upserting on the token (rather
// than inserting) reassigns it, which is the only outcome that does not
// send one person's parcel updates to another person's phone.
if err := db.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "token"}},
DoUpdates: clause.AssignmentColumns([]string{
"appcustomerid", "platform", "appversion", "lastseenat",
}),
}).Create(&device).Error; err != nil {
utils.Error("RegisterCxDevice: upsert failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
return utils.CxOK(c, fiber.Map{"registered": true})
}
// UnregisterCxDevice drops a push token. Called on sign-out, so a signed-out
// phone stops receiving updates about a pickup it can no longer open.
func UnregisterCxDevice(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
token := strings.TrimSpace(c.Params("token"))
if token == "" {
return utils.CxBadRequest(c, "A device token is required")
}
if err := db.DB.Where("appcustomerid = ? AND token = ?", customerID, token).
Delete(&models.CustomerDevice{}).Error; err != nil && err != gorm.ErrRecordNotFound {
utils.Error("UnregisterCxDevice: delete failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
return utils.CxOK(c, fiber.Map{"registered": false})
}

View File

@@ -0,0 +1,266 @@
package controllers
import (
"fmt"
"math"
"strings"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// Fare estimate — §7 of the contract.
//
// The number here is an ESTIMATE RANGE and never a final price. Weight is never
// collected from the customer: the miler weighs and photographs each package at
// the door, and that is when the price settles. Everything shown before that is
// a promise about a band, which is why this returns min/max rather than a
// figure.
//
// Called on every route and package-count change, so it stays cheap: the
// pricing slab comes out of the same Redis-warmed cache the public price check
// uses, and the distance is straight-line rather than a routing call.
// cxAssumedKgPerPackage is the weight band the estimate is priced against
// before anything has been weighed. It is the ceiling of the standard box the
// copy promises ("Standard box (up to 3 kg)"), so the customer is quoted the
// top of the band they were shown rather than an optimistic guess that the
// settled price then exceeds.
const cxAssumedKgPerPackage = 3.0
// cxMultiStopUpliftPct is charged per additional destination on one pickup.
// One visit collecting for three places is one visit, so the uplift is well
// under three times the price — but the parcels still travel three separate
// journeys after the hub, and pricing them as one would undercharge the part
// that actually costs money.
const cxMultiStopUpliftPct = 0.35
// cxFallbackBase / cxFallbackPerKm / cxFallbackPerKg reproduce the estimate the
// booking path already falls back to when no pricing rule matches, so an
// unpriced lane quotes the same number it charges.
const (
cxFallbackBase = 50.0
cxFallbackPerKm = 5.0
cxFallbackPerKg = 10.0
)
type cxEstimateDestination struct {
StateCode string `json:"stateCode"`
DistrictCode string `json:"districtCode"`
PackageCount int `json:"packageCount"`
}
type cxEstimateRequest struct {
Pickup struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
} `json:"pickup"`
Destinations []cxEstimateDestination `json:"destinations"`
}
// cxQuote is what both the estimate endpoint and the booking create path work
// from, so the number quoted on Review is the number stored on the booking.
type cxQuote struct {
Min int
Max int
PaymentMethod string
Parcel string
RouteKM float64
}
// EstimateCxFare prices a whole pickup — one visit, every destination.
func EstimateCxFare(c *fiber.Ctx) error {
var req cxEstimateRequest
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
if len(req.Destinations) == 0 {
return utils.CxBadRequest(c, "Add at least one destination")
}
quote := quoteCxPickup(req.Pickup.Lat, req.Pickup.Lng, req.Destinations)
// Volatile enough to be worth a short cache, stable enough that the same
// route typed twice should not re-price.
c.Set("Cache-Control", "max-age=60")
return utils.CxOK(c, fiber.Map{
"min": quote.Min,
"max": quote.Max,
"paymentMethod": quote.PaymentMethod,
"parcel": quote.Parcel,
"routeKm": quote.RouteKM,
})
}
// quoteCxPickup prices one pickup visit across all of its destinations.
//
// The farthest destination sets the route distance shown on the outline and the
// receipt; every destination contributes its own leg price, and the additional
// stops carry an uplift rather than a full second visit.
func quoteCxPickup(pickupLat, pickupLng float64, destinations []cxEstimateDestination) cxQuote {
districts := loadDistricts(destinations)
var totalMin, totalMax, maxRouteKM float64
totalPackages := 0
for i, d := range destinations {
packages := d.PackageCount
if packages < 1 {
packages = 1
}
totalPackages += packages
district, known := districts[strings.ToUpper(strings.TrimSpace(d.DistrictCode))]
routeKM := 0.0
if known && (district.Centrelatitude != 0 || district.Centrelongitude != 0) &&
(pickupLat != 0 || pickupLng != 0) {
routeKM = calculateDistance(pickupLat, pickupLng, district.Centrelatitude, district.Centrelongitude)
}
if routeKM > maxRouteKM {
maxRouteKM = routeKM
}
legMin, legMax := priceLeg(pickupLat, pickupLng, district, known, packages, routeKM)
// The first destination is the visit; every one after it is an extra
// leg on the same visit.
if i > 0 {
legMin *= cxMultiStopUpliftPct + 1
legMax *= cxMultiStopUpliftPct + 1
}
totalMin += legMin
totalMax += legMax
}
// Whole rupees, not paise — "min: 49" renders as ₹49. Rounded outward so
// the band the customer is shown always contains the price that settles
// inside it.
minR := int(math.Floor(totalMin))
maxR := int(math.Ceil(totalMax))
if maxR < minR {
maxR = minR
}
return cxQuote{
Min: minR,
Max: maxR,
PaymentMethod: "UPI · Cash at doorstep",
Parcel: describeParcels(totalPackages),
RouteKM: math.Round(maxRouteKM*10) / 10,
}
}
// priceLeg prices one destination's journey. Falls back to the same
// distance-and-weight formula the booking path uses when no pricing rule
// covers the lane, so an unpriced route still quotes rather than failing —
// a failed estimate must never block a booking.
func priceLeg(pickupLat, pickupLng float64, district models.ServiceableDistrict, known bool, packages int, routeKM float64) (min, max float64) {
weight := float64(packages) * cxAssumedKgPerPackage
zone := "National"
if known && district.Pincodeprefix != "" {
// Zone is resolved from postal prefixes, the same rule the rest of the
// pricing engine uses, so an estimate and a settlement agree on which
// slab applies.
zone = resolveZone(pincodeForPoint(pickupLat, pickupLng), district.Pincodeprefix)
}
if rules := matchedPricingRules(zone, "Normal", weight); len(rules) > 0 {
lo, hi := rules[0].Minprice, rules[0].Maxprice
for _, r := range rules[1:] {
if r.Minprice < lo {
lo = r.Minprice
}
if r.Maxprice > hi {
hi = r.Maxprice
}
}
return lo, hi
}
base := cxFallbackBase + routeKM*cxFallbackPerKm + weight*cxFallbackPerKg
// A ±20% band around the fallback, so the customer still sees a range and
// not a false precision the miler's scale is about to contradict.
return base * 0.8, base * 1.2
}
// matchedPricingRules returns the active rules covering a weight in a zone,
// reusing the Redis-warmed slab the public price check reads.
func matchedPricingRules(zone, serviceType string, weight float64) []models.DoormilePricing {
rules, err := loadFromPostgres(zone, serviceType)
if err != nil || len(rules) == 0 {
return nil
}
matched := applyFilters(rules, weight, "General")
if len(matched) == 0 {
matched = applyFilters(rules, weight, "")
}
return matched
}
// loadDistricts fetches every district named in one estimate in a single query.
func loadDistricts(destinations []cxEstimateDestination) map[string]models.ServiceableDistrict {
codes := make([]string, 0, len(destinations))
seen := map[string]bool{}
for _, d := range destinations {
code := strings.ToUpper(strings.TrimSpace(d.DistrictCode))
if code != "" && !seen[code] {
seen[code] = true
codes = append(codes, code)
}
}
out := make(map[string]models.ServiceableDistrict, len(codes))
if len(codes) == 0 {
return out
}
var districts []models.ServiceableDistrict
if err := db.DB.Where("districtcode IN ?", codes).Find(&districts).Error; err != nil {
utils.Warn("loadDistricts: query failed, pricing on the fallback formula", "error", err)
return out
}
for _, d := range districts {
out[d.Districtcode] = d
}
return out
}
// pincodeForPoint gives the zone resolver something to work with when the
// pickup is a map pin rather than a typed address. Empty is a valid answer —
// resolveZone treats an unknown prefix as a different state, which prices the
// long way round rather than under-quoting.
func pincodeForPoint(lat, lng float64) string {
if lat == 0 && lng == 0 {
return ""
}
var hubs []models.Hub
if err := db.DB.Select("hubid, pincode, latitude, longitude").
Where("status = ? AND deletedat IS NULL", "Active").Find(&hubs).Error; err != nil {
return ""
}
best := -1.0
pincode := ""
for i := range hubs {
h := hubs[i]
if h.Pincode == "" || (h.Latitude == 0 && h.Longitude == 0) {
continue
}
d := calculateDistance(lat, lng, h.Latitude, h.Longitude)
if best < 0 || d < best {
best, pincode = d, h.Pincode
}
}
return pincode
}
// describeParcels is the "what is being priced" line on Review and the receipt.
func describeParcels(packages int) string {
if packages <= 1 {
return fmt.Sprintf("Standard box (up to %.0f kg)", cxAssumedKgPerPackage)
}
return fmt.Sprintf("%d boxes (up to %.0f kg each)", packages, cxAssumedKgPerPackage)
}

713
controllers/cxHttp_test.go Normal file
View File

@@ -0,0 +1,713 @@
package controllers
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"doormile/config"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// HTTP-level contract tests for the customer surface.
//
// These exercise the real handlers through a real Fiber router and assert the
// STATUS CODE and the ENVELOPE the client will actually receive. They cover
// every path that can be reached without a database — which is every validation
// and gate in the surface, and is precisely where a wrong status code would
// reach production unnoticed.
//
// What they deliberately do NOT cover: the happy paths, which need Postgres,
// Redis and NATS. A 200 from CreateCxBooking cannot be asserted here, and
// pretending otherwise with a mock would test the mock. Those need the
// integration pass against staging (see docs/customer-app-api.md §7).
//
// The rule every test below enforces: a validation failure must be a 4xx with a
// machine-readable error.code and customer-safe English. It must NEVER be a 500
// ("Something went wrong" on a request the server understood perfectly well)
// and never a bare 404 from the router (which would mean the route is missing).
// cxFutureSlotID is a slot id whose date cannot go stale. Used by the cases
// where the SLOT is not what is under test — a dated id like slot_20260905_t1
// silently becomes an expired-slot test the day after it was written, and
// would then assert the wrong failure.
const cxFutureSlotID = "slot_20991231_t1"
// cxTestApp builds a router with the customer routes mounted and a stub auth
// middleware, so handler behaviour is tested rather than JWT parsing.
func cxTestApp(t *testing.T, authenticated bool) *fiber.App {
t.Helper()
app := fiber.New(fiber.Config{
// Without this a panic becomes a dropped connection instead of a 500,
// and a test would report a confusing transport error rather than the
// real fault.
DisableStartupMessage: true,
})
app.Use(func(c *fiber.Ctx) error {
if authenticated {
c.Locals("userid", 4242)
c.Locals("roleid", 9)
c.Locals("tenantid", 0)
}
return c.Next()
})
cfg := &config.Config{JWTSecret: "test-secret"}
customer := app.Group("/customer")
customer.Post("/auth/otp/request", CxRequestOtp(cfg))
customer.Post("/auth/signup", CxSignup(cfg))
customer.Post("/auth/otp/verify", CxVerifyOtp(cfg))
customer.Post("/auth/refresh", CxRefresh(cfg))
customer.Post("/fare/estimate", EstimateCxFare)
customer.Post("/bookings", CreateCxBooking)
customer.Patch("/bookings/:reference/destinations/:index", PatchCxDestination)
customer.Get("/orders/:trackingId", GetCxOrder)
customer.Post("/devices", RegisterCxDevice)
customer.Get("/places/reverse-geocode", ReverseGeocodeCx(cfg))
customer.Post("/ops/bookings/:reference/stage", ForceCxStage)
return app
}
type cxResponse struct {
status int
body map[string]interface{}
raw string
}
func cxDo(t *testing.T, app *fiber.App, method, path string, body interface{}) cxResponse {
t.Helper()
var reader io.Reader
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
t.Fatalf("could not encode request body: %v", err)
}
reader = bytes.NewReader(encoded)
}
req := httptest.NewRequest(method, path, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := app.Test(req, 5000)
if err != nil {
t.Fatalf("%s %s: transport error: %v", method, path, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
out := cxResponse{status: resp.StatusCode, raw: string(raw)}
_ = json.Unmarshal(raw, &out.body)
return out
}
// assertCxError checks the full error contract in one place: the status code,
// the envelope shape, the machine code, and that the message is fit to show a
// customer.
func assertCxError(t *testing.T, got cxResponse, wantStatus int, wantCode string) {
t.Helper()
if got.status != wantStatus {
t.Fatalf("status = %d, want %d (body: %s)", got.status, wantStatus, got.raw)
}
if success, _ := got.body["success"].(bool); success {
t.Errorf("success = true on an error response (body: %s)", got.raw)
}
errObj, ok := got.body["error"].(map[string]interface{})
if !ok {
t.Fatalf("no error object in the envelope — the client reads error.code (body: %s)", got.raw)
}
if code, _ := errObj["code"].(string); code != wantCode {
t.Errorf("error.code = %q, want %q", errObj["code"], wantCode)
}
message, _ := got.body["message"].(string)
if message == "" {
t.Error("message is empty — the app renders it verbatim in its one error state")
}
assertCustomerSafe(t, message)
}
// assertCustomerSafe rejects anything that reads like an internal artefact
// rather than something a customer should be shown. The contract is explicit
// that `message` is displayed verbatim and must never be an enum key, a stack
// trace or a driver error.
func assertCustomerSafe(t *testing.T, message string) {
t.Helper()
leaks := []string{
"gorm", "sql:", "pq:", "panic", "nil pointer", "goroutine",
"doormile/", ".go:", "SELECT ", "INSERT ", "record not found",
}
for _, leak := range leaks {
if bytes.Contains([]byte(message), []byte(leak)) {
t.Errorf("message %q leaks an internal detail (%q) to the customer", message, leak)
}
}
// An enum key rather than a sentence — "INVALID_INPUT", "not_found".
if message == "" {
return
}
upperOnly := true
for _, r := range message {
if r >= 'a' && r <= 'z' {
upperOnly = false
break
}
}
if upperOnly {
t.Errorf("message %q looks like an enum key, not customer-safe English", message)
}
}
// ── Auth (§4) ────────────────────────────────────────────────────────────────
func TestCxAuthValidationStatusCodes(t *testing.T) {
app := cxTestApp(t, false)
cases := []struct {
name string
method string
path string
body interface{}
wantStatus int
wantCode string
}{
{
name: "otp request with no identifier",
method: http.MethodPost, path: "/customer/auth/otp/request",
body: map[string]interface{}{"identifier": ""},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "otp request with a malformed phone",
method: http.MethodPost, path: "/customer/auth/otp/request",
body: map[string]interface{}{"identifier": "12345"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "otp request with a malformed email",
method: http.MethodPost, path: "/customer/auth/otp/request",
body: map[string]interface{}{"identifier": "joe@example"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
// The contract pins this to its own code so the app can highlight
// the name field rather than showing a generic error.
name: "signup with a one-character name",
method: http.MethodPost, path: "/customer/auth/signup",
body: map[string]interface{}{"name": "J", "phone": "+919876543210"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalidName,
},
{
name: "signup with no name at all",
method: http.MethodPost, path: "/customer/auth/signup",
body: map[string]interface{}{"phone": "+919876543210"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalidName,
},
{
name: "signup with a valid name but an unusable phone",
method: http.MethodPost, path: "/customer/auth/signup",
body: map[string]interface{}{"name": "Joe Oommen", "phone": "123"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "verify with no code",
method: http.MethodPost, path: "/customer/auth/otp/verify",
body: map[string]interface{}{"identifier": "+919876543210", "code": ""},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "verify with an unusable identifier",
method: http.MethodPost, path: "/customer/auth/otp/verify",
body: map[string]interface{}{"identifier": "nope", "code": "4821"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
// 401 rather than 400: the client's recovery is "sign in again",
// which it branches on the status for.
name: "refresh with no token",
method: http.MethodPost, path: "/customer/auth/refresh",
body: map[string]interface{}{"refreshToken": ""},
wantStatus: fiber.StatusUnauthorized, wantCode: utils.CxErrUnauthorized,
},
{
name: "refresh with a whitespace token",
method: http.MethodPost, path: "/customer/auth/refresh",
body: map[string]interface{}{"refreshToken": " "},
wantStatus: fiber.StatusUnauthorized, wantCode: utils.CxErrUnauthorized,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cxDo(t, app, tc.method, tc.path, tc.body)
// Logged so `go test -v` shows the exact bytes the app receives,
// not just a pass mark. These tests are about the wire contract, so
// the wire response is the evidence.
t.Logf("%s %s -> %d %s", tc.method, tc.path, got.status, got.raw)
assertCxError(t, got, tc.wantStatus, tc.wantCode)
})
}
}
// ── Bookings, estimate, devices, places ──────────────────────────────────────
func TestCxRequestValidationStatusCodes(t *testing.T) {
app := cxTestApp(t, true)
cases := []struct {
name string
method string
path string
body interface{}
wantStatus int
wantCode string
}{
{
name: "booking with no destinations",
method: http.MethodPost, path: "/customer/bookings",
body: map[string]interface{}{"slotId": cxFutureSlotID},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "booking with an empty destinations array",
method: http.MethodPost, path: "/customer/bookings",
body: map[string]interface{}{
"slotId": cxFutureSlotID,
"destinations": []interface{}{},
},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "booking with destinations but no slot",
method: http.MethodPost, path: "/customer/bookings",
body: map[string]interface{}{
"destinations": []map[string]interface{}{
{"stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1},
},
},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "estimate with no destinations",
method: http.MethodPost, path: "/customer/fare/estimate",
body: map[string]interface{}{"pickup": map[string]float64{"lat": 11.0, "lng": 76.9}},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "device registration with no token",
method: http.MethodPost, path: "/customer/devices",
body: map[string]interface{}{"platform": "android"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "device registration with a whitespace token",
method: http.MethodPost, path: "/customer/devices",
body: map[string]interface{}{"token": " ", "platform": "ios"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "destination patch with a non-numeric index",
method: http.MethodPatch, path: "/customer/bookings/DM-482913/destinations/abc",
body: map[string]interface{}{"street": "12th Main"},
wantStatus: fiber.StatusNotFound, wantCode: utils.CxErrNotFound,
},
{
name: "destination patch with a negative index",
method: http.MethodPatch, path: "/customer/bookings/DM-482913/destinations/-1",
body: map[string]interface{}{"street": "12th Main"},
wantStatus: fiber.StatusNotFound, wantCode: utils.CxErrNotFound,
},
{
name: "reverse geocode with no coordinates",
method: http.MethodGet, path: "/customer/places/reverse-geocode",
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "reverse geocode with unparseable coordinates",
method: http.MethodGet, path: "/customer/places/reverse-geocode?lat=abc&lng=def",
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "reverse geocode at the null island",
method: http.MethodGet, path: "/customer/places/reverse-geocode?lat=0&lng=0",
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cxDo(t, app, tc.method, tc.path, tc.body)
t.Logf("%s %s -> %d %s", tc.method, tc.path, got.status, got.raw)
assertCxError(t, got, tc.wantStatus, tc.wantCode)
})
}
}
// A body the parser cannot read is the customer's problem to fix, not a server
// fault. Answering 500 here would put a "Something went wrong" retry loop in
// front of a request that will never succeed.
func TestCxMalformedJsonIsFourHundredNotFiveHundred(t *testing.T) {
app := cxTestApp(t, true)
for _, path := range []string{
"/customer/bookings",
"/customer/fare/estimate",
"/customer/devices",
} {
t.Run(path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte("{not json")))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, 5000)
if err != nil {
t.Fatalf("transport error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("status = %d on malformed JSON, want 4xx (body: %s)", resp.StatusCode, raw)
}
if resp.StatusCode != fiber.StatusBadRequest {
t.Errorf("status = %d, want 400", resp.StatusCode)
}
})
}
}
// ── The QA stage override (§11) ──────────────────────────────────────────────
// The override is double-gated. Both switches off must be indistinguishable
// from the route not existing — advertising a disabled admin capability tells
// an attacker exactly what to go looking for.
func TestForceStageIsInvisibleUnlessBothGatesAreOpen(t *testing.T) {
app := cxTestApp(t, true)
restore := func(key, value string) func() {
previous, had := os.LookupEnv(key)
_ = os.Setenv(key, value)
return func() {
if had {
_ = os.Setenv(key, previous)
} else {
_ = os.Unsetenv(key)
}
}
}
cases := []struct {
name string
env string
override string
}{
{"both gates closed", "development", ""},
{"override off in development", "development", "false"},
{"override on but production", "production", "true"},
{"override on but PRODUCTION in caps", "PRODUCTION", "true"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
defer restore("ENV", tc.env)()
defer restore("CX_ALLOW_STAGE_OVERRIDE", tc.override)()
got := cxDo(t, app, http.MethodPost,
"/customer/ops/bookings/DM-482913/stage",
map[string]interface{}{"stage": "delivered"})
if got.status != fiber.StatusNotFound {
t.Fatalf("status = %d, want 404 — a disabled override must not announce itself (body: %s)",
got.status, got.raw)
}
})
}
}
// With both gates open the route is reachable, and an unknown stage is a
// validation failure rather than a server fault.
func TestForceStageRejectsAnUnknownStage(t *testing.T) {
app := cxTestApp(t, true)
previousEnv, hadEnv := os.LookupEnv("ENV")
previousOverride, hadOverride := os.LookupEnv("CX_ALLOW_STAGE_OVERRIDE")
_ = os.Setenv("ENV", "development")
_ = os.Setenv("CX_ALLOW_STAGE_OVERRIDE", "true")
defer func() {
if hadEnv {
_ = os.Setenv("ENV", previousEnv)
} else {
_ = os.Unsetenv("ENV")
}
if hadOverride {
_ = os.Setenv("CX_ALLOW_STAGE_OVERRIDE", previousOverride)
} else {
_ = os.Unsetenv("CX_ALLOW_STAGE_OVERRIDE")
}
}()
got := cxDo(t, app, http.MethodPost,
"/customer/ops/bookings/DM-482913/stage",
map[string]interface{}{"stage": "teleported"})
assertCxError(t, got, fiber.StatusBadRequest, utils.CxErrInvalid)
}
// ── Envelope shape (§3.1, §3.4) ──────────────────────────────────────────────
// Every success response carries `message` as a present-but-empty string. The
// contract states it explicitly, and a client that reads message.length on a
// missing key throws.
func TestSuccessEnvelopeAlwaysCarriesAnEmptyMessage(t *testing.T) {
app := fiber.New(fiber.Config{DisableStartupMessage: true})
app.Get("/ok", func(c *fiber.Ctx) error {
return utils.CxOK(c, fiber.Map{"value": 1})
})
app.Get("/created", func(c *fiber.Ctx) error {
return utils.CxCreated(c, fiber.Map{"value": 1})
})
app.Get("/list", func(c *fiber.Ctx) error {
return utils.CxList(c, []int{1, 2}, 2, nil)
})
cases := []struct {
path string
wantStatus int
}{
{"/ok", fiber.StatusOK},
{"/created", fiber.StatusCreated},
{"/list", fiber.StatusOK},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
got := cxDo(t, app, http.MethodGet, tc.path, nil)
if got.status != tc.wantStatus {
t.Fatalf("status = %d, want %d", got.status, tc.wantStatus)
}
if success, _ := got.body["success"].(bool); !success {
t.Error("success != true on a success response")
}
if _, present := got.body["message"]; !present {
t.Error("message key missing — the contract says always present, empty on success")
}
if message, _ := got.body["message"].(string); message != "" {
t.Errorf("message = %q on a success response, want empty", message)
}
if _, present := got.body["data"]; !present {
t.Error("data key missing — every payload lives in data, auth included")
}
})
}
}
// A list envelope always carries an ARRAY and an explicit nextCursor, even when
// empty. The client types data as a list and nextCursor as nullable; a missing
// key or a null data throws in its parser.
func TestListEnvelopeIsAlwaysAnArrayWithACursorKey(t *testing.T) {
app := fiber.New(fiber.Config{DisableStartupMessage: true})
app.Get("/empty", func(c *fiber.Ctx) error {
return utils.CxList(c, []string{}, 0, nil)
})
cursor := "1042"
app.Get("/paged", func(c *fiber.Ctx) error {
return utils.CxList(c, []string{"a"}, 9, &cursor)
})
empty := cxDo(t, app, http.MethodGet, "/empty", nil)
if empty.status != fiber.StatusOK {
t.Fatalf("status = %d, want 200", empty.status)
}
if _, ok := empty.body["data"].([]interface{}); !ok {
t.Errorf("data is not an array on an empty list (body: %s)", empty.raw)
}
if _, present := empty.body["nextCursor"]; !present {
t.Error("nextCursor key missing — it must be present and null on the last page")
}
if empty.body["nextCursor"] != nil {
t.Errorf("nextCursor = %v on the last page, want null", empty.body["nextCursor"])
}
if total, _ := empty.body["total"].(float64); total != 0 {
t.Errorf("total = %v, want 0", empty.body["total"])
}
paged := cxDo(t, app, http.MethodGet, "/paged", nil)
if got, _ := paged.body["nextCursor"].(string); got != cursor {
t.Errorf("nextCursor = %v, want %q", paged.body["nextCursor"], cursor)
}
if total, _ := paged.body["total"].(float64); total != 9 {
t.Errorf("total = %v, want 9 (the size of the filtered set, not the page)", paged.body["total"])
}
}
// Every code in the contract maps to the status the client branches on, and
// none of them produce a 5xx.
func TestErrorEnvelopeStatusCodeMapping(t *testing.T) {
app := fiber.New(fiber.Config{DisableStartupMessage: true})
cases := []struct {
name string
status int
code string
message string
}{
{"invalid", fiber.StatusBadRequest, utils.CxErrInvalid, "Every destination needs a serviceable state and district"},
{"invalid_name", fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name"},
{"invalid_otp", fiber.StatusUnauthorized, utils.CxErrInvalidOtp, "That code did not match"},
{"unauthorized", fiber.StatusUnauthorized, utils.CxErrUnauthorized, "Please sign in again"},
{"forbidden", fiber.StatusForbidden, utils.CxErrForbidden, "You do not have access to this"},
{"not_found", fiber.StatusNotFound, utils.CxErrNotFound, "We could not find that pickup"},
{"conflict", fiber.StatusConflict, utils.CxErrConflict, "This pickup can no longer be cancelled"},
{"unserviceable", fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable, "That district is no longer available"},
{"rate_limited", fiber.StatusTooManyRequests, utils.CxErrRateLimited, "Too many attempts. Try again in a minute"},
}
for _, tc := range cases {
tc := tc
app.Get("/"+tc.name, func(c *fiber.Ctx) error {
return utils.CxFail(c, tc.status, tc.code, tc.message)
})
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cxDo(t, app, http.MethodGet, "/"+tc.name, nil)
assertCxError(t, got, tc.status, tc.code)
if got.status >= 500 {
t.Errorf("a documented client error answered %d", got.status)
}
})
}
}
// CxInternal is the only 5xx the surface produces, and it must never carry the
// underlying error outward.
func TestInternalErrorNeverLeaksTheCause(t *testing.T) {
app := fiber.New(fiber.Config{DisableStartupMessage: true})
app.Get("/boom", func(c *fiber.Ctx) error {
return utils.CxInternal(c)
})
got := cxDo(t, app, http.MethodGet, "/boom", nil)
if got.status != fiber.StatusInternalServerError {
t.Fatalf("status = %d, want 500", got.status)
}
if message, _ := got.body["message"].(string); message != "Something went wrong" {
t.Errorf("message = %q, want the fixed customer-safe string", message)
}
assertCustomerSafe(t, got.body["message"].(string))
errObj, ok := got.body["error"].(map[string]interface{})
if !ok || errObj["code"] != utils.CxErrServer {
t.Errorf("error.code = %v, want %q", got.body["error"], utils.CxErrServer)
}
}
// An expired slot and a full slot are different failures. A client that cached
// the slot list and was left open across midnight sends yesterday's window in
// good faith; telling that customer the window "just filled up" is untrue and
// points them at the wrong recovery.
func TestExpiredSlotIsNotReportedAsFull(t *testing.T) {
app := cxTestApp(t, true)
// A slot id from a date that has certainly passed. It is rejected before
// any database access, because the id carries its own date.
got := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{
"pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558},
"slotId": "slot_20200101_t1",
"destinations": []map[string]interface{}{
{"stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1},
},
})
if got.status == fiber.StatusConflict {
t.Fatalf("an expired slot answered 409 — that is the capacity race, not a stale id (body: %s)", got.raw)
}
if got.status >= 500 {
t.Fatalf("status = %d, want 4xx (body: %s)", got.status, got.raw)
}
if message, _ := got.body["message"].(string); strings.Contains(strings.ToLower(message), "filled up") {
t.Errorf("message = %q — an expired slot is not a full one", message)
}
}
// The server refuses an over-cap booking itself. The client's own limit is UI
// guidance — a modified build, or one whose /config/booking-limits fetch
// failed, still cannot create a booking the fleet cannot service.
func TestMaxDestinationsIsEnforcedServerSide(t *testing.T) {
app := cxTestApp(t, true)
// Above the absolute ceiling, so the refusal lands before any database
// work and is assertable here. The configured per-city cap is the real
// policy and is exercised separately in cxCustomerApp_test.go.
destinations := make([]map[string]interface{}, 0, cxAbsoluteMaxDestinations+1)
for i := 0; i <= cxAbsoluteMaxDestinations; i++ {
destinations = append(destinations, map[string]interface{}{
"stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1,
})
}
got := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{
"pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558},
"slotId": "slot_20991231_t1",
"destinations": destinations,
})
if got.status >= 500 {
t.Fatalf("status = %d, want a 4xx refusal (body: %s)", got.status, got.raw)
}
if got.status < 400 {
t.Fatalf("status = %d — an over-cap booking was accepted (body: %s)", got.status, got.raw)
}
}
// "No destinations at all" and "a destination is missing its state or district"
// are different problems with different fixes. They shared one message, which
// told a customer who had added nothing to go and correct the state on
// destinations they did not have. The messages must stay distinct AND the empty
// case must match what the estimate endpoint says for the same mistake.
func TestEmptyDestinationsSaysAddOneNotFixTheirDetails(t *testing.T) {
app := cxTestApp(t, true)
booking := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{
"pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558},
"slotId": cxFutureSlotID,
"destinations": []interface{}{},
})
estimate := cxDo(t, app, http.MethodPost, "/customer/fare/estimate", map[string]interface{}{
"pickup": map[string]interface{}{"lat": 11.0168, "lng": 76.9558},
"destinations": []interface{}{},
})
t.Logf("booking -> %d %s", booking.status, booking.raw)
t.Logf("estimate -> %d %s", estimate.status, estimate.raw)
bookingMsg, _ := booking.body["message"].(string)
estimateMsg, _ := estimate.body["message"].(string)
if strings.Contains(bookingMsg, "serviceable state and district") {
t.Errorf("empty destinations answered %q — that describes a problem the customer does not have; "+
"they added nothing, so they need to add one", bookingMsg)
}
if bookingMsg != estimateMsg {
t.Errorf("the same mistake is described two ways: booking says %q, estimate says %q",
bookingMsg, estimateMsg)
}
if !strings.Contains(strings.ToLower(bookingMsg), "at least one destination") {
t.Errorf("message = %q, want it to name the actual fix (add a destination)", bookingMsg)
}
}

View File

@@ -0,0 +1,181 @@
package controllers
import (
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"os"
"strings"
"doormile/utils"
)
// Format-preserving scrambling for the customer-facing identifiers.
//
// THE PROBLEM. Both identifiers come off a Postgres sequence, because a
// sequence is the only generator here that can promise uniqueness — the columns
// are UNIQUE, and a random 8-digit id collides with ~43% probability by the
// ten-thousandth parcel, which would be a rider unable to complete a pickup.
// But a sequence is also readable: DMX10000042 and DMX10000043 are visibly
// adjacent, so anyone holding two numbers learns the throughput between them,
// and anyone holding one can guess its neighbours.
//
// THE FIX. Keep the sequence — keep its uniqueness guarantee — and pass the
// index through a bijection before formatting it. Same one-to-one property, so
// no two parcels can ever collide; unrelated outputs, so nothing is guessable
// from a neighbour.
//
// WHY NOT THE OBVIOUS TRICK. Multiplying by a number coprime with the domain is
// also a bijection and is one line. It is wrong here: a multi-destination
// pickup hands ONE customer three consecutive sequence values, so the
// differences between their three tracking numbers are all exactly the
// multiplier. One booking leaks the key, and the whole range becomes walkable.
// The mapping has to be non-linear.
//
// WHAT THIS IS. A 4-round balanced Feistel network keyed with HMAC-SHA256, plus
// cycle-walking to keep the result inside the digit range. A Feistel is a
// bijection for ANY round function — that is its defining property — so
// uniqueness survives regardless of the key. Cycle-walking (re-encrypt until
// the output lands in range) preserves bijectivity on the subset.
//
// WHAT THIS IS NOT. Not a security boundary. Every route that resolves a
// tracking number is already authenticated and owner-scoped, and that is what
// actually stops a stranger reading someone's parcel. This removes the
// information leak in the identifier itself, so the authorisation check is not
// the only thing standing between an outsider and your volume figures.
const (
// Tracking numbers occupy DMX10000000..DMX99999999 — 90,000,000 values,
// always eight digits so the format never changes width.
cxTrackingBase = 10_000_000
cxTrackingDomain = 90_000_000
// 28 bits (two 14-bit halves) is the smallest even split covering the
// domain. Cycle-walking averages ~3 encryptions per id; each is four
// HMACs, so this is microseconds.
cxTrackingHalfBits = 14
// Booking references occupy DM-100000..DM-999999 — 900,000 values, always
// six digits.
cxBookingBase = 100_000
cxBookingDomain = 900_000
// 20 bits (two 10-bit halves). Cycle-walking averages ~1.2 encryptions.
cxBookingHalfBits = 10
// cxFeistelRounds. Four is the standard minimum for a Feistel to be a
// strong pseudorandom permutation (Luby–Rackoff). More rounds cost HMACs
// for no property this needs.
cxFeistelRounds = 4
// cxCycleWalkLimit bounds the walk so a pathological key can never hang a
// request. Reaching it is astronomically unlikely — each step has a ~2/3
// chance of landing in range for tracking numbers — and the caller falls
// back to the plain sequence rather than failing a booking.
cxCycleWalkLimit = 64
)
// cxScrambleKey keys the round function.
//
// Overridable via CX_ID_SCRAMBLE_KEY. Changing it changes every identifier
// minted AFTERWARDS and none already stored, so rotation is safe but leaves a
// visible discontinuity — there is no reason to rotate it, and a good reason
// not to.
//
// The built-in default is not a secret and is not pretending to be one. It
// exists so the scrambling works out of the box rather than being silently off
// on any deployment that forgot to set an env var — an identifier scheme that
// depends on configuration to be safe is one that will be unsafe somewhere.
var cxScrambleKey = func() []byte {
if k := strings.TrimSpace(os.Getenv("CX_ID_SCRAMBLE_KEY")); k != "" {
return []byte(k)
}
return []byte("doormile-cx-identifier-permutation-v1")
}()
// cxFeistelRound is the round function. It need not be invertible — a Feistel
// is a bijection whatever this returns — so any keyed mixing works, and HMAC
// gives good diffusion for four bytes of output.
func cxFeistelRound(half uint64, round int, key []byte) uint64 {
var buf [9]byte
binary.BigEndian.PutUint64(buf[:8], half)
buf[8] = byte(round)
mac := hmac.New(sha256.New, key)
mac.Write(buf[:])
sum := mac.Sum(nil)
return uint64(binary.BigEndian.Uint32(sum[:4]))
}
// cxFeistelEncrypt permutes a value within 2^(2*halfBits).
//
// Bijective by construction: every round is invertible because the half that is
// mixed is carried forward untouched, so the whole network can be run
// backwards. That is the property the UNIQUE constraint depends on.
func cxFeistelEncrypt(x uint64, halfBits uint, key []byte) uint64 {
mask := uint64(1)<<halfBits - 1
left := (x >> halfBits) & mask
right := x & mask
for round := 0; round < cxFeistelRounds; round++ {
left, right = right, left^(cxFeistelRound(right, round, key)&mask)
}
return (left << halfBits) | right
}
// cxPermuteIndex maps a sequence index onto a scattered index in the same
// domain, one-to-one.
//
// Cycle-walking: the Feistel operates on the whole power-of-two space, which is
// larger than the digit range, so an output that overshoots is re-encrypted
// until it lands inside. Re-encrypting a bijection is still a bijection on the
// subset, so no two indices can ever converge.
func cxPermuteIndex(index, domain uint64, halfBits uint) uint64 {
if index >= domain {
// Past the end of the fixed-width range. The caller handles this;
// returning the index unchanged keeps the function total.
return index
}
x := index
for i := 0; i < cxCycleWalkLimit; i++ {
x = cxFeistelEncrypt(x, halfBits, cxScrambleKey)
if x < domain {
return x
}
}
// Unreachable in practice. Falling back to the sequential index keeps the
// identifier unique — which is the property that must never break — and
// loses only the scattering.
utils.Warn("cxPermuteIndex: cycle walk did not converge, using the sequential index",
"index", index, "domain", domain)
return index
}
// cxScrambledTracking turns a sequence value into the eight digits after DMX.
//
// Returns ok=false once the sequence runs past the fixed-width range, so the
// caller can fall back to plain sequential formatting and let the identifier
// grow a digit rather than wrapping onto one already issued.
func cxScrambledTracking(seq int64) (uint64, bool) {
if seq < cxTrackingBase {
return 0, false
}
index := uint64(seq - cxTrackingBase)
if index >= cxTrackingDomain {
return 0, false
}
return cxTrackingBase + cxPermuteIndex(index, cxTrackingDomain, cxTrackingHalfBits), true
}
// cxScrambledBooking is the same for the six digits after DM-.
func cxScrambledBooking(seq int64) (uint64, bool) {
if seq < cxBookingBase {
return 0, false
}
index := uint64(seq - cxBookingBase)
if index >= cxBookingDomain {
return 0, false
}
return cxBookingBase + cxPermuteIndex(index, cxBookingDomain, cxBookingHalfBits), true
}

View File

@@ -0,0 +1,281 @@
package controllers
import (
"fmt"
"testing"
)
// The scrambling exists to remove an information leak, but the property that
// MUST survive it is uniqueness: trackingno and bookingno are UNIQUE columns,
// and a collision is a rider standing at a door unable to complete a pickup.
// Every test here is ultimately about that.
// No two sequence values may ever produce the same tracking number. Checked
// over a large contiguous run, which is exactly the shape real traffic takes.
func TestTrackingScrambleIsCollisionFree(t *testing.T) {
const sample = 200_000
seen := make(map[uint64]int64, sample)
for seq := int64(cxTrackingBase); seq < cxTrackingBase+sample; seq++ {
got, ok := cxScrambledTracking(seq)
if !ok {
t.Fatalf("seq %d reported out of range inside the domain", seq)
}
if prev, dup := seen[got]; dup {
t.Fatalf("COLLISION: seq %d and seq %d both produced %d — "+
"the UNIQUE constraint would reject the second booking", prev, seq, got)
}
seen[got] = seq
}
if len(seen) != sample {
t.Errorf("produced %d distinct numbers from %d inputs", len(seen), sample)
}
}
func TestBookingScrambleIsCollisionFree(t *testing.T) {
// The booking domain is only 900,000, so the whole thing is checkable —
// this is an exhaustive proof of bijectivity, not a sample.
seen := make(map[uint64]int64, cxBookingDomain)
for seq := int64(cxBookingBase); seq < cxBookingBase+cxBookingDomain; seq++ {
got, ok := cxScrambledBooking(seq)
if !ok {
t.Fatalf("seq %d reported out of range inside the domain", seq)
}
if prev, dup := seen[got]; dup {
t.Fatalf("COLLISION: seq %d and seq %d both produced %d", prev, seq, got)
}
seen[got] = seq
}
if len(seen) != cxBookingDomain {
t.Fatalf("the permutation is not a bijection: %d distinct outputs from %d inputs",
len(seen), cxBookingDomain)
}
}
// Output must stay inside the digit range, or the format silently changes
// width and every label, column and deep link that assumed it breaks.
func TestScrambledIdentifiersKeepTheirWidth(t *testing.T) {
for _, seq := range []int64{
cxTrackingBase,
cxTrackingBase + 1,
cxTrackingBase + 12_345,
cxTrackingBase + cxTrackingDomain - 1,
} {
got, ok := cxScrambledTracking(seq)
if !ok {
t.Fatalf("seq %d out of range", seq)
}
if got < cxTrackingBase || got > 99_999_999 {
t.Errorf("seq %d produced %d, outside the eight-digit range", seq, got)
}
if formatted := fmt.Sprintf("DMX%08d", got); len(formatted) != 11 {
t.Errorf("formatted as %q (%d chars), want 11", formatted, len(formatted))
}
}
for _, seq := range []int64{
cxBookingBase,
cxBookingBase + 1,
cxBookingBase + cxBookingDomain - 1,
} {
got, ok := cxScrambledBooking(seq)
if !ok {
t.Fatalf("seq %d out of range", seq)
}
if got < cxBookingBase || got > 999_999 {
t.Errorf("seq %d produced %d, outside the six-digit range", seq, got)
}
if formatted := fmt.Sprintf("DM-%06d", got); len(formatted) != 9 {
t.Errorf("formatted as %q (%d chars), want 9", formatted, len(formatted))
}
}
}
// The point of the whole exercise: consecutive sequence values must NOT produce
// adjacent identifiers. This is the leak being closed.
func TestConsecutiveSequenceValuesAreNotAdjacent(t *testing.T) {
const run = 500
var previous uint64
adjacent := 0
for i := 0; i < run; i++ {
got, _ := cxScrambledTracking(int64(cxTrackingBase + i))
if i > 0 {
diff := int64(got) - int64(previous)
if diff < 0 {
diff = -diff
}
if diff < 100 {
adjacent++
}
}
previous = got
}
// In a well-scattered 90,000,000-wide range, landing within 100 of the
// previous value should essentially never happen.
if adjacent > 2 {
t.Errorf("%d of %d consecutive pairs landed within 100 of each other — "+
"the identifiers are still walkable", adjacent, run-1)
}
}
// A multi-destination pickup hands ONE customer several consecutive sequence
// values at once. If the mapping were linear — multiply by a coprime, the
// obvious one-line trick — the differences between those tracking numbers would
// all equal the multiplier, and that single booking would hand over the key to
// the whole range. This is the test that rejects that design.
func TestOneBookingDoesNotLeakTheMapping(t *testing.T) {
// Three orders minted back to back, as a three-destination pickup would.
a, _ := cxScrambledTracking(cxTrackingBase + 5000)
b, _ := cxScrambledTracking(cxTrackingBase + 5001)
c, _ := cxScrambledTracking(cxTrackingBase + 5002)
d1 := int64(b) - int64(a)
d2 := int64(c) - int64(b)
if d1 == d2 {
t.Fatalf("consecutive differences are identical (%d) — the mapping is "+
"linear, so one multi-destination booking reveals it and the whole "+
"range becomes enumerable", d1)
}
// And knowing two neighbours must not predict the third.
if int64(c) == int64(b)+d1 {
t.Error("the third identifier is predictable from the first two")
}
}
// The mapping is deterministic — the same sequence value always yields the same
// identifier. It is computed at insert time and stored, so this matters only
// for reasoning and tests, but a non-deterministic mapping would mean the
// scrambling depended on something it should not.
func TestScramblingIsDeterministic(t *testing.T) {
for _, seq := range []int64{cxTrackingBase, cxTrackingBase + 99, cxTrackingBase + 123_456} {
first, _ := cxScrambledTracking(seq)
for i := 0; i < 5; i++ {
again, _ := cxScrambledTracking(seq)
if again != first {
t.Fatalf("seq %d produced %d then %d", seq, first, again)
}
}
}
}
// Past the fixed-width range the caller must be told, so it can let the
// identifier grow a digit rather than wrap onto one already issued. Wrapping
// would be a duplicate, and a duplicate is a failed booking.
func TestExhaustedDomainIsReportedNotWrapped(t *testing.T) {
if _, ok := cxScrambledTracking(cxTrackingBase + cxTrackingDomain); ok {
t.Error("the first sequence value past the tracking domain was accepted — " +
"it would wrap onto an identifier already issued")
}
if _, ok := cxScrambledBooking(cxBookingBase + cxBookingDomain); ok {
t.Error("the first sequence value past the booking domain was accepted")
}
// And the generator falls back to plain sequential formatting there, which
// grows a digit rather than colliding.
if got := fmt.Sprintf("DM-%06d", cxBookingBase+cxBookingDomain); len(got) != 10 {
t.Errorf("the overflow reference formats as %q; it should simply grow a digit", got)
}
}
// A value below the sequence start is not a valid index and must be refused
// rather than producing a negative or wrapped result.
func TestBelowBaseIsRefused(t *testing.T) {
if _, ok := cxScrambledTracking(0); ok {
t.Error("seq 0 accepted for tracking")
}
if _, ok := cxScrambledBooking(cxBookingBase - 1); ok {
t.Error("a sequence value below the booking base was accepted")
}
}
// The Feistel itself is a permutation over the full power-of-two space. This is
// the property everything else rests on, so it is checked directly rather than
// only through its callers.
func TestFeistelIsAPermutation(t *testing.T) {
const halfBits = 8 // a 16-bit space, small enough to check exhaustively
full := uint64(1) << (2 * halfBits)
seen := make(map[uint64]uint64, full)
for x := uint64(0); x < full; x++ {
y := cxFeistelEncrypt(x, halfBits, cxScrambleKey)
if y >= full {
t.Fatalf("encrypt(%d) = %d, outside the %d-wide space", x, y, full)
}
if prev, dup := seen[y]; dup {
t.Fatalf("not a permutation: %d and %d both map to %d", prev, x, y)
}
seen[y] = x
}
if uint64(len(seen)) != full {
t.Fatalf("covered %d of %d values", len(seen), full)
}
}
// A different key must produce a different permutation — otherwise the key is
// not actually keying anything.
func TestKeyChangesThePermutation(t *testing.T) {
const halfBits = 8
differences := 0
for x := uint64(0); x < 256; x++ {
if cxFeistelEncrypt(x, halfBits, []byte("key-one")) !=
cxFeistelEncrypt(x, halfBits, []byte("key-two")) {
differences++
}
}
if differences < 250 {
t.Errorf("only %d of 256 values differed between keys — the key has "+
"little effect on the mapping", differences)
}
}
// Every surface — customer app, miler app, admin console, hub console — reads
// the SAME column, so format consistency is structural: one generator, one
// stored value. These assert the generators themselves produce the documented
// shape, including on the fallback path that runs when the sequence cannot be
// read (no database in a test, which is exactly what exercises it here).
func TestGeneratorsProduceTheDocumentedFormat(t *testing.T) {
for i := 0; i < 50; i++ {
booking := generateBookingNo()
if len(booking) < 9 || booking[:3] != "DM-" {
t.Fatalf("generateBookingNo() = %q, want DM- followed by at least six digits", booking)
}
for _, r := range booking[3:] {
if r < '0' || r > '9' {
t.Fatalf("generateBookingNo() = %q — the part after DM- must be digits only", booking)
}
}
tracking := generateTrackingNo()
if len(tracking) < 11 || tracking[:3] != "DMX" {
t.Fatalf("generateTrackingNo() = %q, want DMX followed by at least eight digits", tracking)
}
for _, r := range tracking[3:] {
if r < '0' || r > '9' {
t.Fatalf("generateTrackingNo() = %q — the part after DMX must be digits only", tracking)
}
}
// A tracking number must never be mistakeable for a booking reference:
// the assistant and the consoles tell them apart by prefix alone.
if tracking[:3] == "DM-" {
t.Fatalf("tracking number %q collides with the booking reference prefix", tracking)
}
}
}
// The fallback path must never emit a short number that formats with leading
// zeros — DM-000042 reads as a broken reference, and a padded id is a support
// call.
func TestFallbackNumberNeverGoesShort(t *testing.T) {
for i := 0; i < 200; i++ {
if n := fallbackNumber(6); n < 100_000 || n > 999_999 {
t.Fatalf("fallbackNumber(6) = %d, outside the six-digit range", n)
}
if n := fallbackNumber(8); n < 10_000_000 || n > 99_999_999 {
t.Fatalf("fallbackNumber(8) = %d, outside the eight-digit range", n)
}
}
}

View File

@@ -0,0 +1,95 @@
package controllers
import (
"fmt"
"math/rand"
"time"
"doormile/db"
"doormile/utils"
)
// Human-facing identifiers.
//
// A pickup booking is DM-######; an order is DMX########. Both columns are
// UNIQUE, and both used to be minted from four random bytes plus a truncated
// unix second. Random short ids collide long before the id space runs out, and
// a collision here is not a retry — it is a failed booking at the moment the
// customer taps Confirm. So both come off a Postgres sequence, which is the
// only generator in this system that can promise uniqueness.
//
// The sequences have no MAXVALUE and no CYCLE (migrations/migrate.go): past
// 999999 the reference simply grows a digit rather than wrapping onto an id
// that already exists. Rows written before this change keep their old
// DM-BK-/DM-TRK- strings; nothing anywhere parses either format, so the two
// coexist and no backfill is needed.
// nextSequenceValue draws the next value from a Postgres sequence.
func nextSequenceValue(sequence string) (int64, bool) {
if db.DB == nil {
return 0, false
}
var n int64
if err := db.DB.Raw(fmt.Sprintf("SELECT nextval('%s')", sequence)).Scan(&n).Error; err != nil {
utils.Warn("identifier sequence unavailable, falling back", "sequence", sequence, "error", err)
return 0, false
}
return n, true
}
// fallbackNumber is used only when the sequence cannot be read — a database
// that is unreachable, or a deployment where the migration has not run yet. It
// keeps the shape of the identifier (so the client and the console never see a
// second format) and takes its entropy from the clock plus a random tail,
// which makes a collision vanishingly unlikely for the short window this path
// is ever live. It is a degradation, not a design: the sequence is the
// guarantee.
func fallbackNumber(digits int) int64 {
span := int64(1)
for i := 0; i < digits; i++ {
span *= 10
}
base := time.Now().UnixNano() % span
jitter := rand.Int63n(1000)
n := (base + jitter) % span
// Never return a value that would render with fewer digits than the format
// promises — DM-000042 reads as a broken reference, not a short one.
if n < span/10 {
n += span / 10
}
return n
}
// generateBookingNo mints a pickup booking reference: DM-482913.
//
// The sequence guarantees uniqueness; cxScrambledBooking scatters it so
// consecutive bookings do not get adjacent references. See
// cxIdentifierScramble.go for why the scattering is a keyed permutation rather
// than arithmetic.
//
// Past 900,000 bookings the fixed-width range is exhausted and the reference
// grows a digit instead of wrapping onto one already issued. Uniqueness is
// never traded for appearance.
func generateBookingNo() string {
if n, ok := nextSequenceValue("cx_booking_reference_seq"); ok {
if scrambled, inRange := cxScrambledBooking(n); inRange {
return fmt.Sprintf("DM-%06d", scrambled)
}
return fmt.Sprintf("DM-%06d", n)
}
return fmt.Sprintf("DM-%06d", fallbackNumber(6))
}
// generateTrackingNo mints an order's tracking number: DMX10482913. One per
// destination, minted when the miler completes the pickup — never at booking
// time, because until the parcels are actually collected there is no order to
// track.
func generateTrackingNo() string {
if n, ok := nextSequenceValue("cx_tracking_seq"); ok {
if scrambled, inRange := cxScrambledTracking(n); inRange {
return fmt.Sprintf("DMX%08d", scrambled)
}
return fmt.Sprintf("DMX%08d", n)
}
return fmt.Sprintf("DMX%08d", fallbackNumber(8))
}

View File

@@ -0,0 +1,160 @@
package controllers
import (
"os"
"strings"
"doormile/constants"
"doormile/db"
"doormile/internal/cxstage"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// QA support — §11 of the deliverables.
//
// "A way to force a booking to any stage on staging. Every tracking state must
// be reachable for QA — this is what lets us delete the debug stepper."
//
// The customer app currently walks its tracking screen through a hardcoded
// stepper because no real backend could produce those states on demand.
// Reaching out_for_delivery honestly needs a rider to accept, drive, weigh a
// parcel, hand it to a hub and start a delivery run — which is not something
// design QA can do before every screenshot.
//
// This endpoint is hard-gated. It refuses outright when ENV is production, and
// it additionally requires CX_ALLOW_STAGE_OVERRIDE to be set: two independent
// switches, because one of them being wrong on a production deploy would hand
// anyone with a customer token the ability to mark their own parcel delivered.
// cxStageOverrideEnabled reports whether the QA stage override may run at all.
func cxStageOverrideEnabled() bool {
if strings.EqualFold(strings.TrimSpace(os.Getenv("ENV")), "production") {
return false
}
return strings.EqualFold(strings.TrimSpace(os.Getenv("CX_ALLOW_STAGE_OVERRIDE")), "true")
}
// ForceCxStage drives a booking to an arbitrary stage on staging.
//
// It writes through the same cxstage recorder every real transition uses, so
// what QA sees is the real projection over real event rows — not a special
// rendering path that could pass while the production one is broken.
func ForceCxStage(c *fiber.Ctx) error {
if !cxStageOverrideEnabled() {
return utils.CxNotFound(c, "Not available")
}
customerID := c.Locals("userid").(int)
reference := strings.TrimSpace(c.Params("reference"))
var req struct {
Stage string `json:"stage"`
// Reason is recorded on the audit row so a forced transition is
// distinguishable from a real one forever after. A staging database
// that gets promoted, or an export read months later, must not present
// invented history as observed history.
Reason string `json:"reason"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
stage := strings.TrimSpace(req.Stage)
if constants.CxStageRank(stage) < 0 {
return utils.CxBadRequest(c, "Unknown stage")
}
booking, err := cxLoadBooking(customerID, reference)
if err != nil {
return utils.CxNotFound(c, "We could not find that pickup")
}
var destinations []models.BookingDestination
if err := db.DB.Where("bookingid = ?", booking.Bookingid).
Order("seq ASC").Find(&destinations).Error; err != nil {
utils.Error("ForceCxStage: destination query failed", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
reason = "forced on staging for QA"
}
tx := db.DB.Begin()
// Walk every stage up to the target rather than jumping. A timeline with a
// hole in it is not a state the production flow can produce, so testing
// against one proves nothing about the screen that renders it.
for _, s := range []string{
constants.CxStageBooked, constants.CxStageAssigned, constants.CxStageOnTheWay,
constants.CxStageArrived, constants.CxStagePickedUp, constants.CxStageOrderCreated,
constants.CxStageInTransit, constants.CxStageOutForDelivery, constants.CxStageDelivered,
} {
if constants.CxStageRank(s) > constants.CxStageRank(stage) {
break
}
perOrder := constants.CxStageRank(s) >= constants.CxStageOrder[constants.CxStageInTransit]
if perOrder && len(destinations) > 0 {
for i := range destinations {
id := destinations[i].Bookingdestinationid
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
DestinationID: &id,
Stage: s,
ActorType: constants.CxActorOps,
ActorID: &customerID,
Source: "POST /customer/ops/bookings/{reference}/stage",
Remarks: reason,
}); err != nil {
tx.Rollback()
utils.Error("ForceCxStage: record failed", "booking_id", booking.Bookingid, "stage", s, "error", err)
return utils.CxInternal(c)
}
}
continue
}
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
Stage: s,
ActorType: constants.CxActorOps,
ActorID: &customerID,
Source: "POST /customer/ops/bookings/{reference}/stage",
Remarks: reason,
}); err != nil {
tx.Rollback()
utils.Error("ForceCxStage: record failed", "booking_id", booking.Bookingid, "stage", s, "error", err)
return utils.CxInternal(c)
}
}
// Tracking numbers exist from order_created onward, so a forced booking
// past that point needs them or the orders render with a null trackingId
// and the deep-link path cannot be tested at all.
if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStageOrderCreated] {
for i := range destinations {
if destinations[i].Trackingno != "" {
continue
}
if err := tx.Model(&models.BookingDestination{}).
Where("bookingdestinationid = ?", destinations[i].Bookingdestinationid).
Update("trackingno", generateTrackingNo()).Error; err != nil {
tx.Rollback()
utils.Error("ForceCxStage: could not mint tracking number", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
}
}
if err := tx.Commit().Error; err != nil {
utils.Error("ForceCxStage: commit failed", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusOK)
}

View File

@@ -0,0 +1,251 @@
package controllers
import (
"math"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
"gorm.io/gorm"
)
// Pickup fan-out: one visit becomes N orders.
//
// This is the change §1 of the customer contract turns on. A pickup booking
// used to convert into exactly one consignment, because a booking carried
// exactly one delivery address in its own columns. A customer-app booking
// carries 1..N destinations, and each of those has to become its own
// consignment with its own tracking number and its own journey — three parcels
// collected in one visit for Chennai, Kochi and Bengaluru travel three separate
// routes the moment they leave the door.
//
// A booking with no destination rows — every console-created express booking,
// and every row written before this table existed — produces exactly one leg
// built from the flat delivery columns, which is byte-for-byte the behaviour
// that was there before. Single-destination is not a special case in either
// direction: it is one leg, through the same code.
// cxPickupLeg is one journey to create at pickup completion.
type cxPickupLeg struct {
// Destination is nil for a booking that has no destination rows.
Destination *models.BookingDestination
DeliveryLatitude float64
DeliveryLongitude float64
DeliveryPincode string
CodAmount float64
// Parcels are the packages going to this destination, already weighed by
// the miler.
Parcels []models.BookingParcel
}
// cxPickupLegs splits a booking into the journeys its parcels are about to
// take.
func cxPickupLegs(tx *gorm.DB, booking *models.PickupBooking) ([]cxPickupLeg, error) {
var destinations []models.BookingDestination
if err := tx.Where("bookingid = ?", booking.Bookingid).
Order("seq ASC").Find(&destinations).Error; err != nil {
return nil, err
}
var parcels []models.BookingParcel
if err := tx.Where("bookingid = ?", booking.Bookingid).Find(&parcels).Error; err != nil {
return nil, err
}
if len(destinations) == 0 {
// The pre-existing shape: one consignment from the booking's own
// delivery columns, carrying every parcel on the booking.
return []cxPickupLeg{{
DeliveryLatitude: booking.Deliverylatitude,
DeliveryLongitude: booking.Deliverylongitude,
DeliveryPincode: booking.Deliverypincode,
Parcels: parcels,
}}, nil
}
byDestination := map[int][]models.BookingParcel{}
unassigned := []models.BookingParcel{}
for _, p := range parcels {
if p.Bookingdestinationid == nil {
unassigned = append(unassigned, p)
continue
}
byDestination[*p.Bookingdestinationid] = append(byDestination[*p.Bookingdestinationid], p)
}
legs := make([]cxPickupLeg, 0, len(destinations))
for i := range destinations {
d := destinations[i]
lat, lng := cxDestinationPoint(&d)
leg := cxPickupLeg{
Destination: &destinations[i],
DeliveryLatitude: lat,
DeliveryLongitude: lng,
DeliveryPincode: d.Pincode,
CodAmount: d.Codamount,
Parcels: byDestination[d.Bookingdestinationid],
}
// Parcels the miler added at the door without naming a destination go
// with the first one. Attributing them to nothing would drop them out
// of every weight total, and the first destination is the one the rider
// was shown.
if i == 0 {
leg.Parcels = append(leg.Parcels, unassigned...)
}
legs = append(legs, leg)
}
return legs, nil
}
// cxDestinationPoint gives a destination the best coordinates it has: the
// customer's own map pin if they dropped one, otherwise the district centre.
// Only state and district are required at booking time, so the centre is
// frequently all there is — and routing skips stops sitting at 0,0.
func cxDestinationPoint(d *models.BookingDestination) (lat, lng float64) {
if d.Pinlatitude != nil && d.Pinlongitude != nil &&
(*d.Pinlatitude != 0 || *d.Pinlongitude != 0) {
return *d.Pinlatitude, *d.Pinlongitude
}
var district models.ServiceableDistrict
if err := db.DB.Select("centrelatitude, centrelongitude").
Where("districtcode = ?", d.Districtcode).First(&district).Error; err == nil {
return district.Centrelatitude, district.Centrelongitude
}
return 0, 0
}
// cxLegWeights totals a leg's packages the way the consignment records them.
// A leg whose packages were never weighed falls back to the same 0.5 kg
// placeholder the single-consignment path already used, so an unweighed pickup
// still produces a chargeable consignment rather than a zero-weight one.
func cxLegWeights(leg cxPickupLeg) (deadWeight, chargeable, maxL, maxW, maxH float64) {
for _, p := range leg.Parcels {
vol := calculateVolumetricWeight(p.Length, p.Width, p.Height)
deadWeight += p.Weight
chargeable += math.Max(p.Weight, vol)
if p.Length > maxL {
maxL = p.Length
}
if p.Width > maxW {
maxW = p.Width
}
if p.Height > maxH {
maxH = p.Height
}
}
if len(leg.Parcels) == 0 || chargeable == 0 {
deadWeight, chargeable = 0.5, 0.5
}
return
}
// cxLinkLegToOrder records that a destination has become an order: its tracking
// number, its consignment, and the delivery date the customer is promised.
func cxLinkLegToOrder(tx *gorm.DB, leg cxPickupLeg, consignmentID int, trackingNo string, expected *time.Time, at time.Time) error {
if leg.Destination == nil {
return nil
}
updates := map[string]interface{}{
"consignmentid": consignmentID,
"trackingno": trackingNo,
"stage": constants.CxStageOrderCreated,
"updatedat": at,
}
if expected != nil {
updates["expecteddeliveryat"] = *expected
}
return tx.Model(&models.BookingDestination{}).
Where("bookingdestinationid = ?", leg.Destination.Bookingdestinationid).
Updates(updates).Error
}
// cxRecordVerification stores what the miler measured at the door: the weight
// the price settles on, when, and who took the reading. It is the evidence half
// of the receipt, and it is per destination because each order is weighed
// separately.
func cxRecordVerification(tx *gorm.DB, destinationID int, weightKg float64, byUserID int, at time.Time) error {
return tx.Model(&models.BookingDestination{}).
Where("bookingdestinationid = ?", destinationID).
Updates(map[string]interface{}{
"verifiedweightkg": weightKg,
"verifiedat": at,
"verifiedbyuserid": byUserID,
"updatedat": at,
}).Error
}
// cxDestinationForConsignment finds which order a consignment belongs to.
//
// The delivery handlers used to reach the booking with
// `WHERE consignmentid = ?` on pickupbookings, which held exactly one
// consignment id. With a fan-out that column only names the FIRST order, so
// that lookup silently found nothing for destinations 2..N — no customer
// notification, no stage advance, on every multi-destination booking. The join
// goes through bookingdestinations now, which is the table that actually knows.
func cxDestinationForConsignment(consignmentID int) (*models.BookingDestination, *models.PickupBooking, bool) {
var dest models.BookingDestination
if err := db.DB.Where("consignmentid = ?", consignmentID).First(&dest).Error; err == nil {
var booking models.PickupBooking
if err := db.DB.First(&booking, dest.Bookingid).Error; err == nil {
return &dest, &booking, true
}
return &dest, nil, false
}
// No destination row: a console-created booking, or one written before the
// fan-out existed. The legacy link still answers for those.
var booking models.PickupBooking
if err := db.DB.Where("consignmentid = ?", consignmentID).First(&booking).Error; err != nil {
return nil, nil, false
}
return nil, &booking, true
}
// cxDestinationIDFor returns the destination id to attribute a per-order stage
// to, or nil when the booking has no destination rows.
func cxDestinationIDFor(dest *models.BookingDestination) *int {
if dest == nil {
return nil
}
id := dest.Bookingdestinationid
return &id
}
// cxEstimatedDelivery is the promise shown to the customer, derived from the
// serving district's own promise text rather than a single platform-wide SLA —
// "Next-day delivery" into Chennai and "2-day delivery" into a district two
// states away are different promises and must not resolve to the same date.
func cxEstimatedDelivery(leg cxPickupLeg, from time.Time) *time.Time {
days := 2
if leg.Destination != nil {
var district models.ServiceableDistrict
if err := db.DB.Select("promise").
Where("districtcode = ?", leg.Destination.Districtcode).
First(&district).Error; err == nil {
switch district.Promise {
case "Same-day delivery":
days = 0
case "Next-day delivery":
days = 1
case "2-day delivery":
days = 2
case "3-day delivery":
days = 3
}
}
}
// Delivered by end of the promised day, expressed in the wall clock this
// database stores.
target := from.AddDate(0, 0, days)
expected := time.Date(target.Year(), target.Month(), target.Day(), 20, 0, 0, 0, time.UTC)
if utils.EpochMillis(expected) < utils.EpochMillis(from) {
expected = from
}
return &expected
}

View File

@@ -0,0 +1,350 @@
package controllers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"doormile/config"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// Places — §6 of the contract.
//
// Both endpoints proxy a geocoder rather than handing the app a key. The legacy
// rider app shipped a Google Maps key inside the binary; it was extracted and
// had to be revoked, and that side now runs on OSM/OSRM with no key at all. The
// customer app is never given one: it asks Doormile, Doormile asks the
// geocoder, and the answer is cached so the same street typed a hundred times
// costs one upstream call.
const (
// cxGeocodeTimeout is short on purpose. The pickup point already has a
// device-supplied coordinate; a slow geocoder should degrade the label, not
// stall the booking form.
cxGeocodeTimeout = 4 * time.Second
// cxGeocodeCacheTTL — addresses do not move. A week is conservative.
cxGeocodeCacheTTL = 7 * 24 * time.Hour
cxSearchCacheTTL = 24 * time.Hour
cxMaxSearchResults = 6
// cxRecentPlacesShown is what the search sheet opens on: the customer's own
// recent and saved places, capped by the design.
cxRecentPlacesShown = 4
)
// cxPlace is the one shape a place is returned in, from search, from reverse
// geocode and from the saved-address list. title is a short label the UI puts
// on one line; sub is the full address under it. Neither is ever null — the
// client types both as non-nullable strings.
type cxPlace struct {
Title string `json:"title"`
Sub string `json:"sub"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}
// ReverseGeocodeCx turns the device's coordinates into the pickup label.
func ReverseGeocodeCx(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
lat, latErr := strconv.ParseFloat(c.Query("lat"), 64)
lng, lngErr := strconv.ParseFloat(c.Query("lng"), 64)
if latErr != nil || lngErr != nil || (lat == 0 && lng == 0) {
return utils.CxBadRequest(c, "We need a location to look up")
}
cacheKey := fmt.Sprintf("cx:geo:rev:%.5f:%.5f", lat, lng)
if cached, ok := cxCacheGet(cacheKey); ok {
var place cxPlace
if json.Unmarshal([]byte(cached), &place) == nil {
return utils.CxOK(c, place)
}
}
place, err := cxReverseGeocode(cfg, lat, lng)
if err != nil {
utils.Warn("ReverseGeocodeCx: upstream failed", "error", err)
// A label the customer can correct beats a blocked booking form.
// The coordinates are what the rider actually navigates to; the
// text is what the customer reads, and they can edit it.
return utils.CxOK(c, cxPlace{
Title: "Selected location",
Sub: fmt.Sprintf("%.5f, %.5f", lat, lng),
Lat: lat,
Lng: lng,
})
}
if data, merr := json.Marshal(place); merr == nil {
cxCacheSet(cacheKey, string(data), cxGeocodeCacheTTL)
}
return utils.CxOK(c, place)
}
}
// SearchCxPlaces backs the pickup-point search sheet.
//
// An empty query is not an error: the sheet opens on it, and answers with the
// customer's own saved and recently used places.
func SearchCxPlaces(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
q := strings.TrimSpace(c.Query("q"))
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
if q == "" {
customerID, _ := c.Locals("userid").(int)
places := cxRecentPlaces(customerID)
return utils.CxList(c, places, len(places), nil)
}
cacheKey := fmt.Sprintf("cx:geo:q:%s:%.2f:%.2f", strings.ToLower(q), lat, lng)
if cached, ok := cxCacheGet(cacheKey); ok {
var places []cxPlace
if json.Unmarshal([]byte(cached), &places) == nil {
return utils.CxList(c, places, len(places), nil)
}
}
places, err := cxSearchPlaces(cfg, q, lat, lng)
if err != nil {
utils.Warn("SearchCxPlaces: upstream failed", "error", err)
// An empty list is a designed state in the sheet ("no matches");
// an error is a retry button in the middle of a booking.
return utils.CxList(c, []cxPlace{}, 0, nil)
}
if data, merr := json.Marshal(places); merr == nil {
cxCacheSet(cacheKey, string(data), cxSearchCacheTTL)
}
return utils.CxList(c, places, len(places), nil)
}
}
// ── Upstream ─────────────────────────────────────────────────────────────────
type nominatimPlace struct {
DisplayName string `json:"display_name"`
Lat string `json:"lat"`
Lon string `json:"lon"`
Name string `json:"name"`
Address struct {
Road string `json:"road"`
HouseNumber string `json:"house_number"`
Neighbourhood string `json:"neighbourhood"`
Suburb string `json:"suburb"`
City string `json:"city"`
Town string `json:"town"`
Village string `json:"village"`
StateDistrict string `json:"state_district"`
State string `json:"state"`
Postcode string `json:"postcode"`
} `json:"address"`
}
func cxReverseGeocode(cfg *config.Config, lat, lng float64) (cxPlace, error) {
endpoint := fmt.Sprintf("%s/reverse?format=jsonv2&lat=%f&lon=%f&addressdetails=1&zoom=18",
strings.TrimRight(cfg.GeocoderURL, "/"), lat, lng)
var place nominatimPlace
if err := cxGeocoderGet(cfg, endpoint, &place); err != nil {
return cxPlace{}, err
}
return cxPlaceFrom(place, lat, lng), nil
}
func cxSearchPlaces(cfg *config.Config, q string, lat, lng float64) ([]cxPlace, error) {
endpoint := fmt.Sprintf("%s/search?format=jsonv2&addressdetails=1&limit=%d&countrycodes=in&q=%s",
strings.TrimRight(cfg.GeocoderURL, "/"), cxMaxSearchResults, url.QueryEscape(q))
// Bias to where the customer is. A "Brookefields" typed in Coimbatore must
// not answer with one in another state first.
if lat != 0 || lng != 0 {
const box = 0.75 // degrees, roughly 80km
endpoint += fmt.Sprintf("&viewbox=%f,%f,%f,%f&bounded=0",
lng-box, lat+box, lng+box, lat-box)
}
var raw []nominatimPlace
if err := cxGeocoderGet(cfg, endpoint, &raw); err != nil {
return nil, err
}
places := make([]cxPlace, 0, len(raw))
for _, r := range raw {
plat, _ := strconv.ParseFloat(r.Lat, 64)
plng, _ := strconv.ParseFloat(r.Lon, 64)
places = append(places, cxPlaceFrom(r, plat, plng))
}
return places, nil
}
func cxGeocoderGet(cfg *config.Config, endpoint string, out interface{}) error {
ctx, cancel := context.WithTimeout(context.Background(), cxGeocodeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
// Nominatim's usage policy requires an identifiable caller; anonymous
// traffic gets throttled or blocked outright.
agent := "doormile-backend/1.0"
if cfg.GeocoderEmail != "" {
agent += " (" + cfg.GeocoderEmail + ")"
}
req.Header.Set("User-Agent", agent)
req.Header.Set("Accept-Language", "en")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("geocoder returned %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// cxPlaceFrom builds the two-line label. title is capped at the 32 characters
// the design allots it, and falls back through the parts of the address most
// likely to be recognisable at a glance.
func cxPlaceFrom(p nominatimPlace, lat, lng float64) cxPlace {
title := strings.TrimSpace(p.Name)
if title == "" {
title = joinNonEmpty(" ", p.Address.HouseNumber, p.Address.Road)
}
if title == "" {
title = firstNonEmpty(p.Address.Neighbourhood, p.Address.Suburb,
p.Address.City, p.Address.Town, p.Address.Village)
}
if title == "" {
// Last resort: the leading segment of the display name.
if i := strings.Index(p.DisplayName, ","); i > 0 {
title = strings.TrimSpace(p.DisplayName[:i])
} else {
title = "Selected location"
}
}
if r := []rune(title); len(r) > 32 {
title = strings.TrimSpace(string(r[:32]))
}
sub := joinNonEmpty(", ",
firstNonEmpty(p.Address.Neighbourhood, p.Address.Suburb),
firstNonEmpty(p.Address.City, p.Address.Town, p.Address.Village, p.Address.StateDistrict),
p.Address.Postcode)
if sub == "" {
sub = strings.TrimSpace(p.DisplayName)
}
if sub == "" {
sub = fmt.Sprintf("%.5f, %.5f", lat, lng)
}
return cxPlace{Title: title, Sub: sub, Lat: lat, Lng: lng}
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
// cxRecentPlaces answers an empty search with the places this customer already
// uses — their saved addresses first, then the pickup points of their recent
// bookings. Booking again from the same doorstep is the common case, and making
// them re-type it is the friction this removes.
func cxRecentPlaces(customerID int) []cxPlace {
places := make([]cxPlace, 0, cxRecentPlacesShown)
seen := map[string]bool{}
add := func(p cxPlace) {
if len(places) >= cxRecentPlacesShown {
return
}
key := fmt.Sprintf("%.4f:%.4f", p.Lat, p.Lng)
if seen[key] || (p.Lat == 0 && p.Lng == 0) {
return
}
seen[key] = true
places = append(places, p)
}
if customerID != 0 {
var saved []models.AppCustomerLocation
if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active").
Order("isdefault DESC, appcustomerlocationid DESC").
Limit(cxRecentPlacesShown).Find(&saved).Error; err == nil {
for _, l := range saved {
title := l.Label
if title == "" {
title = l.Address
}
add(cxPlace{
Title: title,
Sub: joinNonEmpty(", ", l.Address, l.City, l.Pincode),
Lat: l.Latitude,
Lng: l.Longitude,
})
}
}
var recent []models.PickupBooking
if err := db.DB.Select("pickuptitle, pickupsub, pickupaddress, pickuppincode, pickuplatitude, pickuplongitude").
Where("appcustomerid = ?", customerID).
Order("bookingid DESC").Limit(10).Find(&recent).Error; err == nil {
for i := range recent {
b := recent[i]
add(cxPlace{
Title: cxPickupTitle(&b),
Sub: cxPickupSub(&b),
Lat: b.Pickuplatitude,
Lng: b.Pickuplongitude,
})
}
}
}
return places
}
// ── Cache ────────────────────────────────────────────────────────────────────
// cxCacheGet / cxCacheSet are best-effort. A geocoder answer is a convenience,
// never a correctness requirement, so a Redis outage costs latency and upstream
// quota rather than the feature.
func cxCacheGet(key string) (string, bool) {
if db.Rdb == nil {
return "", false
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
value, err := db.Rdb.Get(ctx, key).Result()
if err != nil {
return "", false
}
return value, true
}
func cxCacheSet(key, value string, ttl time.Duration) {
if db.Rdb == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
if err := db.Rdb.Set(ctx, key, value, ttl).Err(); err != nil {
utils.Warn("cxCacheSet: failed", "key", key, "error", err)
}
}

View File

@@ -443,8 +443,13 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
// the base gate — and ridercharges the order amount, both written the same way
// MilerDeliverConsignment writes them for a final-mile leg. Without this an
// intercity rider's every job reported zero distance and zero value.
var booking models.PickupBooking
if tx.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil {
// Resolved through bookingdestinations, not through
// pickupbookings.consignmentid. That column names only the FIRST order of a
// multi-destination pickup, so joining on it found nothing for orders 2..N
// — and an intercity rider handing in the second parcel of a three-stop
// pickup had their assignment left open and their distance recorded as zero.
if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil {
booking := *bookingPtr
dropLat, dropLon := lat, lon
if dropLat == 0 && dropLon == 0 {
dropLat, dropLon = hub.Latitude, hub.Longitude
@@ -479,10 +484,25 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
return utils.Internal(c, "failed to update miler availability")
}
// The customer's "In transit" milestone. Recorded against THIS order, not
// the booking, because the other parcels from the same visit may still be
// in the rider's hands.
notifyInTransit, err := recordCxConsignmentStage(tx, consignment.Consignmentid,
constants.ConsignmentInwardedAtHub, constants.CxActorMiler, &milerUserID,
"POST /miler/consignments/{id}/inward-at-hub")
if err != nil {
tx.Rollback()
utils.Error("MilerInwardConsignmentAtHub: could not record in_transit",
"consignment_id", consignment.Consignmentid, "error", err)
return utils.Internal(c, "failed to record the handover")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to record the handover")
}
notifyInTransit()
// Best-effort, on an already-bound subject — a dropped event must never fail
// a handover the rider has physically completed.
if db.Js != nil {

View File

@@ -254,6 +254,28 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
}
}
// A customer-app pickup fans out into one consignment per destination, and
// each of those is a SEPARATE delivery the rider has to make. Without this
// the queue showed one row per booking keyed on pickupbookings.consignmentid
// — which names only the first order — so on a three-destination pickup two
// parcels would exist in the rider's bag with no stop, no deliver button and
// no way to close them.
//
// Bookings with no destination rows (every console/express booking, and
// everything written before the fan-out) are untouched: they still produce
// exactly one row, built from the booking's own columns.
destinationsByBooking := map[int][]models.BookingDestination{}
if len(bookingIDs) > 0 {
var destinations []models.BookingDestination
db.DB.Where("bookingid IN ?", bookingIDs).Order("seq ASC").Find(&destinations)
for _, d := range destinations {
destinationsByBooking[d.Bookingid] = append(destinationsByBooking[d.Bookingid], d)
if d.Consignmentid != nil {
consignmentIDs = append(consignmentIDs, *d.Consignmentid)
}
}
}
// step / road-optimized sequence lives on the active assignment row, written
// by the express route optimizer. Step 0 = not sequenced (single-stop or
// optimizer down), never a position — passed through verbatim.
@@ -287,41 +309,6 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
var customer models.AppCustomer
db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer)
// Cash-to-collect: prefer the consignment's COD once it exists, otherwise
// fall back to a pending Cash payment on the booking. Prepaid/UPI stays 0.
codAmount := 0.0
paymentMode := ""
if b.Consignmentid != nil {
if cn, ok := codByConsignment[*b.Consignmentid]; ok {
paymentMode = cn.Paymentmode
codAmount = cn.Codamount - cn.Codcollected
if codAmount < 0 {
codAmount = 0
}
}
}
if codAmount == 0 {
for _, p := range b.Payments {
if p.Paymentmode == constants.PaymentModeCash && p.Paymentstatus == constants.PaymentStatusPending {
codAmount = p.Amount
paymentMode = p.Paymentmode
break
}
if paymentMode == "" {
paymentMode = p.Paymentmode
}
}
}
// consignmentid/consignmentstatus so the app can call deliver, skip and
// start-delivery straight from the list without a per-order lookup.
var consignmentStatus string
if b.Consignmentid != nil {
if cn, ok := codByConsignment[*b.Consignmentid]; ok {
consignmentStatus = cn.Status
}
}
// Where this parcel is collected FROM, and what kind of place that is, so
// Home can title the stop correctly. Without pickup_source_type every
// logistics pickup was grouped under the rider's own base name and a
@@ -334,79 +321,145 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b,
customer.Firstname+" "+customer.Lastname)
// next_action / next_hub: the leg this parcel is on, rebuilt from server
// state on every poll. pickup-complete used to be the only place that ever
// said it, so a restart mid-leg left the app with nothing authoritative to
// read — consignment status alone cannot separate a hub-routed parcel from
// a freshly-collected hyperlocal one, since both can sit on Created.
nextAction := constants.NextActionPickup
var nextHub fiber.Map
if b.Status == constants.BookingCancelled {
nextAction = constants.NextActionNone
} else if b.Consignmentid != nil {
if cn, ok := codByConsignment[*b.Consignmentid]; ok {
nextAction = nextActionForConsignment(cn.Status)
nextHub = nextHubForConsignment(&cn)
}
}
stops := milerStopsForBooking(&b, destinationsByBooking[b.Bookingid])
row := fiber.Map{
"bookingid": b.Bookingid,
"bookingreference": b.Bookingno,
"status": b.Status,
"stoptype": milerStopType(b.Status),
"consignmentid": b.Consignmentid,
"consignmentstatus": consignmentStatus,
"next_action": nextAction,
"next_hub": nextHub,
"pickup_source_type": sourceType,
"sourceid": sourceID,
"pickuplocationid": sourceID,
"pickup_source_name": sourceName,
"pickupaddress": sourceAddress,
"pickuplatitude": b.Pickuplatitude,
"pickuplongitude": b.Pickuplongitude,
"deliveryaddress": b.Deliveryaddress,
"deliverylatitude": b.Deliverylatitude,
"deliverylongitude": b.Deliverylongitude,
"customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
"customerphone": customer.Phone,
// Arrival fact: the rider app derives its "Arrived" rung from
// pickup-scheduled + a non-null reachedat, so this survives an app
// restart without a separate booking status. Null until the rider hits
// the reached endpoint. arrivallatitude/longitude are the GPS captured
// at that moment (null if the app sent none).
"reachedat": b.Arrivedat,
"arrivallatitude": b.Arrivallatitude,
"arrivallongitude": b.Arrivallongitude,
"parcels": b.Parcels,
"serviceoptions": b.ServiceOptions,
"codamount": codAmount,
"paymentmode": paymentMode,
"createdat": b.Createdat,
// Route sequencing — 0/empty when the stop was never sequenced.
// sequencedat is the authoritative-order signal: non-null means the
// console/optimizer fixed this stop's position and the app must follow
// step exactly; null means no route was assigned and the app is free to
// fall back to its own nearest-first ordering.
"step": 0,
"cumulativekms": 0.0,
"etaminutes": 0,
"cumulativeeta": 0,
"sequencedat": nil,
for _, stop := range stops {
// Cash-to-collect: prefer the consignment's COD once it exists, otherwise
// fall back to a pending Cash payment on the booking. Prepaid/UPI stays 0.
codAmount := 0.0
paymentMode := ""
var consignmentStatus string
// next_action / next_hub: the leg this parcel is on, rebuilt from server
// state on every poll. pickup-complete used to be the only place that ever
// said it, so a restart mid-leg left the app with nothing authoritative to
// read — consignment status alone cannot separate a hub-routed parcel from
// a freshly-collected hyperlocal one, since both can sit on Created.
nextAction := constants.NextActionPickup
var nextHub fiber.Map
if stop.consignmentID != nil {
if cn, ok := codByConsignment[*stop.consignmentID]; ok {
consignmentStatus = cn.Status
paymentMode = cn.Paymentmode
codAmount = cn.Codamount - cn.Codcollected
if codAmount < 0 {
codAmount = 0
}
nextAction = nextActionForConsignment(cn.Status)
nextHub = nextHubForConsignment(&cn)
} else {
// The consignment row did not load. Fall back to what this
// destination asked for rather than to zero — a rider shown
// ₹0 collects nothing, and the customer's money is the one
// thing that must not silently vanish from a stop.
codAmount = stop.codAmount
}
} else if stop.codAmount > 0 {
// Not yet collected: the collection is what the customer
// requested for this door.
codAmount = stop.codAmount
}
if b.Status == constants.BookingCancelled {
nextAction = constants.NextActionNone
}
// The pickup fee is charged once for the visit, so it is only offered
// against the first stop — asking a rider to collect it again at the
// second parcel would double-charge the customer.
if codAmount == 0 && stop.seq == 0 {
for _, p := range b.Payments {
if p.Paymentmode == constants.PaymentModeCash && p.Paymentstatus == constants.PaymentStatusPending {
codAmount = p.Amount
paymentMode = p.Paymentmode
break
}
if paymentMode == "" {
paymentMode = p.Paymentmode
}
}
}
row := fiber.Map{
"bookingid": b.Bookingid,
"bookingreference": b.Bookingno,
"status": b.Status,
"stoptype": milerStopType(b.Status),
// consignmentid/consignmentstatus so the app can call deliver, skip and
// start-delivery straight from the list without a per-order lookup.
"consignmentid": stop.consignmentID,
"consignmentstatus": consignmentStatus,
"trackingno": stop.trackingNo,
// Which of the booking's destinations this stop is, and how many
// there are in total, so the app can say "Stop 2 of 3" instead of
// showing three identical-looking rows. Always 0 and 1 for a
// single-destination or console booking.
"destinationseq": stop.seq,
"destinationcount": len(stops),
"next_action": nextAction,
"next_hub": nextHub,
"pickup_source_type": sourceType,
"sourceid": sourceID,
"pickuplocationid": sourceID,
"pickup_source_name": sourceName,
"pickupaddress": sourceAddress,
"pickuplatitude": b.Pickuplatitude,
"pickuplongitude": b.Pickuplongitude,
"deliveryaddress": stop.deliveryAddress,
"deliverylatitude": stop.deliveryLat,
"deliverylongitude": stop.deliveryLng,
"recipientname": stop.recipientName,
"recipientphone": stop.recipientPhone,
"customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
"customerphone": customer.Phone,
// Arrival fact: the rider app derives its "Arrived" rung from
// pickup-scheduled + a non-null reachedat, so this survives an app
// restart without a separate booking status. Null until the rider hits
// the reached endpoint. arrivallatitude/longitude are the GPS captured
// at that moment (null if the app sent none).
"reachedat": b.Arrivedat,
"arrivallatitude": b.Arrivallatitude,
"arrivallongitude": b.Arrivallongitude,
"parcels": stop.parcels,
"serviceoptions": b.ServiceOptions,
"codamount": codAmount,
// collectionamt is the same number under the name the rider app
// reads. Added rather than renamed: `codamount` is what this
// endpoint has always emitted and the deployed build parses it,
// so removing it would break every stop on every existing
// device. Both are written from one variable, so they cannot
// drift apart.
"collectionamt": codAmount,
"paymentmode": paymentMode,
"createdat": b.Createdat,
// Route sequencing — 0/empty when the stop was never sequenced.
// sequencedat is the authoritative-order signal: non-null means the
// console/optimizer fixed this stop's position and the app must follow
// step exactly; null means no route was assigned and the app is free to
// fall back to its own nearest-first ordering.
"step": 0,
"cumulativekms": 0.0,
"etaminutes": 0,
"cumulativeeta": 0,
"sequencedat": nil,
}
if a, ok := seqByBooking[b.Bookingid]; ok {
row["step"] = a.Step
row["cumulativekms"] = a.Cumulativekms
row["etaminutes"] = a.Etaminutes
row["cumulativeeta"] = a.Cumulativeeta
row["sequencedat"] = a.Sequencedat
}
response = append(response, row)
}
if a, ok := seqByBooking[b.Bookingid]; ok {
row["step"] = a.Step
row["cumulativekms"] = a.Cumulativekms
row["etaminutes"] = a.Etaminutes
row["cumulativeeta"] = a.Cumulativeeta
row["sequencedat"] = a.Sequencedat
}
response = append(response, row)
}
// Sequenced stops ascend by step; unsequenced (step 0) fall to the end while
// keeping the newest-first order the app already relied on.
// keeping the newest-first order the app already relied on. Stops from one
// booking share its step, so they stay adjacent and in destination order.
sort.SliceStable(response, func(i, j int) bool {
si, sj := response[i]["step"].(int), response[j]["step"].(int)
switch {
@@ -424,6 +477,121 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
return utils.List(c, response, int64(len(response)))
}
// milerStop is one thing the rider actually has to do with one parcel — a
// pickup before collection, a delivery leg after it.
type milerStop struct {
seq int
consignmentID *int
trackingNo string
deliveryAddress string
deliveryLat float64
deliveryLng float64
recipientName string
recipientPhone string
// codAmount is the collection the CUSTOMER asked for at THIS door, read off
// the destination row. The consignment's own figure wins at delivery time
// because it also knows what has already been collected — but this is what
// makes the per-stop split provable without a database, and it is the value
// used if the consignment row could not be loaded. Money must never fall
// back to another destination's number.
codAmount float64
parcels []models.BookingParcel
}
// milerStopsForBooking decides how many stops a booking is worth to the rider.
//
// Before the parcels are collected it is always ONE stop: the rider makes a
// single visit to the customer's door, whatever it is carrying away. After
// collection a customer-app pickup becomes one stop per destination, because
// each parcel now has its own journey, its own tracking number and its own
// deliver/skip action.
//
// A booking with no destination rows — every console/express booking, and
// everything written before the fan-out — is always one stop built from the
// booking's own columns, exactly as before.
func milerStopsForBooking(b *models.PickupBooking, destinations []models.BookingDestination) []milerStop {
single := []milerStop{{
seq: 0,
consignmentID: b.Consignmentid,
deliveryAddress: b.Deliveryaddress,
deliveryLat: b.Deliverylatitude,
deliveryLng: b.Deliverylongitude,
parcels: b.Parcels,
}}
// A booking with exactly one destination row still carries its COD there.
if len(destinations) == 1 {
single[0].codAmount = destinations[0].Codamount
}
if len(destinations) == 0 {
return single
}
// Not collected yet: one visit, one stop. The destinations are still only an
// intention, and showing three rows for a pickup that has not happened would
// have the rider drive to the same door three times.
collected := false
for _, d := range destinations {
if d.Consignmentid != nil {
collected = true
break
}
}
if !collected {
// Destination 0's address is already mirrored onto the booking, so the
// single pre-pickup stop is correct as built above.
return single
}
parcelsByDestination := map[int][]models.BookingParcel{}
for _, p := range b.Parcels {
if p.Bookingdestinationid != nil {
parcelsByDestination[*p.Bookingdestinationid] = append(parcelsByDestination[*p.Bookingdestinationid], p)
}
}
// Stop order is `seq`, guaranteed here rather than inherited from whatever
// ORDER BY the caller happened to use. The queue query does sort by seq
// today, so this changes nothing — but the ordering IS the route the rider
// drives, and leaving it as an unstated precondition means the next caller,
// or an edited query, silently reorders someone's afternoon. Sorted on a
// copy so the caller's slice is never mutated underneath it.
ordered := make([]models.BookingDestination, len(destinations))
copy(ordered, destinations)
sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Seq < ordered[j].Seq })
destinations = ordered
stops := make([]milerStop, 0, len(destinations))
for _, d := range destinations {
lat, lng := 0.0, 0.0
if d.Pinlatitude != nil && d.Pinlongitude != nil {
lat, lng = *d.Pinlatitude, *d.Pinlongitude
}
stops = append(stops, milerStop{
seq: d.Seq,
consignmentID: d.Consignmentid,
trackingNo: d.Trackingno,
deliveryAddress: joinNonEmpty(", ", d.Building, d.Street, d.Landmark, d.Districtname, d.Statename),
deliveryLat: lat,
deliveryLng: lng,
recipientName: d.Recipientname,
recipientPhone: d.Recipientphone,
codAmount: d.Codamount,
parcels: parcelsByDestination[d.Bookingdestinationid],
})
}
// Destination 0's coordinates are mirrored onto the booking and may have
// been corrected there by the rider at the door, so prefer those when the
// destination itself never got a pin.
if stops[0].deliveryLat == 0 && stops[0].deliveryLng == 0 {
stops[0].deliveryLat = b.Deliverylatitude
stops[0].deliveryLng = b.Deliverylongitude
}
return stops
}
// milerConsignmentForRider loads a consignment and confirms it belongs to this
// rider — either it is linked to a booking currently assigned to them, or they
// are the one who collected it (Createdby). Returns a stable error code on
@@ -437,6 +605,19 @@ func milerConsignmentForRider(milerUserID, consignmentID int) (*models.Consignme
db.DB.Model(&models.PickupBooking{}).
Where("consignmentid = ? AND assignedmileruserid = ?", consignmentID, milerUserID).
Count(&count)
// pickupbookings.consignmentid names only the FIRST order of a
// multi-destination pickup, so the count above misses orders 2..N entirely.
// The destination table is what actually knows which booking a consignment
// belongs to.
if count == 0 {
db.DB.Model(&models.BookingDestination{}).
Joins("JOIN pickupbookings ON pickupbookings.bookingid = bookingdestinations.bookingid").
Where("bookingdestinations.consignmentid = ? AND pickupbookings.assignedmileruserid = ?",
consignmentID, milerUserID).
Count(&count)
}
if count == 0 && consignment.Createdby != milerUserID {
return nil, constants.ErrConsignmentNotAssigned, fmt.Errorf("not this rider's consignment")
}
@@ -544,14 +725,30 @@ func MilerStartDelivery(c *fiber.Ctx) error {
return utils.Internal(c, "failed to update miler availability")
}
notifyOutForDelivery, err := recordCxConsignmentStage(tx, consignment.Consignmentid,
constants.ConsignmentOutForDelivery, constants.CxActorMiler, &milerUserID,
"POST /miler/consignments/{id}/start-delivery")
if err != nil {
tx.Rollback()
utils.Error("MilerStartDelivery: could not record out_for_delivery",
"consignment_id", consignment.Consignmentid, "error", err)
return utils.Internal(c, "failed to start delivery")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to start delivery")
}
notifyOutForDelivery()
// Tell the customer it's on the way, and hand the receiver their OTP (only the
// receiver — the rider is told it at the door).
var booking models.PickupBooking
if db.DB.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil {
//
// Resolved through bookingdestinations: pickupbookings.consignmentid names
// only the first order of a multi-destination pickup, so joining on it meant
// no customer was ever told about orders 2..N going out for delivery.
if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil {
booking := *bookingPtr
var customer models.AppCustomer
if db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error == nil && customer.Devicetoken != "" {
body := "Your parcel is out for delivery."
@@ -598,16 +795,32 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
return utils.BadRequest(c, "deliveredtoname is required")
}
var consignment models.Consignment
if err := db.DB.First(&consignment, consignmentID).Error; err != nil {
return utils.NotFound(c, "consignment not found")
}
var booking models.PickupBooking
if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID).
First(&booking).Error; err != nil {
// Ownership and the booking behind the parcel, via the one helper that
// understands a multi-destination pickup. This used to be a direct
// `WHERE consignmentid = ? AND assignedmileruserid = ?` on pickupbookings —
// which names only the FIRST order of a pickup, so a rider delivering the
// second parcel of a three-destination visit was told "assigned consignment
// not found" and could not close the delivery at all.
// Both failures answer 404 with the exact messages this endpoint has always
// returned. milerConsignmentForRider distinguishes "no such consignment"
// from "not yours" and start-delivery reports that as a 403, but the
// deployed rider app was built against a 404 here and changing a live
// endpoint's status code is not this work's business. Only the LOOKUP is
// fixed; the contract is byte-identical.
consignmentPtr, code, err := milerConsignmentForRider(milerUserID, consignmentID)
if err != nil {
if code == constants.ErrConsignmentNotFound {
return utils.NotFound(c, "consignment not found")
}
return utils.NotFound(c, "assigned consignment not found")
}
consignment := *consignmentPtr
_, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid)
if !ok || bookingPtr == nil {
return utils.NotFound(c, "assigned consignment not found")
}
booking := *bookingPtr
if consignment.Status != constants.ConsignmentOutForDelivery {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
@@ -702,10 +915,27 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
return utils.Internal(c, "failed to close assignment")
}
// The customer's Delivered milestone, against THIS order. The booking only
// reads as completed once every destination has landed — cxstage rolls the
// booking up from the least-advanced order, so a two-parcel pickup with one
// still in transit stays "In transit" rather than telling the customer
// everything arrived.
notifyDelivered, err := recordCxConsignmentStage(tx, consignment.Consignmentid,
constants.ConsignmentDelivered, constants.CxActorMiler, &milerUserID,
"POST /miler/consignments/{id}/deliver")
if err != nil {
tx.Rollback()
utils.Error("MilerDeliverConsignment: could not record delivered",
"consignment_id", consignment.Consignmentid, "error", err)
return utils.Internal(c, "failed to confirm delivery")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to confirm delivery")
}
notifyDelivered()
if db.Js != nil {
payload := map[string]interface{}{
"bookingid": booking.Bookingid,

View File

@@ -2,7 +2,6 @@ package controllers
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"math"
@@ -16,6 +15,7 @@ import (
"doormile/db"
"doormile/dto"
"doormile/internal/assignment"
"doormile/internal/cxstage"
"doormile/internal/legs"
"doormile/internal/notify"
"doormile/internal/routing"
@@ -26,12 +26,6 @@ import (
"github.com/redis/go-redis/v9"
)
func generateTrackingNo() string {
b := make([]byte, 4)
rand.Read(b)
return fmt.Sprintf("DM-TRK-%X-%d", b, time.Now().Unix()%100000)
}
func LoginMiler(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.MilerLoginRequest)
@@ -529,10 +523,40 @@ func AcceptMilerAssignment(c *fiber.Ctx) error {
return utils.Internal(c, "failed to update miler availability")
}
// Accepting is the customer's "on the way": the rider has seen the job and
// is heading over. `assigned` was already recorded when the assignment was
// created (assignMilerTx / commitAssignment) and is re-asserted here only
// as a safety net for a booking assigned before this surface existed —
// Record dedupes, so it appends nothing when it is already on the timeline.
//
// Deriving on_the_way from the GPS stream instead would mean re-deriving it
// on every ping: thousands of writes to learn something the accept already
// said.
stageAt := utils.DBNow()
for _, stage := range []string{constants.CxStageAssigned, constants.CxStageOnTheWay} {
if err := cxstage.Record(tx, cxstage.Event{
BookingID: assignment.Bookingid,
Stage: stage,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/assignments/{id}/accept",
At: stageAt,
}); err != nil {
tx.Rollback()
utils.Error("AcceptMilerAssignment: could not record customer stage",
"booking_id", assignment.Bookingid, "stage", stage, "error", err)
return utils.Internal(c, "failed to accept assignment")
}
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to commit assignment acceptance")
}
// No push here: `assigned` was already announced when the assignment was
// created, and on_the_way deliberately rolls up on the timeline. The
// existing "Miler Accepted" notification below is the one the customer gets.
// Accepting a stop moves it into the active set the optimizer orders over, so
// re-sequence the rider off the request path. No-op below two active stops.
routing.SequenceMilerStopsAsync(milerUserID)
@@ -687,6 +711,17 @@ func MilerCancelAssignment(c *fiber.Ctx) error {
return utils.Internal(c, "failed to update miler availability")
}
// The pickup is NOT cancelled — it goes back into the pool. The customer's
// stage has to walk back with it, or they keep seeing "Miler assigned" and
// a rider card for someone who is no longer coming.
if err := cxstage.Release(tx, booking.Bookingid, req.Reason,
constants.CxActorMiler, &milerUserID,
"POST /miler/bookings/{id}/cancel"); err != nil {
tx.Rollback()
utils.Error("MilerCancelAssignment: could not release the customer stage", "booking_id", booking.Bookingid, "error", err)
return utils.Internal(c, "failed to commit cancellation")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to commit cancellation")
}
@@ -751,9 +786,29 @@ func BookingReachedCustomer(c *fiber.Ctx) error {
return utils.Internal(c, "failed to update miler availability")
}
// Arrival is the LAST cancellable stage on the customer side, so it has to
// be recorded transactionally with the arrival fact itself. A gap between
// the two is a window in which the customer can still cancel a pickup the
// rider is already standing at.
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
Stage: constants.CxStageArrived,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/bookings/{id}/reached",
At: utils.DBNow(),
}); err != nil {
tx.Rollback()
utils.Error("BookingReachedCustomer: could not record arrived stage", "booking_id", booking.Bookingid, "error", err)
return utils.Internal(c, "failed to record arrival")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to confirm arrival")
}
go cxstage.Notify(booking.Bookingid, nil, constants.CxStageArrived)
return utils.OK(c, fiber.Map{
"bookingid": booking.Bookingid,
"status": booking.Status,
@@ -859,12 +914,19 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
return utils.NotFound(c, "assigned booking not found")
}
// Photos are the evidence half of the receipt. Weight without a photograph
// is a number the customer has no way to check, and this is the only point
// in the flow where anyone is standing next to the parcel. Sent as storage
// keys from the presigned upload (POST /miler/uploads/sign), not as raw
// URLs: the customer is served a short-lived signed link derived from the
// key, never a permanent one.
type ParcelUpdate struct {
ParcelID int `json:"parcel_id"`
Weight float64 `json:"weight"`
Length float64 `json:"length"`
Width float64 `json:"width"`
Height float64 `json:"height"`
ParcelID int `json:"parcel_id"`
Weight float64 `json:"weight"`
Length float64 `json:"length"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Photos []string `json:"photos"`
}
var req struct {
Parcels []ParcelUpdate `json:"parcels"`
@@ -887,8 +949,13 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
parcelMap[parcels[i].Bookingparcelid] = &parcels[i]
}
now := time.Now()
now := utils.DBNow()
var totalChargeable float64
// Chargeable weight per destination, so each order settles on the weight of
// its own parcels rather than on the whole visit's total.
perDestination := map[int]float64{}
tx := db.DB.Begin()
for _, upd := range req.Parcels {
p, ok := parcelMap[upd.ParcelID]
@@ -900,10 +967,51 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
p.Width = upd.Width
p.Height = upd.Height
p.Updatedat = now
db.DB.Save(p)
if err := tx.Save(p).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save parcel measurements")
}
volumetric := calculateVolumetricWeight(upd.Length, upd.Width, upd.Height)
totalChargeable += math.Max(upd.Weight, volumetric)
chargeable := math.Max(upd.Weight, volumetric)
totalChargeable += chargeable
if p.Bookingdestinationid != nil {
perDestination[*p.Bookingdestinationid] += chargeable
}
for _, key := range upd.Photos {
key = strings.TrimSpace(key)
if key == "" {
continue
}
photo := models.BookingParcelPhoto{
Bookingid: bookingID,
Bookingdestinationid: p.Bookingdestinationid,
Objectkey: key,
Capturedbyuserid: &milerUserID,
Capturedat: now,
}
if err := tx.Create(&photo).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save parcel photo")
}
}
}
// The verification block the customer's receipt reads. Written here, at the
// door, where the measurement was actually taken — pickup-complete restates
// it from the same parcel rows when the price settles, so the two cannot
// disagree.
for destinationID, weight := range perDestination {
if err := cxRecordVerification(tx, destinationID, weight, milerUserID, now); err != nil {
tx.Rollback()
utils.Error("BookingParcelConfirm: could not record verification", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the parcel weight")
}
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to confirm parcels")
}
return utils.OK(c, fiber.Map{
@@ -1030,30 +1138,17 @@ func BookingPickupComplete(c *fiber.Ctx) error {
}
}
var parcels []models.BookingParcel
tx.Where("bookingid = ?", bookingID).Find(&parcels)
var totalDead, totalChargeable, maxL, maxW, maxH float64
for _, p := range parcels {
vol := calculateVolumetricWeight(p.Length, p.Width, p.Height)
totalDead += p.Weight
totalChargeable += math.Max(p.Weight, vol)
if p.Length > maxL {
maxL = p.Length
}
if p.Width > maxW {
maxW = p.Width
}
if p.Height > maxH {
maxH = p.Height
}
// One visit, N orders. A customer-app booking fans out into one consignment
// per destination — each with its own tracking number and its own journey —
// while a console-created booking, which has no destination rows, produces
// the single consignment it always did. cxPickupLegs is what decides which
// of those this is; nothing below needs to know.
legs, err := cxPickupLegs(tx, &booking)
if err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not resolve pickup legs", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to read the parcels on this booking")
}
if len(parcels) == 0 {
totalDead = 0.5
totalChargeable = 0.5
}
trackingNo := generateTrackingNo()
// The base this parcel belongs to. Backend decides — the app is told where to
// go and never picks a base itself. resolveHandoverHub prefers the base the
@@ -1069,36 +1164,6 @@ func BookingPickupComplete(c *fiber.Ctx) error {
utils.Warn("BookingPickupComplete: no base could be resolved for this pickup", "miler_user_id", milerUserID, "booking_id", bookingID)
}
// A hub-routed parcel: with the hub-handover flow ON it stops at Created —
// collected, in the rider's hands, on its way to a base — and only reaches
// Inwarded_at_Hub when the handover is actually recorded. With it OFF
// (default, and what the current app expects) it is marked Inwarded_at_Hub
// here, which is not where the parcel physically is but is what the current
// app and the console's inbound views read.
consignmentStatus := constants.ConsignmentInwardedAtHub
if hubHandoverEnabled() {
consignmentStatus = constants.ConsignmentCreated
}
// Hyperlocal shortcut: pickup and delivery in the same postal area mean no
// hub-to-hub tripsheet leg is needed, so the same miler carries it to the
// final mile instead of parking it at the hub.
//
// With the collected-state flow ON it lands in Collected_By_Miler — collected
// but not yet out for delivery — and the rider taps start-delivery to move it
// to Out_for_Delivery, which lets the console tell "collected" from "actively
// delivering". With it OFF (default, and what the current app expects) it goes
// straight to Out_for_Delivery exactly as before.
if isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode,
booking.Pickuplatitude, booking.Pickuplongitude,
booking.Deliverylatitude, booking.Deliverylongitude) {
if collectedStateEnabled() {
consignmentStatus = constants.ConsignmentCollectedByMiler
} else {
consignmentStatus = constants.ConsignmentOutForDelivery
}
}
// The consignment's tenant is the booking's own tenant (set explicitly at
// CreateExpressBooking time), not the completing miler's tenantid claim — a
// miler can carry parcels for tenants other than their own, and using
@@ -1110,96 +1175,257 @@ func BookingPickupComplete(c *fiber.Ctx) error {
consignmentTenantID = *booking.Tenantid
}
// Carried over so the consignment stays traceable to the client site it was
// collected from — for a food client that's the kitchen, and "how many
// parcels went out of which kitchen" is unanswerable without it.
consignment := models.Consignment{
Trackingno: trackingNo,
Tenantid: consignmentTenantID,
Pickuplocationid: booking.Pickuplocationid,
Tenantlocationid: booking.Tenantlocationid,
Pickuplatitude: booking.Pickuplatitude,
Pickuplongitude: booking.Pickuplongitude,
Deliverylatitude: booking.Deliverylatitude,
Deliverylongitude: booking.Deliverylongitude,
Pickuppincode: booking.Pickuppincode,
Deliverypincode: booking.Deliverypincode,
Length: maxL,
Width: maxW,
Height: maxH,
Deadweight: totalDead,
Volumetricweight: totalChargeable - totalDead,
Chargeableweight: totalChargeable,
Paymentmode: "Prepaid",
Status: consignmentStatus,
Estimateddeliveryat: nil,
Createdby: milerUserID,
Originhubid: defaultHubID,
Currenthubid: defaultHubID,
}
// Under the compatibility flow the parcel is treated as received at the base
// the moment it is collected, so the received-at fact is stamped here too —
// otherwise every parcel inwarded this way would have a null handover time
// and base reconciliation would have nothing to compare against.
if consignmentStatus == constants.ConsignmentInwardedAtHub {
consignment.Inwardedat = &now
var tenant models.Tenant
tenantNeedsOTP := false
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil {
tenantNeedsOTP = tenant.Requiredeliveryotp
}
// Money collected at the door. Split across the legs below rather than
// stamped whole onto each one: a single payment covering a three-stop
// pickup must not appear three times in the books.
var payment models.BookingPayment
if tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil {
if payment.Paymentstatus == constants.PaymentStatusPaid {
consignment.Codcollected = payment.Amount
} else {
consignment.Codamount = payment.Amount
hasPayment := tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil
created := make([]models.Consignment, 0, len(legs))
trackingNos := make([]string, 0, len(legs))
riderMarkedBusy := false
assignmentStillOpen := false
for i, leg := range legs {
totalDead, totalChargeable, maxL, maxW, maxH := cxLegWeights(leg)
trackingNo := generateTrackingNo()
// A hub-routed parcel: with the hub-handover flow ON it stops at Created —
// collected, in the rider's hands, on its way to a base — and only reaches
// Inwarded_at_Hub when the handover is actually recorded. With it OFF
// (default, and what the current app expects) it is marked Inwarded_at_Hub
// here, which is not where the parcel physically is but is what the current
// app and the console's inbound views read.
consignmentStatus := constants.ConsignmentInwardedAtHub
if hubHandoverEnabled() {
consignmentStatus = constants.ConsignmentCreated
}
// Hyperlocal shortcut: pickup and delivery in the same postal area mean no
// hub-to-hub tripsheet leg is needed, so the same miler carries it to the
// final mile instead of parking it at the hub. Decided per leg, because on
// a multi-destination pickup one parcel can be going round the corner while
// another is going to another state.
//
// With the collected-state flow ON it lands in Collected_By_Miler — collected
// but not yet out for delivery — and the rider taps start-delivery to move it
// to Out_for_Delivery, which lets the console tell "collected" from "actively
// delivering". With it OFF (default, and what the current app expects) it goes
// straight to Out_for_Delivery exactly as before.
if isHyperlocalBooking(booking.Pickuppincode, leg.DeliveryPincode,
booking.Pickuplatitude, booking.Pickuplongitude,
leg.DeliveryLatitude, leg.DeliveryLongitude) {
if collectedStateEnabled() {
consignmentStatus = constants.ConsignmentCollectedByMiler
} else {
consignmentStatus = constants.ConsignmentOutForDelivery
}
}
// Carried over so the consignment stays traceable to the client site it was
// collected from — for a food client that's the kitchen, and "how many
// parcels went out of which kitchen" is unanswerable without it.
consignment := models.Consignment{
Trackingno: trackingNo,
Tenantid: consignmentTenantID,
Pickuplocationid: booking.Pickuplocationid,
Tenantlocationid: booking.Tenantlocationid,
Pickuplatitude: booking.Pickuplatitude,
Pickuplongitude: booking.Pickuplongitude,
Deliverylatitude: leg.DeliveryLatitude,
Deliverylongitude: leg.DeliveryLongitude,
Pickuppincode: booking.Pickuppincode,
Deliverypincode: leg.DeliveryPincode,
Length: maxL,
Width: maxW,
Height: maxH,
Deadweight: totalDead,
Volumetricweight: totalChargeable - totalDead,
Chargeableweight: totalChargeable,
Paymentmode: "Prepaid",
Status: consignmentStatus,
Estimateddeliveryat: cxEstimatedDelivery(leg, now),
Createdby: milerUserID,
Originhubid: defaultHubID,
Currenthubid: defaultHubID,
}
// COD the customer asked to have collected at THIS door, on their behalf.
// Doormile is the carrier, not the seller — this money is never Doormile's.
if leg.CodAmount > 0 {
consignment.Codamount = leg.CodAmount
consignment.Paymentmode = "COD"
}
}
// A parcel that goes straight out for delivery here (collected-state flow off)
// needs its receiver OTP before commit — same as before. When the flow is on,
// a hyperlocal parcel stops at Collected_By_Miler and its OTP is issued later
// at start-delivery instead, so this block simply doesn't fire. Only clients
// that ask for one get an OTP (Tenant.Requiredeliveryotp).
if consignmentStatus == constants.ConsignmentOutForDelivery {
var tenant models.Tenant
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil && tenant.Requiredeliveryotp {
// Under the compatibility flow the parcel is treated as received at the base
// the moment it is collected, so the received-at fact is stamped here too —
// otherwise every parcel inwarded this way would have a null handover time
// and base reconciliation would have nothing to compare against.
if consignmentStatus == constants.ConsignmentInwardedAtHub {
consignment.Inwardedat = &now
}
// The pickup fee the miler collected covers the whole visit, so it is
// recorded once — against the first order — rather than repeated on
// every leg.
if hasPayment && i == 0 {
if payment.Paymentstatus == constants.PaymentStatusPaid {
consignment.Codcollected = payment.Amount
} else {
consignment.Codamount += payment.Amount
consignment.Paymentmode = "COD"
}
}
// A parcel that goes straight out for delivery here (collected-state flow off)
// needs its receiver OTP before commit — same as before. When the flow is on,
// a hyperlocal parcel stops at Collected_By_Miler and its OTP is issued later
// at start-delivery instead, so this block simply doesn't fire. Only clients
// that ask for one get an OTP (Tenant.Requiredeliveryotp).
if consignmentStatus == constants.ConsignmentOutForDelivery && tenantNeedsOTP {
consignment.Deliveryotp = utils.GenerateNumericOTP(6)
}
if err := tx.Create(&consignment).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to convert booking to consignment")
}
if err := cxLinkLegToOrder(tx, leg, consignment.Consignmentid, trackingNo,
consignment.Estimateddeliveryat, now); err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not link destination to order", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to link the destination to its order")
}
// What the miler weighed at this door, kept where the customer's receipt
// reads it. Without this the verification block stays empty and the
// receipt loses the evidence behind the settled price.
if leg.Destination != nil {
if err := cxRecordVerification(tx, leg.Destination.Bookingdestinationid,
totalChargeable, milerUserID, now); err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not record verification", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the parcel weight")
}
}
history := models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Hubid: defaultHubID,
Userid: &milerUserID,
Eventstatus: consignmentStatus,
Remarks: "Package collected by miler and converted to consignment",
}
if err := tx.Create(&history).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record consignment history")
}
// Two separate questions, deliberately not merged.
//
// "Is the rider marked busy?" keeps the EXACT rule this endpoint has
// always applied — Created or Collected_By_Miler only. A hyperlocal
// parcel that goes straight to Out_for_Delivery has always left the
// rider Available here, even though the comment below says otherwise.
// That mismatch is pre-existing and is the default path today; changing
// it would alter live rider availability, which is not this work's
// business. Flagged in docs/customer-app-api.md, not silently fixed.
//
// "Is the assignment still open?" is the one that has to understand the
// fan-out: it closes only when every leg has been handed over at a base,
// which for a single-leg booking is identical to the previous behaviour.
if consignmentStatus == constants.ConsignmentCollectedByMiler ||
consignmentStatus == constants.ConsignmentCreated {
riderMarkedBusy = true
}
if consignmentStatus != constants.ConsignmentInwardedAtHub {
assignmentStillOpen = true
}
// The customer's per-order stage. in_transit is recorded here only on the
// compatibility flow, where the parcel really is treated as received at
// the base the instant it is collected; on the handover flow it waits for
// the rider to actually hand it over.
destinationID := cxDestinationIDFor(leg.Destination)
if err := cxstage.Record(tx, cxstage.Event{
BookingID: bookingID,
DestinationID: destinationID,
Stage: constants.CxStageOrderCreated,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/bookings/{id}/pickup-complete",
At: utils.DBNow(),
}); err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not record order_created", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the pickup")
}
// On the compatibility flow a parcel is already past order_created the
// instant it is collected — hub-routed ones are stamped Inwarded_at_Hub
// here, and hyperlocal ones go straight to Out_for_Delivery. Recording
// what the status actually says keeps the timeline honest: those
// transitions really did happen at this moment, and omitting them would
// leave a parcel showing "Package collected" while the rider is already
// carrying it to the door.
if implied, ok := cxStageForConsignmentStatus(consignmentStatus); ok {
if err := cxstage.Record(tx, cxstage.Event{
BookingID: bookingID,
DestinationID: destinationID,
Stage: implied,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/bookings/{id}/pickup-complete",
At: utils.DBNow(),
}); err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not record per-order stage", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the pickup")
}
}
created = append(created, consignment)
trackingNos = append(trackingNos, trackingNo)
}
if err := tx.Create(&consignment).Error; err != nil {
// Recorded before order_created in wall-clock terms — the parcels were in the
// rider's hands before the orders existed — but written after, because the
// weights the price settles on are only known once the legs are built.
if err := cxstage.Record(tx, cxstage.Event{
BookingID: bookingID,
Stage: constants.CxStagePickedUp,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/bookings/{id}/pickup-complete",
At: utils.DBNow(),
}); err != nil {
tx.Rollback()
return utils.Internal(c, "failed to convert booking to consignment")
utils.Error("BookingPickupComplete: could not record picked_up", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the pickup")
}
booking.Consignmentid = &consignment.Consignmentid
// pickupbookings.consignmentid names the FIRST order only. It is kept for
// the console and the legacy reads that still join on it; anything that
// needs the whole set goes through bookingdestinations.
first := created[0]
booking.Consignmentid = &first.Consignmentid
booking.Status = constants.BookingConvertedConsignment
if err := tx.Save(&booking).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to link booking to consignment")
}
history := models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Hubid: defaultHubID,
Userid: &milerUserID,
Eventstatus: consignmentStatus,
Remarks: "Package collected by miler and converted to consignment",
}
if err := tx.Create(&history).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record consignment history")
}
// A hyperlocal parcel is still in the rider's hands (they will deliver it), so
// they stay Picked_Up and out of the assignment pool until they finish. A
// hub-routed parcel was dropped at the hub, so the rider frees up.
// Unchanged from before the fan-out: a parcel still on its way to a base
// keeps the rider marked busy; anything else frees them up.
postPickupAvailability := constants.MilerAvailable
if consignmentStatus == constants.ConsignmentCollectedByMiler ||
consignmentStatus == constants.ConsignmentCreated {
// Created here means hub-routed and still in the rider's hands: they are
// carrying it to a base, so they are not free yet.
if riderMarkedBusy {
postPickupAvailability = constants.MilerPickedUp
}
@@ -1208,7 +1434,7 @@ func BookingPickupComplete(c *fiber.Ctx) error {
// on the compatibility flow and the rider could not go off duty — MilerEndDuty
// refuses while any assignment is still Assigned/Accepted. On the handover
// flow the assignment stays open on purpose and closes at inward-at-hub.
if consignmentStatus == constants.ConsignmentInwardedAtHub {
if !assignmentStillOpen {
if err := tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
bookingID, milerUserID,
@@ -1231,25 +1457,16 @@ func BookingPickupComplete(c *fiber.Ctx) error {
return utils.Internal(c, "failed to complete pickup")
}
// One notification for the milestone, not one per order: three tracking
// numbers arriving as three buzzes for a single visit is noise, and
// order_created deliberately rolls up on the timeline.
go cxstage.Notify(bookingID, nil, constants.CxStagePickedUp)
// If an OTP was issued here (parcel went straight out for delivery), it goes to
// the receiver in this notification — the rider is told it at the door. When
// the collected-state flow is on, no OTP exists yet and the notification is
// just "collected"; the OTP rides the start-delivery notification instead.
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo)
payload := map[string]string{
"booking_id": strconv.Itoa(bookingID),
"tracking_no": trackingNo,
}
if consignment.Deliveryotp != "" {
body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignment.Deliveryotp)
payload["delivery_otp"] = consignment.Deliveryotp
}
if notifyErr := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); notifyErr != nil {
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", bookingID, "error", notifyErr)
}
}
notifyCustomerOnPickup(&booking, created, trackingNos)
// next_action says what the rider does next; next_hub says where. An
// inward_at_hub with no base named leaves a rider holding a parcel with
@@ -1259,22 +1476,74 @@ func BookingPickupComplete(c *fiber.Ctx) error {
//
// consignment_id is always present: the delivery leg is keyed on it, and
// without it the app cannot name the parcel it is about to act on.
//
// The single-consignment fields still describe the FIRST order, unchanged,
// so the deployed rider app keeps working exactly as before. `consignments`
// is additive and carries the full set for a build that can show them.
resp := fiber.Map{
"tracking_no": trackingNo,
"consignment_id": consignment.Consignmentid,
"consignmentstatus": consignment.Status,
"status": consignment.Status,
"tracking_no": trackingNos[0],
"consignment_id": first.Consignmentid,
"consignmentstatus": first.Status,
"status": first.Status,
"booking_no": booking.Bookingno,
"booking_status": booking.Status,
"next_action": nextActionForConsignment(consignment.Status),
"next_action": nextActionForConsignment(first.Status),
"consignments": renderPickupOrders(created, trackingNos),
}
if consignment.Status == constants.ConsignmentCreated ||
consignment.Status == constants.ConsignmentInwardedAtHub {
if first.Status == constants.ConsignmentCreated ||
first.Status == constants.ConsignmentInwardedAtHub {
resp["next_hub"] = renderBase(handoverHub)
}
return utils.OK(c, resp)
}
// renderPickupOrders lists every order a pickup produced, so a rider carrying
// three parcels from one visit can be shown three stops rather than one.
func renderPickupOrders(consignments []models.Consignment, trackingNos []string) []fiber.Map {
out := make([]fiber.Map, 0, len(consignments))
for i := range consignments {
cn := consignments[i]
out = append(out, fiber.Map{
"consignment_id": cn.Consignmentid,
"tracking_no": trackingNos[i],
"consignmentstatus": cn.Status,
"next_action": nextActionForConsignment(cn.Status),
"delivery_pincode": cn.Deliverypincode,
})
}
return out
}
// notifyCustomerOnPickup tells the customer their parcels were collected, and
// hands the receiver any delivery OTP that was issued. Best-effort: a push that
// fails must never fail a pickup that already committed.
func notifyCustomerOnPickup(booking *models.PickupBooking, consignments []models.Consignment, trackingNos []string) {
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err != nil {
return
}
if customer.Devicetoken == "" {
return
}
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNos[0])
if len(trackingNos) > 1 {
body = fmt.Sprintf("%d parcels picked up — first tracking no: %s", len(trackingNos), trackingNos[0])
}
payload := map[string]string{
"booking_id": strconv.Itoa(booking.Bookingid),
"tracking_no": trackingNos[0],
"reference": booking.Bookingno,
}
if len(consignments) > 0 && consignments[0].Deliveryotp != "" {
body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignments[0].Deliveryotp)
payload["delivery_otp"] = consignments[0].Deliveryotp
}
if err := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); err != nil {
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", booking.Bookingid, "error", err)
}
}
func BookingVehicleRequiredEscalate(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
bookingID, err := strconv.Atoi(c.Params("bookingid"))

View File

@@ -1,128 +0,0 @@
package controllers
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"time"
"doormile/config"
"doormile/db"
"doormile/dto"
"doormile/internal/mail"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
const (
otpTTL = 5 * time.Minute
otpMaxAttempts = 5
// otpVerifiedTTL is how long a successful email verification stays usable as
// proof of identity for a follow-up action such as a PIN reset. Long enough
// to type a new PIN, short enough that a stale verification can't be
// redeemed later.
otpVerifiedTTL = 10 * time.Minute
)
func generateOtpCode() string {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "000000"
}
return fmt.Sprintf("%06d", n.Int64())
}
func otpKey(email string) string { return fmt.Sprintf("otp:email:%s", email) }
func otpAttemptsKey(email string) string { return fmt.Sprintf("otp:email:%s:attempts", email) }
// otpVerifiedKey marks an email as recently proven. Verification previously
// left no trace at all, so nothing downstream could require it — which is why
// ResetCustomerPin was able to overwrite a PIN on nothing but a phone number.
func otpVerifiedKey(email string) string { return fmt.Sprintf("otp:email:%s:verified", email) }
// ConsumeEmailVerification reports whether the email was verified recently, and
// clears the marker so a single verification can authorise exactly one action.
func ConsumeEmailVerification(email string) bool {
if db.Rdb == nil || email == "" {
return false
}
ctx := context.Background()
n, err := db.Rdb.Del(ctx, otpVerifiedKey(email)).Result()
return err == nil && n > 0
}
func SendCustomerEmailOtp(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.SendEmailOtpRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Email == "" {
return utils.BadRequest(c, "email is required")
}
if db.Rdb == nil {
return utils.Internal(c, "verification service unavailable")
}
code := generateOtpCode()
ctx := context.Background()
if err := db.Rdb.Set(ctx, otpKey(req.Email), code, otpTTL).Err(); err != nil {
return utils.Internal(c, "failed to generate verification code")
}
db.Rdb.Del(ctx, otpAttemptsKey(req.Email))
if err := mail.SendOTPEmail(cfg, req.Email, code); err != nil {
utils.Warn("failed to send OTP email", "email", req.Email, "error", err)
return utils.Internal(c, "failed to send verification email")
}
return utils.Message(c, "verification code sent")
}
}
func VerifyCustomerEmailOtp() fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.VerifyEmailOtpRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Email == "" || req.Otp == "" {
return utils.BadRequest(c, "email and otp are required")
}
if db.Rdb == nil {
return utils.Internal(c, "verification service unavailable")
}
ctx := context.Background()
key := otpKey(req.Email)
stored, err := db.Rdb.Get(ctx, key).Result()
if err == redis.Nil {
return utils.BadRequest(c, "verification code expired or not found, please resend")
} else if err != nil {
return utils.Internal(c, "failed to verify code")
}
if stored != req.Otp {
attemptsKey := otpAttemptsKey(req.Email)
attempts, _ := db.Rdb.Incr(ctx, attemptsKey).Result()
db.Rdb.Expire(ctx, attemptsKey, otpTTL)
if attempts >= otpMaxAttempts {
db.Rdb.Del(ctx, key, attemptsKey)
return utils.BadRequest(c, "too many incorrect attempts, please request a new code")
}
return utils.Unauthorized(c, "incorrect verification code")
}
db.Rdb.Del(ctx, key, otpAttemptsKey(req.Email))
// Recorded so a follow-up PIN reset can prove this email was verified.
db.Rdb.Set(ctx, otpVerifiedKey(req.Email), "1", otpVerifiedTTL)
return utils.Message(c, "email verified successfully")
}
}