package controllers import ( "context" "encoding/json" "fmt" "math" "os" "strconv" "strings" "time" "doormile/config" "doormile/constants" "doormile/db" "doormile/dto" "doormile/internal/assignment" "doormile/internal/cxstage" "doormile/internal/legs" "doormile/internal/notify" "doormile/internal/routing" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" "github.com/redis/go-redis/v9" ) 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") } // pin_set tells the app which screen to show next: true → enter-PIN // (verify-pin login), false → set-PIN (first-time). A rider created without // a PIN has an empty Password; every real PIN is a non-empty bcrypt hash. return c.JSON(fiber.Map{ "success": true, "message": "PIN verification required", "phone": req.Phone, "pin_set": user.Password != "", }) } } // 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") } return issueMilerSession(c, cfg, user, req.DeviceToken) } } // issueMilerSession builds the authenticated-session response shared by // verify-pin and set-pin: it mints the JWT, ensures a MilerProfile exists, // refreshes the device token, and returns the single {token, user, profile} // shape both entry points must agree on. tenantname drives the app's // service-profile resolution (hyperlocal vs logistics), checked ahead of the // raw tenantid and persisted client-side so a rider picks up a changed tenant // name on next login. func issueMilerSession(c *fiber.Ctx, cfg *config.Config, user models.AppUser, deviceToken string) error { 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 deviceToken != "" && profile.Devicetoken != deviceToken { profile.Devicetoken = deviceToken db.DB.Model(&profile).Update("device_token", deviceToken) } 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") } // SetMilerPin is the self-service first-login PIN creation, the counterpart to // the ops-only ResetMilerPin. It is allowed ONLY when the account has no PIN yet // (empty Password): because it can never overwrite an existing PIN, the // phone-only unauthenticated path here cannot take over an already-active // account the way an unguarded reset could — the worst case is a not-yet-used // account being claimed by someone who knows the phone number, which OTP should // close once the SMS gateway is live. A rider who already has a PIN is sent to // verify-pin (or an ops reset) via 409. On success the rider is logged in // immediately, so the app never has to make a second verify-pin call. func SetMilerPin(cfg *config.Config) fiber.Handler { return func(c *fiber.Ctx) error { req := new(dto.MilerSetPinRequest) 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") } if user.Status != "Active" { return utils.Forbidden(c, "miler account is not active") } if user.Password != "" { return utils.Conflict(c, "a PIN is already set for this account; use verify-pin to log in") } pinHash, err := utils.HashPassword(req.NewPin) if err != nil { return utils.Internal(c, "failed to set PIN") } user.Password = pinHash if err := db.DB.Save(&user).Error; err != nil { return utils.Internal(c, "failed to set PIN") } return issueMilerSession(c, cfg, user, req.DeviceToken) } } 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) } // indexMilerLocation records a rider's position where auto-assignment looks // for riders: the `miler:gps:{userid}` key and the `milers:locations` GEO set // that internal/assignment GEOSEARCHes around a pickup. // // Used by UpdateMilerLocation and MilerStartDuty. Before, only the location // ping wrote here, so a rider who had tapped "Start duty" was Available in the // database but invisible to assignment until their app sent its first GPS // ping — orders created in that gap were never offered to them. func indexMilerLocation(milerUserID int, lat, lon float64) { if db.Rdb == nil || lat == 0 || lon == 0 { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() redisKey := fmt.Sprintf("miler:gps:%d", milerUserID) val := fmt.Sprintf("%f,%f", lat, lon) db.Rdb.Set(ctx, redisKey, val, 30*time.Minute) db.Rdb.GeoAdd(ctx, "milers:locations", &redis.GeoLocation{ Name: strconv.Itoa(milerUserID), Latitude: lat, Longitude: lon, }) } 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") } indexMilerLocation(milerUserID, req.Latitude, 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") } // Accepting is the customer's "on the way": the rider has seen the job and // is heading over. `assigned` was already recorded when the assignment was // created (assignMilerTx / commitAssignment) and is re-asserted here only // as a safety net for a booking assigned before this surface existed — // Record dedupes, so it appends nothing when it is already on the timeline. // // Deriving on_the_way from the GPS stream instead would mean re-deriving it // on every ping: thousands of writes to learn something the accept already // said. stageAt := utils.DBNow() for _, stage := range []string{constants.CxStageAssigned, constants.CxStageOnTheWay} { if err := cxstage.Record(tx, cxstage.Event{ BookingID: assignment.Bookingid, Stage: stage, ActorType: constants.CxActorMiler, ActorID: &milerUserID, Source: "POST /miler/assignments/{id}/accept", At: stageAt, }); err != nil { tx.Rollback() utils.Error("AcceptMilerAssignment: could not record customer stage", "booking_id", assignment.Bookingid, "stage", stage, "error", err) return utils.Internal(c, "failed to accept assignment") } } if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to commit assignment acceptance") } // No push here: `assigned` was already announced when the assignment was // created, and on_the_way deliberately rolls up on the timeline. The // existing "Miler Accepted" notification below is the one the customer gets. // 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") } // The pickup is NOT cancelled — it goes back into the pool. The customer's // stage has to walk back with it, or they keep seeing "Miler assigned" and // a rider card for someone who is no longer coming. if err := cxstage.Release(tx, booking.Bookingid, req.Reason, constants.CxActorMiler, &milerUserID, "POST /miler/bookings/{id}/cancel"); err != nil { tx.Rollback() utils.Error("MilerCancelAssignment: could not release the customer stage", "booking_id", booking.Bookingid, "error", err) return utils.Internal(c, "failed to commit cancellation") } 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") } // Arrival is the LAST cancellable stage on the customer side, so it has to // be recorded transactionally with the arrival fact itself. A gap between // the two is a window in which the customer can still cancel a pickup the // rider is already standing at. if err := cxstage.Record(tx, cxstage.Event{ BookingID: booking.Bookingid, Stage: constants.CxStageArrived, ActorType: constants.CxActorMiler, ActorID: &milerUserID, Source: "POST /miler/bookings/{id}/reached", At: utils.DBNow(), }); err != nil { tx.Rollback() utils.Error("BookingReachedCustomer: could not record arrived stage", "booking_id", booking.Bookingid, "error", err) return utils.Internal(c, "failed to record arrival") } if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to confirm arrival") } go cxstage.Notify(booking.Bookingid, nil, constants.CxStageArrived) 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") } // Photos are the evidence half of the receipt. Weight without a photograph // is a number the customer has no way to check, and this is the only point // in the flow where anyone is standing next to the parcel. Sent as storage // keys from the presigned upload (POST /miler/uploads/sign), not as raw // URLs: the customer is served a short-lived signed link derived from the // key, never a permanent one. type ParcelUpdate struct { ParcelID int `json:"parcel_id"` Weight float64 `json:"weight"` Length float64 `json:"length"` Width float64 `json:"width"` Height float64 `json:"height"` Photos []string `json:"photos"` } 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 := utils.DBNow() var totalChargeable float64 // Chargeable weight per destination, so each order settles on the weight of // its own parcels rather than on the whole visit's total. perDestination := map[int]float64{} tx := db.DB.Begin() 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 if err := tx.Save(p).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to save parcel measurements") } volumetric := calculateVolumetricWeight(upd.Length, upd.Width, upd.Height) chargeable := math.Max(upd.Weight, volumetric) totalChargeable += chargeable if p.Bookingdestinationid != nil { perDestination[*p.Bookingdestinationid] += chargeable } for _, key := range upd.Photos { key = strings.TrimSpace(key) if key == "" { continue } photo := models.BookingParcelPhoto{ Bookingid: bookingID, Bookingdestinationid: p.Bookingdestinationid, Objectkey: key, Capturedbyuserid: &milerUserID, Capturedat: now, } if err := tx.Create(&photo).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to save parcel photo") } } } // The verification block the customer's receipt reads. Written here, at the // door, where the measurement was actually taken — pickup-complete restates // it from the same parcel rows when the price settles, so the two cannot // disagree. for destinationID, weight := range perDestination { if err := cxRecordVerification(tx, destinationID, weight, milerUserID, now); err != nil { tx.Rollback() utils.Error("BookingParcelConfirm: could not record verification", "booking_id", bookingID, "error", err) return utils.Internal(c, "failed to record the parcel weight") } } if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to confirm parcels") } 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 { return legs.SamePostalArea(pickupPincode, deliveryPincode) } // 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 = legs.MaxHyperlocalKM // 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 { return legs.IsHyperlocal(pickupPincode, deliveryPincode, pLat, pLng, dLat, dLng) } // 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") } // IST wall-clock (DBNow), so the compatibility-flow inwardedat stamped below // (and booking.updatedat) matches createdat and the reconciliation windows. now := utils.DBNow() 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") } } // One visit, N orders. A customer-app booking fans out into one consignment // per destination — each with its own tracking number and its own journey — // while a console-created booking, which has no destination rows, produces // the single consignment it always did. cxPickupLegs is what decides which // of those this is; nothing below needs to know. legs, err := cxPickupLegs(tx, &booking) if err != nil { tx.Rollback() utils.Error("BookingPickupComplete: could not resolve pickup legs", "booking_id", bookingID, "error", err) return utils.Internal(c, "failed to read the parcels on this booking") } // The base this parcel belongs to. Backend decides — the app is told where to // go and never picks a base itself. resolveHandoverHub prefers the base the // booking was routed to, then the collecting rider's own base (the previous // behaviour, and still the operational default), then the nearest active base // to the pickup point — that last step replaces a fallback that took whichever // hub row happened to come back first. handoverHub := resolveHandoverHub(&booking, profile.Hubid) var defaultHubID *int if handoverHub != nil { defaultHubID = &handoverHub.Hubid } else { utils.Warn("BookingPickupComplete: no base could be resolved for this pickup", "miler_user_id", milerUserID, "booking_id", bookingID) } // 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 } var tenant models.Tenant tenantNeedsOTP := false if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil { tenantNeedsOTP = tenant.Requiredeliveryotp } // Money collected at the door. Split across the legs below rather than // stamped whole onto each one: a single payment covering a three-stop // pickup must not appear three times in the books. var payment models.BookingPayment hasPayment := tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil created := make([]models.Consignment, 0, len(legs)) trackingNos := make([]string, 0, len(legs)) riderMarkedBusy := false assignmentStillOpen := false for i, leg := range legs { totalDead, totalChargeable, maxL, maxW, maxH := cxLegWeights(leg) trackingNo := generateTrackingNo() // A hub-routed parcel: with the hub-handover flow ON it stops at Created — // collected, in the rider's hands, on its way to a base — and only reaches // Inwarded_at_Hub when the handover is actually recorded. With it OFF // (default, and what the current app expects) it is marked Inwarded_at_Hub // here, which is not where the parcel physically is but is what the current // app and the console's inbound views read. consignmentStatus := constants.ConsignmentInwardedAtHub if hubHandoverEnabled() { consignmentStatus = constants.ConsignmentCreated } // 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. Decided per leg, because on // a multi-destination pickup one parcel can be going round the corner while // another is going to another state. // // 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. if isHyperlocalBooking(booking.Pickuppincode, leg.DeliveryPincode, booking.Pickuplatitude, booking.Pickuplongitude, leg.DeliveryLatitude, leg.DeliveryLongitude) { if collectedStateEnabled() { consignmentStatus = constants.ConsignmentCollectedByMiler } else { consignmentStatus = constants.ConsignmentOutForDelivery } } // 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: leg.DeliveryLatitude, Deliverylongitude: leg.DeliveryLongitude, Pickuppincode: booking.Pickuppincode, Deliverypincode: leg.DeliveryPincode, Length: maxL, Width: maxW, Height: maxH, Deadweight: totalDead, Volumetricweight: totalChargeable - totalDead, Chargeableweight: totalChargeable, Paymentmode: "Prepaid", Status: consignmentStatus, Estimateddeliveryat: cxEstimatedDelivery(leg, now), Createdby: milerUserID, Originhubid: defaultHubID, Currenthubid: defaultHubID, } // COD the customer asked to have collected at THIS door, on their behalf. // Doormile is the carrier, not the seller — this money is never Doormile's. if leg.CodAmount > 0 { consignment.Codamount = leg.CodAmount consignment.Paymentmode = "COD" } // Under the compatibility flow the parcel is treated as received at the base // the moment it is collected, so the received-at fact is stamped here too — // otherwise every parcel inwarded this way would have a null handover time // and base reconciliation would have nothing to compare against. if consignmentStatus == constants.ConsignmentInwardedAtHub { consignment.Inwardedat = &now } // The pickup fee the miler collected covers the whole visit, so it is // recorded once — against the first order — rather than repeated on // every leg. if hasPayment && i == 0 { 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 && tenantNeedsOTP { 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") } if err := cxLinkLegToOrder(tx, leg, consignment.Consignmentid, trackingNo, consignment.Estimateddeliveryat, now); err != nil { tx.Rollback() utils.Error("BookingPickupComplete: could not link destination to order", "booking_id", bookingID, "error", err) return utils.Internal(c, "failed to link the destination to its order") } // What the miler weighed at this door, kept where the customer's receipt // reads it. Without this the verification block stays empty and the // receipt loses the evidence behind the settled price. if leg.Destination != nil { if err := cxRecordVerification(tx, leg.Destination.Bookingdestinationid, totalChargeable, milerUserID, now); err != nil { tx.Rollback() utils.Error("BookingPickupComplete: could not record verification", "booking_id", bookingID, "error", err) return utils.Internal(c, "failed to record the parcel weight") } } 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") } // Two separate questions, deliberately not merged. // // "Is the rider marked busy?" keeps the EXACT rule this endpoint has // always applied — Created or Collected_By_Miler only. A hyperlocal // parcel that goes straight to Out_for_Delivery has always left the // rider Available here, even though the comment below says otherwise. // That mismatch is pre-existing and is the default path today; changing // it would alter live rider availability, which is not this work's // business. Flagged in docs/customer-app-api.md, not silently fixed. // // "Is the assignment still open?" is the one that has to understand the // fan-out: it closes only when every leg has been handed over at a base, // which for a single-leg booking is identical to the previous behaviour. if consignmentStatus == constants.ConsignmentCollectedByMiler || consignmentStatus == constants.ConsignmentCreated { riderMarkedBusy = true } if consignmentStatus != constants.ConsignmentInwardedAtHub { assignmentStillOpen = true } // The customer's per-order stage. in_transit is recorded here only on the // compatibility flow, where the parcel really is treated as received at // the base the instant it is collected; on the handover flow it waits for // the rider to actually hand it over. destinationID := cxDestinationIDFor(leg.Destination) if err := cxstage.Record(tx, cxstage.Event{ BookingID: bookingID, DestinationID: destinationID, Stage: constants.CxStageOrderCreated, ActorType: constants.CxActorMiler, ActorID: &milerUserID, Source: "POST /miler/bookings/{id}/pickup-complete", At: utils.DBNow(), }); err != nil { tx.Rollback() utils.Error("BookingPickupComplete: could not record order_created", "booking_id", bookingID, "error", err) return utils.Internal(c, "failed to record the pickup") } // On the compatibility flow a parcel is already past order_created the // instant it is collected — hub-routed ones are stamped Inwarded_at_Hub // here, and hyperlocal ones go straight to Out_for_Delivery. Recording // what the status actually says keeps the timeline honest: those // transitions really did happen at this moment, and omitting them would // leave a parcel showing "Package collected" while the rider is already // carrying it to the door. if implied, ok := cxStageForConsignmentStatus(consignmentStatus); ok { if err := cxstage.Record(tx, cxstage.Event{ BookingID: bookingID, DestinationID: destinationID, Stage: implied, ActorType: constants.CxActorMiler, ActorID: &milerUserID, Source: "POST /miler/bookings/{id}/pickup-complete", At: utils.DBNow(), }); err != nil { tx.Rollback() utils.Error("BookingPickupComplete: could not record per-order stage", "booking_id", bookingID, "error", err) return utils.Internal(c, "failed to record the pickup") } } created = append(created, consignment) trackingNos = append(trackingNos, trackingNo) } // Recorded before order_created in wall-clock terms — the parcels were in the // rider's hands before the orders existed — but written after, because the // weights the price settles on are only known once the legs are built. if err := cxstage.Record(tx, cxstage.Event{ BookingID: bookingID, Stage: constants.CxStagePickedUp, ActorType: constants.CxActorMiler, ActorID: &milerUserID, Source: "POST /miler/bookings/{id}/pickup-complete", At: utils.DBNow(), }); err != nil { tx.Rollback() utils.Error("BookingPickupComplete: could not record picked_up", "booking_id", bookingID, "error", err) return utils.Internal(c, "failed to record the pickup") } // pickupbookings.consignmentid names the FIRST order only. It is kept for // the console and the legacy reads that still join on it; anything that // needs the whole set goes through bookingdestinations. first := created[0] booking.Consignmentid = &first.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") } // Unchanged from before the fan-out: a parcel still on its way to a base // keeps the rider marked busy; anything else frees them up. postPickupAvailability := constants.MilerAvailable if riderMarkedBusy { postPickupAvailability = constants.MilerPickedUp } // A parcel that is already inwarded at the base ends this rider's leg, so the // assignment closes with it. Without this the assignment stayed open forever // on the compatibility flow and the rider could not go off duty — MilerEndDuty // refuses while any assignment is still Assigned/Accepted. On the handover // flow the assignment stays open on purpose and closes at inward-at-hub. if !assignmentStillOpen { if err := tx.Model(&models.BookingAssignment{}). Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?", bookingID, milerUserID, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). Updates(map[string]interface{}{ "assignmentstatus": constants.AssignmentCompleted, "completedat": now, }).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to close assignment") } } 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") } // One notification for the milestone, not one per order: three tracking // numbers arriving as three buzzes for a single visit is noise, and // order_created deliberately rolls up on the timeline. go cxstage.Notify(bookingID, nil, constants.CxStagePickedUp) // 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. notifyCustomerOnPickup(&booking, created, trackingNos) // next_action says what the rider does next; next_hub says where. An // inward_at_hub with no base named leaves a rider holding a parcel with // nowhere to take it, so the two travel together — and next_hub carries all // six fields, because the coordinates are the only thing that can drive // Navigate and the address and pincode are what the rider reads at the gate. // // consignment_id is always present: the delivery leg is keyed on it, and // without it the app cannot name the parcel it is about to act on. // // The single-consignment fields still describe the FIRST order, unchanged, // so the deployed rider app keeps working exactly as before. `consignments` // is additive and carries the full set for a build that can show them. resp := fiber.Map{ "tracking_no": trackingNos[0], "consignment_id": first.Consignmentid, "consignmentstatus": first.Status, "status": first.Status, "booking_no": booking.Bookingno, "booking_status": booking.Status, "next_action": nextActionForConsignment(first.Status), "consignments": renderPickupOrders(created, trackingNos), } if first.Status == constants.ConsignmentCreated || first.Status == constants.ConsignmentInwardedAtHub { resp["next_hub"] = renderBase(handoverHub) } return utils.OK(c, resp) } // renderPickupOrders lists every order a pickup produced, so a rider carrying // three parcels from one visit can be shown three stops rather than one. func renderPickupOrders(consignments []models.Consignment, trackingNos []string) []fiber.Map { out := make([]fiber.Map, 0, len(consignments)) for i := range consignments { cn := consignments[i] out = append(out, fiber.Map{ "consignment_id": cn.Consignmentid, "tracking_no": trackingNos[i], "consignmentstatus": cn.Status, "next_action": nextActionForConsignment(cn.Status), "delivery_pincode": cn.Deliverypincode, }) } return out } // notifyCustomerOnPickup tells the customer their parcels were collected, and // hands the receiver any delivery OTP that was issued. Best-effort: a push that // fails must never fail a pickup that already committed. func notifyCustomerOnPickup(booking *models.PickupBooking, consignments []models.Consignment, trackingNos []string) { var customer models.AppCustomer if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err != nil { return } if customer.Devicetoken == "" { return } body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNos[0]) if len(trackingNos) > 1 { body = fmt.Sprintf("%d parcels picked up — first tracking no: %s", len(trackingNos), trackingNos[0]) } payload := map[string]string{ "booking_id": strconv.Itoa(booking.Bookingid), "tracking_no": trackingNos[0], "reference": booking.Bookingno, } if len(consignments) > 0 && consignments[0].Deliveryotp != "" { body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignments[0].Deliveryotp) payload["delivery_otp"] = consignments[0].Deliveryotp } if err := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); err != nil { utils.Warn("FCM: failed to notify customer on pickup", "booking_id", booking.Bookingid, "error", err) } } 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") }