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, }) } } // resolveTenantName returns the tenant's display name for a tenantid, or "" // when the id is unset/unknown. The miler app resolves a rider's service // profile (hyperlocal vs logistics) from this name in preference to the raw // tenantid, so it must be present on the login and profile responses — a rider // whose tenant we don't name falls back to id-matching, and any unmapped id // falls back to logistics (no Start-delivery button). Cheap single-row lookup. func resolveTenantName(tenantID int) string { if tenantID == 0 { return "" } var t models.Tenant if err := db.DB.Select("tenantname").Where("tenantid = ?", tenantID).First(&t).Error; err != nil { return "" } return t.Tenantname } 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) } // tenantname drives the app's service-profile resolution (hyperlocal vs // logistics), checked ahead of the raw tenantid. Persisted client-side at // verify-pin, so a rider picks up a changed tenant name on next login. tenantName := resolveTenantName(user.Tenantid) return c.JSON(fiber.Map{ "success": true, "token": token, "tenantid": user.Tenantid, "tenantname": tenantName, "user": fiber.Map{ "userid": user.Userid, "authname": user.Authname, "email": user.Email, "contactno": user.Contactno, "tenantid": user.Tenantid, "tenantname": tenantName, "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") } // tenantname is included here too so the app can refresh it at launch via // GET /miler/profile without forcing a re-login after the tenantname rollout. return utils.OK(c, fiber.Map{ "userid": user.Userid, "authname": user.Authname, "email": user.Email, "contactno": user.Contactno, "tenantid": user.Tenantid, "tenantname": resolveTenantName(user.Tenantid), "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] } // maxHyperlocalKM bounds the straight-line pickup→delivery distance under which // a booking with a missing/short pincode is still treated as hyperlocal. It is // used ONLY as a fallback when the pincode-prefix rule can't decide: console- // created kitchen→customer bookings (e.g. DailyGrubs) frequently carry accurate // coordinates but no delivery pincode, and must not be wrongly routed through a // hub. When both pincodes are present the prefix rule still wins. const maxHyperlocalKM = 30.0 // isHyperlocalBooking decides whether a booking can skip the hub and be carried // straight to the final mile. It prefers the pincode-prefix rule (isHyperlocal) // when both pincodes are present, and falls back to the straight-line distance // between the pickup and delivery coordinates when a pincode is missing — so a // same-area booking whose address carried no pincode isn't sent to a hub. func isHyperlocalBooking(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng float64) bool { // Both pincodes present: the prefix rule decides definitively (a matching // pincode is hyperlocal, a differing one is genuinely inter-area — don't let // distance override that). if len(pickupPincode) >= 3 && len(deliveryPincode) >= 3 { return isHyperlocal(pickupPincode, deliveryPincode) } // A pincode is missing: fall back to straight-line distance when we have both // coordinates. if pLat != 0 && pLng != 0 && dLat != 0 && dLng != 0 { return haversineKM(pLat, pLng, dLat, dLng) <= maxHyperlocalKM } return false } // 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 isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode, booking.Pickuplatitude, booking.Pickuplongitude, booking.Deliverylatitude, booking.Deliverylongitude) { 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") }