backend requirements onthe xustomer app
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
614
controllers/cxAuthController.go
Normal file
614
controllers/cxAuthController.go
Normal 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)
|
||||
}
|
||||
895
controllers/cxBookingController.go
Normal file
895
controllers/cxBookingController.go
Normal 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)
|
||||
}
|
||||
}
|
||||
760
controllers/cxBookingView.go
Normal file
760
controllers/cxBookingView.go
Normal 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
|
||||
}
|
||||
563
controllers/cxCatalogueController.go
Normal file
563
controllers/cxCatalogueController.go
Normal 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
|
||||
}
|
||||
100
controllers/cxConsignmentHooks.go
Normal file
100
controllers/cxConsignmentHooks.go
Normal 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)
|
||||
}
|
||||
856
controllers/cxCustomerApp_test.go
Normal file
856
controllers/cxCustomerApp_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
85
controllers/cxDeviceController.go
Normal file
85
controllers/cxDeviceController.go
Normal 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})
|
||||
}
|
||||
266
controllers/cxFareController.go
Normal file
266
controllers/cxFareController.go
Normal 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
713
controllers/cxHttp_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
181
controllers/cxIdentifierScramble.go
Normal file
181
controllers/cxIdentifierScramble.go
Normal 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
|
||||
}
|
||||
281
controllers/cxIdentifierScramble_test.go
Normal file
281
controllers/cxIdentifierScramble_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
controllers/cxIdentifiers.go
Normal file
95
controllers/cxIdentifiers.go
Normal 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))
|
||||
}
|
||||
160
controllers/cxOpsController.go
Normal file
160
controllers/cxOpsController.go
Normal 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)
|
||||
}
|
||||
251
controllers/cxPickupFanout.go
Normal file
251
controllers/cxPickupFanout.go
Normal 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
|
||||
}
|
||||
350
controllers/cxPlacesController.go
Normal file
350
controllers/cxPlacesController.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user