Files
doormile_backend/controllers/customerController.go
Suriya c8a9b5d797 fix: customer PIN-reset takeover, booking-quote and consignment-log IDORs
- POST /customer/reset-pin was unauthenticated and overwrote a customer's PIN
  given only their phone number — which is the login identifier, not a secret —
  so reset-pin followed by verify-pin took over any customer account. Exactly
  the miler flaw fixed in fd7cf3e, on the B2C side. It now requires the account's
  registered email to have been verified through the existing
  send-email-otp/verify-email-otp flow; the verification is recorded in Redis
  for 10 minutes and consumed on use, so one verification authorises one reset.
  Accounts with no email on file are directed to support rather than left open.

- GET /customer/bookings/:id/price had no ownership check, unlike every other
  customer booking route, so any signed-in customer could read the price quoted
  on anyone else's booking by walking the id.

- GET /miler/consignments/userlogs/:userid took the rider from the URL and never
  compared it to the caller, letting any miler read another miler's movement
  history.

Verified as already correct while sweeping: miler assignment and booking-flow
handlers all scope by mileruserid/assignedmileruserid, customer booking detail
and cancel scope by appcustomerid, /internal sits behind InternalKeyAuth, and
CreateHubStaffAccount already refuses non-Doormile staff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:20:47 +05:30

701 lines
20 KiB
Go

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)
}
func calculateDistance(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371.0
dLat := (lat2 - lat1) * math.Pi / 180.0
dLon := (lon2 - lon1) * math.Pi / 180.0
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*math.Pi/180.0)*math.Cos(lat2*math.Pi/180.0)*
math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return R * c
}
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.OK(c, customer)
}
func UpdateCustomerProfile(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")
}
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"`
}
req := new(ProfileUpdate)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Firstname != "" {
customer.Firstname = req.Firstname
}
customer.Lastname = req.Lastname
customer.Email = req.Email
if req.Defaultlatitude != 0 {
customer.Defaultlatitude = req.Defaultlatitude
}
if req.Defaultlongitude != 0 {
customer.Defaultlongitude = req.Defaultlongitude
}
if req.Defaultpincode != "" {
customer.Defaultpincode = req.Defaultpincode
}
if err := db.DB.Save(&customer).Error; err != nil {
return utils.Internal(c, "failed to update profile")
}
return utils.OK(c, 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")
}
return utils.List(c, locations, int64(len(locations)))
}
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)
if count >= 10 {
return utils.BadRequest(c, "maximum of 10 saved locations allowed")
}
req := new(dto.LocationCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Address == "" || req.Pincode == "" || req.Latitude == 0 || req.Longitude == 0 {
return utils.BadRequest(c, "address, pincode, latitude, and longitude are required")
}
if req.Isdefault {
db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ?", customerID).Update("isdefault", false)
}
location := models.AppCustomerLocation{
Appcustomerid: customerID,
Label: req.Label,
Receivername: req.Receivername,
Receiverphone: req.Receiverphone,
Address: req.Address,
Landmark: req.Landmark,
City: req.City,
State: req.State,
Pincode: req.Pincode,
Latitude: req.Latitude,
Longitude: req.Longitude,
Isdefault: req.Isdefault,
Status: "Active",
}
if err := db.DB.Create(&location).Error; err != nil {
return utils.Internal(c, "failed to save location")
}
return utils.Created(c, 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")
}
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")
}
req := new(dto.LocationCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Label != "" {
location.Label = req.Label
}
location.Receivername = req.Receivername
location.Receiverphone = req.Receiverphone
if req.Address != "" {
location.Address = req.Address
}
location.Landmark = req.Landmark
location.City = req.City
location.State = req.State
if req.Pincode != "" {
location.Pincode = req.Pincode
}
if req.Latitude != 0 {
location.Latitude = req.Latitude
}
if req.Longitude != 0 {
location.Longitude = req.Longitude
}
location.Isdefault = req.Isdefault
if req.Isdefault {
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")
}
return utils.OK(c, 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")
}
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")
}
location.Status = "InActive"
db.DB.Save(&location)
return utils.Message(c, "location deleted successfully")
}
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")
}
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",
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)
}
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")
}
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")
}
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")
}