package controllers import ( "context" "crypto/rand" "encoding/json" "fmt" "math" "strconv" "time" "doormile/config" "doormile/constants" "doormile/db" "doormile/dto" "doormile/internal/assignment" "doormile/internal/notify" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" "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) 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 user models.AppUser if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil { return utils.NotFound(c, "no miler account found for this phone number") } if user.Roleid != 5 { return utils.Forbidden(c, "this endpoint is restricted to miler accounts") } if user.Status != "Active" { return utils.Forbidden(c, "miler account is not active") } return c.JSON(fiber.Map{ "success": true, "message": "PIN verification required", "phone": req.Phone, }) } } func VerifyMilerPin(cfg *config.Config) fiber.Handler { return func(c *fiber.Ctx) error { req := new(dto.MilerPinVerifyRequest) 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 user models.AppUser if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil { return utils.NotFound(c, "no miler account found for this phone number") } if user.Roleid != 5 { return utils.Forbidden(c, "this endpoint is restricted to miler accounts") } if user.Status != "Active" { return utils.Forbidden(c, "miler account is not active") } if !utils.CheckPasswordHash(req.Pin, user.Password) { return utils.Unauthorized(c, "incorrect PIN") } token, err := utils.GenerateToken(user.Userid, user.Email, user.Roleid, user.Tenantid, user.Configid, cfg.JWTSecret) if err != nil { return utils.Internal(c, "failed to generate token") } var profile models.MilerProfile if err := db.DB.Where("userid = ?", user.Userid).First(&profile).Error; err != nil { profile = models.MilerProfile{ Userid: user.Userid, Displayname: user.Authname, Phone: user.Contactno, Availabilitystatus: constants.MilerOffline, Rating: 5.0, Applocationid: user.Applocationid, } db.DB.Create(&profile) } if req.DeviceToken != "" && profile.Devicetoken != req.DeviceToken { profile.Devicetoken = req.DeviceToken db.DB.Model(&profile).Update("device_token", req.DeviceToken) } return c.JSON(fiber.Map{ "success": true, "token": token, "user": fiber.Map{ "userid": user.Userid, "authname": user.Authname, "email": user.Email, "contactno": user.Contactno, "profile": profile, }, }) } } // ResetMilerPin lets a miler who forgot their PIN set a new one from just // their phone number, matching ResetCustomerPin's flow exactly (protected // only by authThrottle at the route level, same as the customer version — // no OTP verification wired in here either, consistent with the existing // pattern rather than a change to it). func ResetMilerPin(c *fiber.Ctx) error { req := new(dto.MilerResetPinRequest) 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 user models.AppUser if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil { return utils.NotFound(c, "no miler account found for this phone number") } if user.Roleid != 5 { return utils.Forbidden(c, "this endpoint is restricted to miler accounts") } pinHash, err := utils.HashPassword(req.NewPin) if err != nil { return utils.Internal(c, "failed to process PIN reset") } user.Password = pinHash if err := db.DB.Save(&user).Error; err != nil { return utils.Internal(c, "failed to reset PIN") } return utils.Message(c, "PIN reset successfully") } func GetMilerProfile(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var user models.AppUser if err := db.DB.First(&user, milerUserID).Error; err != nil { return utils.NotFound(c, "user not found") } var profile models.MilerProfile if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil { return utils.NotFound(c, "miler profile not found") } return utils.OK(c, fiber.Map{ "userid": user.Userid, "authname": user.Authname, "email": user.Email, "contactno": user.Contactno, "profile": profile, }) } func UpdateMilerProfile(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var profile models.MilerProfile if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil { return utils.NotFound(c, "miler profile not found") } type ProfileUpdate struct { Displayname string `json:"displayname"` Profilephotourl string `json:"profilephotourl"` Defaultvehicletype string `json:"defaultvehicletype"` Phone string `json:"phone"` } req := new(ProfileUpdate) if err := c.BodyParser(req); err != nil { return utils.BadRequest(c, "invalid request body") } if req.Displayname != "" { profile.Displayname = req.Displayname } if req.Phone != "" { profile.Phone = req.Phone } profile.Profilephotourl = req.Profilephotourl profile.Defaultvehicletype = req.Defaultvehicletype profile.Updatedat = time.Now() if err := db.DB.Save(&profile).Error; err != nil { return utils.Internal(c, "failed to update profile") } return utils.OK(c, profile) } func UpdateMilerLocation(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) req := new(dto.MilerLocationUpdateRequest) if err := c.BodyParser(req); err != nil { return utils.BadRequest(c, "invalid request body") } if req.Latitude == 0 || req.Longitude == 0 { return utils.BadRequest(c, "latitude and longitude are required") } var profile models.MilerProfile if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil { return utils.NotFound(c, "miler profile not found") } profile.Currentlatitude = req.Latitude profile.Currentlongitude = req.Longitude profile.Currentpincode = req.Pincode now := time.Now() profile.Lastlocationupdatedat = &now profile.Updatedat = now if err := db.DB.Save(&profile).Error; err != nil { return utils.Internal(c, "failed to update location") } if db.Rdb != nil { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() redisKey := fmt.Sprintf("miler:gps:%d", milerUserID) val := fmt.Sprintf("%f,%f", req.Latitude, req.Longitude) db.Rdb.Set(ctx, redisKey, val, 30*time.Minute) db.Rdb.GeoAdd(ctx, "milers:locations", &redis.GeoLocation{ Name: strconv.Itoa(milerUserID), Latitude: req.Latitude, Longitude: req.Longitude, }) } return utils.OK(c, fiber.Map{ "latitude": req.Latitude, "longitude": req.Longitude, "pincode": req.Pincode, }) } func UpdateMilerAvailability(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) req := new(dto.MilerAvailabilityRequest) if err := c.BodyParser(req); err != nil { return utils.BadRequest(c, "invalid request body") } status := req.ResolvedStatus() if status == "" { return utils.BadRequest(c, "status is required") } var profile models.MilerProfile if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil { return utils.NotFound(c, "miler profile not found") } profile.Availabilitystatus = status profile.Updatedat = time.Now() if err := db.DB.Save(&profile).Error; err != nil { return utils.Internal(c, "failed to update availability") } var user models.AppUser if err := db.DB.First(&user, milerUserID).Error; err == nil { if req.Status == constants.MilerOffline || req.Status == constants.MilerBlocked { user.Onduty = 0 } else { user.Onduty = 1 } db.DB.Save(&user) } return utils.OK(c, profile) } func GetMilerAssignments(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var assignments []models.BookingAssignment if err := db.DB.Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). Order("assignedat DESC").Find(&assignments).Error; err != nil { return utils.Internal(c, "failed to fetch assignments") } return utils.List(c, assignments, int64(len(assignments))) } func GetMilerAssignmentDetails(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) assignmentID, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid assignment ID") } var assignment models.BookingAssignment if err := db.DB.Where("bookingassignmentid = ? AND mileruserid = ?", assignmentID, milerUserID).First(&assignment).Error; err != nil { return utils.NotFound(c, "assignment not found") } var booking models.PickupBooking db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, assignment.Bookingid) return utils.OK(c, fiber.Map{ "assignment": assignment, "booking": booking, }) } func AcceptMilerAssignment(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) assignmentID, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid assignment ID") } tx := db.DB.Begin() var assignment models.BookingAssignment if err := tx.Where("bookingassignmentid = ? AND mileruserid = ?", assignmentID, milerUserID).First(&assignment).Error; err != nil { tx.Rollback() return utils.NotFound(c, "assignment not found") } if assignment.Assignmentstatus != constants.AssignmentAssigned { tx.Rollback() return utils.BadRequest(c, fmt.Sprintf("assignment is not pending acceptance (current status: %s)", assignment.Assignmentstatus)) } now := time.Now() assignment.Assignmentstatus = constants.AssignmentAccepted assignment.Acceptedat = &now if err := tx.Save(&assignment).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to accept assignment") } var booking models.PickupBooking if err := tx.First(&booking, assignment.Bookingid).Error; err == nil { booking.Status = constants.BookingPickupScheduled booking.Assignedmileruserid = &milerUserID booking.Updatedat = now if err := tx.Save(&booking).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to update booking") } } if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). Update("availabilitystatus", constants.MilerAssigned).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to update miler availability") } if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to commit assignment acceptance") } 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") } func RejectMilerAssignment(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) assignmentID, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid assignment ID") } var req struct { Reason string `json:"reason"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } if req.Reason == "" { req.Reason = "Rejected by rider" } tx := db.DB.Begin() var ba models.BookingAssignment if err := tx.Where("bookingassignmentid = ? AND mileruserid = ?", assignmentID, milerUserID).First(&ba).Error; err != nil { tx.Rollback() return utils.NotFound(c, "assignment not found") } ba.Assignmentstatus = constants.AssignmentRejected ba.Remarks = req.Reason if err := tx.Save(&ba).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to reject assignment") } var booking models.PickupBooking if err := tx.First(&booking, ba.Bookingid).Error; err == nil { booking.Status = constants.BookingCreated booking.Assignedmileruserid = nil booking.Updatedat = time.Now() if err := tx.Save(&booking).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to release booking") } } if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). Update("availabilitystatus", constants.MilerAvailable).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to update miler availability") } if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to commit assignment rejection") } if booking.Bookingid != 0 { if booking.Bookingsource == "CRM_Console" { go assignment.AssignCRMMiler(booking.Bookingid) } else { go assignment.AssignCustomerMiler(booking.Bookingid) } } return utils.Message(c, "assignment rejected") } // MilerCancelAssignment lets a miler back out of a booking they've already // accepted but not yet picked up (vehicle breakdown, can't reach the // address, etc.). Distinct from RejectMilerAssignment, which only applies // before acceptance — once the parcel is picked up the booking has become a // consignment and this no longer applies (use MilerSkipDelivery instead for // a failed delivery attempt on an in-flight consignment). Releases the // booking for reassignment the same way RejectMilerAssignment does. func MilerCancelAssignment(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) bookingID, err := strconv.Atoi(c.Params("bookingid")) if err != nil { return utils.BadRequest(c, "invalid booking ID") } var req struct { Reason string `json:"reason"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } if req.Reason == "" { req.Reason = "Cancelled by miler" } tx := db.DB.Begin() var booking models.PickupBooking if err := tx.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil { tx.Rollback() return utils.NotFound(c, "assigned booking not found") } if booking.Status == constants.BookingPickedUp || booking.Status == constants.BookingConvertedConsignment { tx.Rollback() return utils.BadRequest(c, "booking cannot be cancelled after pickup — the parcel is already in the network") } var ba models.BookingAssignment if err := tx.Where("bookingid = ? AND mileruserid = ? AND assignmentstatus = ?", bookingID, milerUserID, constants.AssignmentAccepted).First(&ba).Error; err != nil { tx.Rollback() return utils.BadRequest(c, "no accepted assignment found for this booking") } ba.Assignmentstatus = constants.AssignmentCancelled ba.Remarks = req.Reason if err := tx.Save(&ba).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to cancel assignment") } booking.Status = constants.BookingCreated booking.Assignedmileruserid = nil booking.Updatedat = time.Now() if err := tx.Save(&booking).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to release booking") } if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). Update("availabilitystatus", constants.MilerAvailable).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to update miler availability") } if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to commit cancellation") } if booking.Bookingsource == "CRM_Console" { go assignment.AssignCRMMiler(booking.Bookingid) } else { go assignment.AssignCustomerMiler(booking.Bookingid) } return utils.Message(c, "assignment cancelled and released for reassignment") } func BookingReachedCustomer(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) bookingID, err := strconv.Atoi(c.Params("bookingid")) if err != nil { return utils.BadRequest(c, "invalid booking ID") } tx := db.DB.Begin() var booking models.PickupBooking if err := tx.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil { tx.Rollback() return utils.NotFound(c, "assigned booking not found") } if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). Update("availabilitystatus", constants.MilerAtCustomer).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to update miler availability") } if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to confirm arrival") } return utils.Message(c, "arrival at customer confirmed") } func BookingParcelConfirm(c *fiber.Ctx) error { milerUserID := 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 assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil { return utils.NotFound(c, "assigned booking not found") } type ParcelUpdate struct { ParcelID int `json:"parcel_id"` Weight float64 `json:"weight"` Length float64 `json:"length"` Width float64 `json:"width"` Height float64 `json:"height"` } 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 parcels []models.BookingParcel if err := db.DB.Where("bookingid = ?", bookingID).Find(&parcels).Error; err != nil { return utils.NotFound(c, "parcel details not found") } // 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] } 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 { milerUserID := 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 assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil { return utils.NotFound(c, "assigned booking not found") } req := new(dto.PaymentRequest) if err := c.BodyParser(req); err != nil { return utils.BadRequest(c, "invalid request body") } if req.Amount <= 0 { return utils.BadRequest(c, "payment amount must be greater than zero") } now := time.Now() payment := models.BookingPayment{ Bookingid: bookingID, Amount: req.Amount, Paymentmode: req.Paymentmode, Paymentstatus: constants.PaymentStatusPaid, Collectedbyuserid: &milerUserID, Transactionref: req.Transactionref, Paidat: &now, } if err := db.DB.Create(&payment).Error; err != nil { return utils.Internal(c, "failed to record payment") } return utils.Created(c, payment) } // isHyperlocal reports whether a pickup and delivery pincode fall in the same // 3-digit postal area, following the same zone-prefix convention as // hubPincodePrefix in hubController.go. A same-area booking needs no // hub-to-hub tripsheet leg, so the collecting miler can carry it straight to // final-mile delivery. Pincodes shorter than 3 characters are treated as // unknown rather than matching, so bad data falls back to the safe hub route. func isHyperlocal(pickupPincode, deliveryPincode string) bool { if len(pickupPincode) < 3 || len(deliveryPincode) < 3 { return false } return pickupPincode[:3] == deliveryPincode[:3] } func BookingPickupComplete(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) bookingID, err := strconv.Atoi(c.Params("bookingid")) if err != nil { return utils.BadRequest(c, "invalid booking ID") } tx := db.DB.Begin() var booking models.PickupBooking if err := tx.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil { tx.Rollback() return utils.NotFound(c, "assigned booking not found") } now := time.Now() booking.Status = constants.BookingPickedUp booking.Updatedat = now if err := tx.Save(&booking).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to update booking status") } var profile models.MilerProfile if err := tx.Where("userid = ?", milerUserID).First(&profile).Error; err == nil { profile.Totalcompletedpickups += 1 profile.Availabilitystatus = constants.MilerPickedUp profile.Updatedat = now if err := tx.Save(&profile).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to update miler profile") } } 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 } } if len(parcels) == 0 { totalDead = 0.5 totalChargeable = 0.5 } trackingNo := generateTrackingNo() var defaultHubID *int if profile.Hubid != nil { defaultHubID = profile.Hubid } else { utils.Warn("BookingPickupComplete: miler has no assigned hub, falling back to first hub row", "miler_user_id", milerUserID, "booking_id", bookingID) var hub models.Hub if tx.First(&hub).Error == nil { defaultHubID = &hub.Hubid } } // Hyperlocal shortcut: pickup and delivery in the same postal area mean // no hub-to-hub tripsheet leg is needed, so the same miler goes straight // to final-mile delivery instead of parking the consignment at the hub. consignmentStatus := constants.ConsignmentInwardedAtHub if isHyperlocal(booking.Pickuppincode, booking.Deliverypincode) { 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 // their JWT tenantid here mis-attributed every such consignment. Falls // back to the miler's own tenantid only for B2C bookings that don't carry // one yet, matching the previous behavior for that case. consignmentTenantID := c.Locals("tenantid").(int) if booking.Tenantid != nil { 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, 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, } 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 consignment.Paymentmode = "COD" } } // A hyperlocal parcel goes straight out for delivery, so its receiver OTP has // to exist before this transaction commits. Anything routed via a hub gets // its OTP when it actually leaves for the final mile instead. Only issued // for clients that ask for it — see Tenant.Requiredeliveryotp. if consignmentStatus == constants.ConsignmentOutForDelivery { var tenant models.Tenant if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil && tenant.Requiredeliveryotp { 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") } booking.Consignmentid = &consignment.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") } if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). Update("availabilitystatus", constants.MilerAvailable).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to update miler availability") } if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to complete pickup") } 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, } // The OTP goes to the receiver and only the receiver — the rider has to // be told it at the door, which is what makes it proof of handover. 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) } } return utils.OK(c, fiber.Map{ "tracking_no": trackingNo, "consignment_id": consignment.Consignmentid, "booking_no": booking.Bookingno, }) } func BookingVehicleRequiredEscalate(c *fiber.Ctx) error { milerUserID := 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 assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil { return utils.NotFound(c, "assigned booking not found") } reqVeh := models.BookingVehicleRequirement{ Bookingid: bookingID, Requiredvehicletype: c.Query("type", "truck"), Reason: c.Query("reason", "Package is too large for bike rider"), Status: "Required", } if err := db.DB.Create(&reqVeh).Error; err != nil { return utils.Internal(c, "failed to register vehicle requirement") } return utils.Created(c, reqVeh) } func CreateMilerPeriodicLog(c *fiber.Ctx) error { ctx := context.Background() var log models.MilerLog if err := c.BodyParser(&log); err != nil { return utils.BadRequest(c, "invalid request body") } // The rider's identity comes from their token, never the body. Trusting a // client-supplied userid let any authenticated miler write another miler's // GPS trail, which feeds the location data dispatch reasons over. log.UserID = c.Locals("userid").(int) t, err := time.Parse("2006-01-02 15:04:05", log.LogDate) if err != nil { return utils.BadRequest(c, "invalid logdate format — expected YYYY-MM-DD HH:MM:SS") } timestamp := t.Unix() logKey := fmt.Sprintf("miler_periodic_log:%d:%d", log.UserID, timestamp) data, _ := json.Marshal(log) if db.Rdb != nil { if err := db.Rdb.Set(ctx, logKey, data, 0).Err(); err != nil { return utils.Internal(c, "failed to store log") } userZsetKey := fmt.Sprintf("miler_periodic_logs:%d", log.UserID) db.Rdb.ZAdd(ctx, userZsetKey, redis.Z{Score: float64(timestamp), Member: logKey}) db.Rdb.ZAdd(ctx, "miler_periodic_logs_all", redis.Z{Score: float64(timestamp), Member: logKey}) } return utils.Message(c, "miler periodic log stored successfully") } func GetMilerPeriodicLogs(c *fiber.Ctx) error { ctx := context.Background() if db.Rdb == nil { return utils.Internal(c, "cache service unavailable") } userID := c.Query("userid") var keys []string var err error if userID != "" { zsetKey := fmt.Sprintf("miler_periodic_logs:%s", userID) keys, err = db.Rdb.ZRevRange(ctx, zsetKey, 0, 0).Result() } else { keys, err = db.Rdb.ZRevRange(ctx, "miler_periodic_logs_all", 0, 0).Result() } if err != nil { return utils.Internal(c, "failed to fetch logs") } if len(keys) == 0 { return utils.List(c, []interface{}{}, 0) } val, err := db.Rdb.Get(ctx, keys[0]).Result() if err != nil { return utils.Internal(c, "failed to retrieve log data") } var log map[string]interface{} json.Unmarshal([]byte(val), &log) return utils.OK(c, log) } func CreateMilerStatus(c *fiber.Ctx) error { ctx := context.Background() var status models.MilerStatus if err := c.BodyParser(&status); err != nil { return utils.BadRequest(c, "invalid request body") } // Identity from the token, not the body — otherwise one rider can set // another rider's live status. status.UserID = c.Locals("userid").(int) if status.Status == "" { return utils.BadRequest(c, "status is required") } key := fmt.Sprintf("miler_status:%d", status.UserID) data, _ := json.Marshal(status) if db.Rdb != nil { if err := db.Rdb.Set(ctx, key, data, 0).Err(); err != nil { return utils.Internal(c, "failed to store status") } db.Rdb.ZAdd(ctx, "miler_status_all", redis.Z{ Score: float64(time.Now().Unix()), Member: key, }) } return utils.Message(c, "miler status updated successfully") } func GetMilerStatus(c *fiber.Ctx) error { ctx := context.Background() if db.Rdb == nil { return utils.Internal(c, "cache service unavailable") } userIDStr := c.Query("userid") if userIDStr != "" { key := fmt.Sprintf("miler_status:%s", userIDStr) val, err := db.Rdb.Get(ctx, key).Result() if err != nil { return utils.NotFound(c, "status not found for this miler") } var data map[string]interface{} json.Unmarshal([]byte(val), &data) return utils.OK(c, data) } pageStr := c.Query("page") pageSizeStr := c.Query("pagesize") page, _ := strconv.Atoi(pageStr) pageSize, _ := strconv.Atoi(pageSizeStr) var start, end int64 if page > 0 && pageSize > 0 { offset := (page - 1) * pageSize start = int64(offset) end = int64(offset + pageSize - 1) } else { start = 0 end = -1 } keys, err := db.Rdb.ZRevRange(ctx, "miler_status_all", start, end).Result() if err != nil { return utils.Internal(c, "failed to fetch statuses") } if len(keys) == 0 { return utils.List(c, []interface{}{}, 0) } values, err := db.Rdb.MGet(ctx, keys...).Result() if err != nil { return utils.Internal(c, "failed to retrieve status data") } var result []map[string]interface{} for _, val := range values { if val == nil { continue } var item map[string]interface{} json.Unmarshal([]byte(val.(string)), &item) result = append(result, item) } return utils.List(c, result, int64(len(result))) } func PublishConsignmentLogs(c *fiber.Ctx) error { var input []models.ConsignmentLog if err := c.BodyParser(&input); err != nil { return utils.BadRequest(c, "invalid request body") } if len(input) == 0 { return utils.BadRequest(c, "at least one log entry is required") } if db.Rdb == nil { return utils.Internal(c, "cache service unavailable") } milerUserID := c.Locals("userid").(int) pipe := db.Rdb.TxPipeline() tx := db.DB.Begin() for _, item := range input { // Same rule as the other telemetry writers: the token owns the identity, // so a batch can't be attributed to some other rider. item.UserID = milerUserID logTime, err := time.Parse("2006-01-02 15:04:05", item.LogDate) if err != nil { logTime = time.Now() } ts := logTime.Unix() logKey := "Consignmentlogs:" + strconv.Itoa(item.ConsignmentID) userIndexKey := "user:consignmentlogs:" + strconv.Itoa(item.UserID) jsonData, _ := json.Marshal(item) pipe.RPush(db.Ctx, logKey, jsonData) pipe.ZAdd(db.Ctx, userIndexKey, redis.Z{ Score: float64(ts), Member: item.ConsignmentID, }) history := models.ConsignmentHistory{ Consignmentid: item.ConsignmentID, Userid: &item.UserID, Eventstatus: item.Status, Remarks: fmt.Sprintf("GPS Update: Lat %s, Lon %s. Speed %s. Remarks: %s", item.Latitude, item.Longitude, item.Speed, item.Remarks), Createdat: logTime, } if err := tx.Create(&history).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to persist consignment log") } } if _, err := pipe.Exec(db.Ctx); err != nil { tx.Rollback() return utils.Internal(c, "failed to publish logs to cache") } if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to publish consignment logs") } return utils.Message(c, "consignment logs published successfully") } func GetConsignmentLogs(c *fiber.Ctx) error { consignmentID, err := strconv.Atoi(c.Params("consignmentid")) if err != nil { return utils.BadRequest(c, "invalid consignment ID") } if db.Rdb == nil { return utils.Internal(c, "cache service unavailable") } logKey := "Consignmentlogs:" + strconv.Itoa(consignmentID) redisList, err := db.Rdb.LRange(db.Ctx, logKey, 0, -1).Result() if err == nil && len(redisList) > 0 { var logs []map[string]interface{} for _, raw := range redisList { var m map[string]interface{} json.Unmarshal([]byte(raw), &m) logs = append(logs, m) } return utils.List(c, logs, int64(len(logs))) } var history []models.ConsignmentHistory if err := db.DB.Where("consignmentid = ?", consignmentID).Order("createdat ASC").Find(&history).Error; err != nil { return utils.Internal(c, "failed to fetch consignment logs") } return utils.List(c, history, int64(len(history))) } func GetUserConsignmentLogs(c *fiber.Ctx) error { userID, err := strconv.Atoi(c.Params("userid")) if err != nil { return utils.BadRequest(c, "invalid user ID") } if db.Rdb == nil { return utils.Internal(c, "cache service unavailable") } userIndexKey := "user:consignmentlogs:" + strconv.Itoa(userID) members, err := db.Rdb.ZRevRange(db.Ctx, userIndexKey, 0, -1).Result() if err != nil { return utils.Internal(c, "failed to fetch consignment log index") } var logs []map[string]interface{} for _, consignmentIDStr := range members { logKey := "Consignmentlogs:" + consignmentIDStr rawList, err := db.Rdb.LRange(db.Ctx, logKey, -1, -1).Result() if err == nil && len(rawList) > 0 { var m map[string]interface{} json.Unmarshal([]byte(rawList[0]), &m) logs = append(logs, m) } } 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") }