Files
doormile_backend/controllers/customerController.go
Suriya c91c887726 Add assignment engine, FCM, WebSockets, city gate, and internal APIs
- internal/assignment: GEORADIUS miler assignment with retry/escalation,
  customer-side provider scoring, FCM notifications on assign
- internal/notify: Firebase Admin SDK (FCM) client initialisation
- internal/ws: WebSocket handlers for live parcel tracking and
  customer↔miler chat
- middlewares: city gate (pincode prefix validation), internal API key
  auth, WebSocket JWT auth
- controllers: InternalNotify + InternalReassign for machine-to-machine
  calls; pricing helpers wired into CreateCustomerBooking and CreateCRMBooking
- routes: /internal/*, /ws/bookings/:id/track, /ws/bookings/:id/chat
- models/users, models/doormile_pricing: new fields for device tokens,
  assignment state, pricing bands
- seed_data.sql: initial pricing seed rows

.env and Firebase service-account JSON intentionally excluded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 12:31:53 +05:30

658 lines
19 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
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")
}
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")
}
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
if price, found := lookupDoormilePrice(zone, mapServiceTypeToPricing(serviceType), totalWeight, itemCategory); found {
estimatedPrice = price
} 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,
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",
}
tx.Create(&reqVeh)
}
tx.Commit()
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 {
bookingID, err := strconv.Atoi(c.Params("bookingid"))
if err != nil {
return utils.BadRequest(c, "invalid booking ID")
}
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")
}