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>
This commit is contained in:
2026-06-25 12:31:53 +05:30
parent c577d47b75
commit c91c887726
21 changed files with 2399 additions and 75 deletions

View File

@@ -13,6 +13,7 @@ import (
"doormile/constants"
"doormile/db"
"doormile/dto"
"doormile/internal/notify"
"doormile/models"
"doormile/utils"
@@ -336,6 +337,21 @@ func AcceptMilerAssignment(c *fiber.Ctx) error {
tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerAssigned)
tx.Commit()
if booking.Bookingid != 0 {
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
if notifyErr := notify.SendToDevice(
customer.Devicetoken,
"Miler Accepted",
"Your miler has accepted and is coming",
map[string]string{"booking_id": strconv.Itoa(booking.Bookingid)},
); notifyErr != nil {
utils.Warn("FCM: failed to notify customer on accept", "booking_id", booking.Bookingid, "error", notifyErr)
}
}
}
return utils.Message(c, "assignment accepted successfully")
}
@@ -406,30 +422,56 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
}
type ParcelUpdate struct {
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"`
}
req := new(ParcelUpdate)
if err := c.BodyParser(req); err != nil {
var req struct {
Parcels []ParcelUpdate `json:"parcels"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if len(req.Parcels) == 0 {
return utils.BadRequest(c, "parcels array is required")
}
var parcel models.BookingParcel
if err := db.DB.Where("bookingid = ?", bookingID).First(&parcel).Error; err != nil {
var parcels []models.BookingParcel
if err := db.DB.Where("bookingid = ?", bookingID).Find(&parcels).Error; err != nil {
return utils.NotFound(c, "parcel details not found")
}
parcel.Weight = req.Weight
parcel.Length = req.Length
parcel.Width = req.Width
parcel.Height = req.Height
parcel.Updatedat = time.Now()
db.DB.Save(&parcel)
// Index loaded parcels by ID for O(1) lookup.
parcelMap := make(map[int]*models.BookingParcel, len(parcels))
for i := range parcels {
parcelMap[parcels[i].Bookingparcelid] = &parcels[i]
}
return utils.OK(c, parcel)
now := time.Now()
var totalChargeable float64
for _, upd := range req.Parcels {
p, ok := parcelMap[upd.ParcelID]
if !ok {
continue
}
p.Weight = upd.Weight
p.Length = upd.Length
p.Width = upd.Width
p.Height = upd.Height
p.Updatedat = now
db.DB.Save(p)
volumetric := calculateVolumetricWeight(upd.Length, upd.Width, upd.Height)
totalChargeable += math.Max(upd.Weight, volumetric)
}
return utils.OK(c, fiber.Map{
"parcels": parcels,
"total_chargeable_weight": totalChargeable,
})
}
func BookingPaymentCollect(c *fiber.Ctx) error {
@@ -499,11 +541,28 @@ func BookingPickupComplete(c *fiber.Ctx) error {
tx.Save(&profile)
}
var parcel models.BookingParcel
tx.Where("bookingid = ?", bookingID).First(&parcel)
var parcels []models.BookingParcel
tx.Where("bookingid = ?", bookingID).Find(&parcels)
volumetric := calculateVolumetricWeight(parcel.Length, parcel.Width, parcel.Height)
chargeable := math.Max(parcel.Weight, volumetric)
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
}
}
if len(parcels) == 0 {
totalDead = 0.5
totalChargeable = 0.5
}
trackingNo := generateTrackingNo()
@@ -522,12 +581,12 @@ func BookingPickupComplete(c *fiber.Ctx) error {
Deliverylongitude: booking.Deliverylongitude,
Pickuppincode: booking.Pickuppincode,
Deliverypincode: booking.Deliverypincode,
Length: parcel.Length,
Width: parcel.Width,
Height: parcel.Height,
Deadweight: parcel.Weight,
Volumetricweight: volumetric,
Chargeableweight: chargeable,
Length: maxL,
Width: maxW,
Height: maxH,
Deadweight: totalDead,
Volumetricweight: totalChargeable - totalDead,
Chargeableweight: totalChargeable,
Paymentmode: "Prepaid",
Status: constants.ConsignmentInwardedAtHub,
Estimateddeliveryat: nil,
@@ -568,6 +627,21 @@ func BookingPickupComplete(c *fiber.Ctx) error {
tx.Commit()
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
if notifyErr := notify.SendToDevice(
customer.Devicetoken,
"Parcel Picked Up",
fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo),
map[string]string{
"booking_id": strconv.Itoa(bookingID),
"tracking_no": trackingNo,
},
); notifyErr != nil {
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", bookingID, "error", notifyErr)
}
}
return utils.OK(c, fiber.Map{
"tracking_no": trackingNo,
"consignment_id": consignment.Consignmentid,
@@ -881,3 +955,25 @@ func GetUserConsignmentLogs(c *fiber.Ctx) error {
return utils.List(c, logs, int64(len(logs)))
}
func SaveMilerDeviceToken(c *fiber.Ctx) error {
milerUserID := 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.MilerProfile{}).
Where("userid = ?", milerUserID).
Update("device_token", req.DeviceToken).Error; err != nil {
return utils.Internal(c, "failed to save device token")
}
return utils.Message(c, "device token saved")
}