Files
doormile_backend/controllers/milerController.go
Suriyakumarvijayanayagam aeb859d80b feat: miler lifecycle — expose reachedat, arrival-fact reached, PATCH addresses
- GET /miler/bookings now returns reachedat + arrivallatitude/arrivallongitude
  on every row, so the app reconstructs "Arrived" (Pickup_Scheduled + reachedat)
  after a restart with no new status.
- reached records arrival as a FACT (timestamp + GPS) and no longer flips the
  booking to Arrived_At_Pickup — the status stays Pickup_Scheduled, matching the
  rider app's derive-from-reachedat model and dropping the console mapping need.
- New PATCH /miler/bookings/:id/addresses: partial pickup/delivery address,
  pincode, coords, city correction before pickup-complete (INVALID_STATE after).
- pickupbookings gains nullable arrivedat/arrivallatitude/arrivallongitude
  (AutoMigrate, additive).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
2026-08-25 17:49:59 +05:30

1542 lines
49 KiB
Go

package controllers
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"math"
"os"
"strconv"
"strings"
"time"
"doormile/config"
"doormile/constants"
"doormile/db"
"doormile/dto"
"doormile/internal/assignment"
"doormile/internal/notify"
"doormile/internal/routing"
"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")
}
// Email and Address are pointers so an omitted field is left untouched rather
// than blanked. Email lives on AppUser (the login identity), Address on the
// profile — the edit screen collects both, so both are persisted here.
type ProfileUpdate struct {
Displayname string `json:"displayname"`
Profilephotourl string `json:"profilephotourl"`
Defaultvehicletype string `json:"defaultvehicletype"`
Phone string `json:"phone"`
Email *string `json:"email"`
Address *string `json:"address"`
}
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
}
if req.Address != nil {
profile.Address = *req.Address
}
profile.Profilephotourl = req.Profilephotourl
profile.Defaultvehicletype = req.Defaultvehicletype
profile.Updatedat = time.Now()
// Email is unique on appusers, so a collision must fail cleanly rather than
// 500. Only touch it when a non-empty value that actually changed is sent.
if req.Email != nil && *req.Email != "" {
var user models.AppUser
if err := db.DB.First(&user, milerUserID).Error; err == nil && !strings.EqualFold(user.Email, *req.Email) {
var clash int64
db.DB.Model(&models.AppUser{}).
Where("email = ? AND userid <> ?", *req.Email, milerUserID).Count(&clash)
if clash > 0 {
return utils.Fail(c, fiber.StatusConflict, constants.ErrEmailInUse, "that email is already in use")
}
if err := db.DB.Model(&models.AppUser{}).
Where("userid = ?", milerUserID).Update("email", *req.Email).Error; err != nil {
return utils.Internal(c, "failed to update email")
}
}
}
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
// Sequenced stops come first, in the road order internal/routing worked out,
// because that is the order the rider should actually ride them. Anything
// not yet sequenced (step 0 — a single stop, or the optimizer being
// unreachable) falls back to newest-first, which is the old behaviour.
if err := db.DB.Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Order("CASE WHEN step > 0 THEN 0 ELSE 1 END ASC, step ASC, assignedat DESC").
Find(&assignments).Error; err != nil {
return utils.Internal(c, "failed to fetch assignments")
}
// Enrich each assignment with its booking's consignment id + status so the app
// can act on the consignment (deliver/skip/start-delivery) straight from this
// list, without the extra per-order lookup it does today. Batched to two
// queries regardless of how many stops the rider holds.
bookingIDs := make([]int, 0, len(assignments))
for _, a := range assignments {
bookingIDs = append(bookingIDs, a.Bookingid)
}
type bookingRow struct {
Bookingid int
Consignmentid *int
Status string
}
bookingByID := map[int]bookingRow{}
consignmentIDs := make([]int, 0, len(bookingIDs))
if len(bookingIDs) > 0 {
var rows []bookingRow
db.DB.Model(&models.PickupBooking{}).
Select("bookingid, consignmentid, status").
Where("bookingid IN ?", bookingIDs).Scan(&rows)
for _, r := range rows {
bookingByID[r.Bookingid] = r
if r.Consignmentid != nil {
consignmentIDs = append(consignmentIDs, *r.Consignmentid)
}
}
}
consignmentStatusByID := map[int]string{}
if len(consignmentIDs) > 0 {
type cnRow struct {
Consignmentid int
Status string
}
var rows []cnRow
db.DB.Model(&models.Consignment{}).
Select("consignmentid, status").
Where("consignmentid IN ?", consignmentIDs).Scan(&rows)
for _, r := range rows {
consignmentStatusByID[r.Consignmentid] = r.Status
}
}
// Embedded so every existing assignment field stays flat at the top level and
// the app's current parsing is unaffected; the three new keys are additive.
type enrichedAssignment struct {
models.BookingAssignment
Consignmentid *int `json:"consignmentid"`
Consignmentstatus string `json:"consignmentstatus"`
Bookingstatus string `json:"bookingstatus"`
}
out := make([]enrichedAssignment, 0, len(assignments))
for _, a := range assignments {
e := enrichedAssignment{BookingAssignment: a}
if b, ok := bookingByID[a.Bookingid]; ok {
e.Bookingstatus = b.Status
e.Consignmentid = b.Consignmentid
if b.Consignmentid != nil {
e.Consignmentstatus = consignmentStatusByID[*b.Consignmentid]
}
}
out = append(out, e)
}
return utils.List(c, out, int64(len(out)))
}
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")
}
// 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)
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")
}
// The reason may arrive in the JSON body or as a ?reason= query string — the
// deployed contract and the app disagreed on which, so accept both and prefer
// whichever is non-empty. An absent/invalid body is not an error here.
var req struct {
Reason string `json:"reason"`
}
_ = c.BodyParser(&req)
if req.Reason == "" {
req.Reason = c.Query("reason")
}
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")
}
// Rider's actual GPS at the moment of arrival. Optional in the body but the
// app sends it; stored as a durable arrival record, distinct from live
// telemetry. Zero is treated as "not supplied" and left null.
var req struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
_ = c.BodyParser(&req)
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.Fail(c, fiber.StatusNotFound, constants.ErrBookingNotAssigned, "assigned booking not found")
}
// Persist the arrival as a FACT (timestamp + GPS), not as a status change.
// The rider app derives its "Arrived" rung from Pickup_Scheduled + a non-null
// reachedat, so the booking status is deliberately left untouched here —
// introducing an Arrived_At_Pickup status would break that derivation (the
// app classifies strictly by the known status strings) and would need a
// console mapping. Keeping only the fact makes Arrived survive an app restart
// with no new status and no console dependency. reachedat is refreshed on a
// repeat call; GPS is stored only when the app supplies it.
now := time.Now()
booking.Arrivedat = &now
if req.Latitude != 0 && req.Longitude != 0 {
lat, lng := req.Latitude, req.Longitude
booking.Arrivallatitude = &lat
booking.Arrivallongitude = &lng
}
booking.Updatedat = now
if err := tx.Save(&booking).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record arrival")
}
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.OK(c, fiber.Map{
"bookingid": booking.Bookingid,
"status": booking.Status,
"reachedat": booking.Arrivedat,
})
}
// BookingUpdateAddresses lets the rider correct the pickup/delivery address,
// pincode, coordinates and delivery city at the door, before the parcel is
// converted to a consignment. Only fields actually sent are written (pointer
// DTO), so a partial update never wipes an existing value — a request that
// carries just corrected delivery coordinates leaves the address untouched.
//
// Must run before pickup-complete: once the booking is picked up / converted,
// the shipment's addresses are frozen on the consignment and this is rejected
// with INVALID_STATE. Correcting delivery coordinates here also feeds route
// sequencing, which skips stops sitting at 0,0.
func BookingUpdateAddresses(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 {
Pickupaddress *string `json:"pickupaddress"`
Pickuppincode *string `json:"pickuppincode"`
Pickuplatitude *float64 `json:"pickuplatitude"`
Pickuplongitude *float64 `json:"pickuplongitude"`
Deliveryaddress *string `json:"deliveryaddress"`
Deliverypincode *string `json:"deliverypincode"`
Deliverylatitude *float64 `json:"deliverylatitude"`
Deliverylongitude *float64 `json:"deliverylongitude"`
Deliverycity *string `json:"deliverycity"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
var booking models.PickupBooking
if err := db.DB.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil {
return utils.Fail(c, fiber.StatusNotFound, constants.ErrBookingNotAssigned, "assigned booking not found")
}
if booking.Status == constants.BookingPickedUp ||
booking.Status == constants.BookingConvertedConsignment {
return utils.Fail(c, fiber.StatusConflict, constants.ErrInvalidState,
"addresses can only be edited before pickup-complete")
}
// Build only the columns actually supplied. A non-empty string or a non-zero
// coordinate counts as supplied; an omitted field (nil pointer) is left as-is.
updates := map[string]interface{}{}
if req.Pickupaddress != nil && strings.TrimSpace(*req.Pickupaddress) != "" {
updates["pickupaddress"] = strings.TrimSpace(*req.Pickupaddress)
}
if req.Pickuppincode != nil && strings.TrimSpace(*req.Pickuppincode) != "" {
updates["pickuppincode"] = strings.TrimSpace(*req.Pickuppincode)
}
if req.Pickuplatitude != nil && *req.Pickuplatitude != 0 {
updates["pickuplatitude"] = *req.Pickuplatitude
}
if req.Pickuplongitude != nil && *req.Pickuplongitude != 0 {
updates["pickuplongitude"] = *req.Pickuplongitude
}
if req.Deliveryaddress != nil && strings.TrimSpace(*req.Deliveryaddress) != "" {
updates["deliveryaddress"] = strings.TrimSpace(*req.Deliveryaddress)
}
if req.Deliverypincode != nil && strings.TrimSpace(*req.Deliverypincode) != "" {
updates["deliverypincode"] = strings.TrimSpace(*req.Deliverypincode)
}
if req.Deliverylatitude != nil && *req.Deliverylatitude != 0 {
updates["deliverylatitude"] = *req.Deliverylatitude
}
if req.Deliverylongitude != nil && *req.Deliverylongitude != 0 {
updates["deliverylongitude"] = *req.Deliverylongitude
}
if req.Deliverycity != nil && strings.TrimSpace(*req.Deliverycity) != "" {
updates["deliverycity"] = strings.TrimSpace(*req.Deliverycity)
}
if len(updates) == 0 {
return utils.BadRequest(c, "no address fields supplied to update")
}
updates["updatedat"] = time.Now()
if err := db.DB.Model(&booking).Updates(updates).Error; err != nil {
return utils.Internal(c, "failed to update addresses")
}
return utils.OK(c, booking)
}
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]
}
// collectedStateEnabled gates the two-step hyperlocal delivery flow
// (Collected_By_Miler → start-delivery → Out_for_Delivery) and its knock-on
// changes: OTP issued at start-delivery instead of pickup, and Arrived_At_Pickup
// persisted on the reached action. Default OFF so this code deploys without
// changing behaviour — a hyperlocal pickup still goes straight to
// Out_for_Delivery and the *current* rider app keeps working. It MUST stay off
// until a rider-app build that calls start-delivery is live; flipping it early
// would strand every collected parcel in a state the old app can't advance.
//
// Read at request time (env MILER_COLLECTED_STATE_ENABLED=true) so it can be
// turned on the moment the app ships without a redeploy. The additive parts of
// this work — consignmentid on the lists, the GET consignment endpoint, stable
// error codes, idempotency — are NOT gated; they are safe for the old app and
// are what fixes the deliver-button P0 on their own.
func collectedStateEnabled() bool {
return strings.EqualFold(os.Getenv("MILER_COLLECTED_STATE_ENABLED"), "true")
}
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 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.
consignmentStatus := constants.ConsignmentInwardedAtHub
if isHyperlocal(booking.Pickuppincode, booking.Deliverypincode) {
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
// 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,
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,
}
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 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 {
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")
}
// 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.
postPickupAvailability := constants.MilerAvailable
if consignmentStatus == constants.ConsignmentCollectedByMiler {
postPickupAvailability = constants.MilerPickedUp
}
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
Update("availabilitystatus", postPickupAvailability).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")
}
// 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)
}
}
return utils.OK(c, fiber.Map{
"tracking_no": trackingNo,
"consignment_id": consignment.Consignmentid,
"consignmentstatus": consignment.Status,
"booking_no": booking.Bookingno,
"booking_status": booking.Status,
"next_action": pickupNextAction(consignment.Status),
})
}
// pickupNextAction tells the app what the rider does next after a pickup, so it
// doesn't have to encode the hub-vs-hyperlocal branch itself:
// - Collected_By_Miler → tap start-delivery (collected-state flow on)
// - Out_for_Delivery → deliver directly (hyperlocal, collected-state off)
// - Inwarded_at_Hub → handed to the hub, done for this rider
func pickupNextAction(consignmentStatus string) string {
switch consignmentStatus {
case constants.ConsignmentCollectedByMiler:
return "start_delivery"
case constants.ConsignmentOutForDelivery:
return "deliver"
default:
return "handed_to_hub"
}
}
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")
}
// The path names a rider, so it has to be checked against the caller —
// otherwise any miler could read another miler's movement history simply by
// changing the number in the URL.
if userID != c.Locals("userid").(int) {
return utils.Forbidden(c, "you can only read your own consignment logs")
}
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")
}