diff --git a/constants/constants.go b/constants/constants.go index c51ee40..7b59e71 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -33,6 +33,7 @@ const ( BookingCreated = "Created" BookingMilerAssigned = "Miler_Assigned" BookingPickupScheduled = "Pickup_Scheduled" + BookingArrivedAtPickup = "Arrived_At_Pickup" // miler is at the pickup point, parcel not yet collected BookingPickedUp = "Picked_Up" BookingConvertedConsignment = "Converted_To_Consignment" BookingCancelled = "Cancelled" @@ -40,8 +41,15 @@ const ( // Consignment Statuses const ( - ConsignmentCreated = "Created" - ConsignmentInwardedAtHub = "Inwarded_at_Hub" + ConsignmentCreated = "Created" + ConsignmentInwardedAtHub = "Inwarded_at_Hub" + // ConsignmentCollectedByMiler is the intermediate state for a hyperlocal + // parcel: the miler has collected it but has NOT yet started the final-mile + // run. It sits between pickup and Out_for_Delivery so the console can tell + // "collected, waiting to leave" apart from "actively delivering" — before + // this existed a hyperlocal pickup jumped straight to Out_for_Delivery and + // looked active the instant it was collected. StartDelivery moves it on. + ConsignmentCollectedByMiler = "Collected_By_Miler" ConsignmentTripsheetLoaded = "Tripsheet_Loaded" ConsignmentInTransit = "In_Transit" ConsignmentOutForDelivery = "Out_for_Delivery" @@ -52,6 +60,23 @@ const ( ConsignmentDamaged = "Damaged" ) +// Machine-readable error codes returned to the miler app in the "code" field of +// 4xx responses, so the client can branch on a stable identifier instead of +// parsing human-readable messages. Add here, never inline. +const ( + ErrInvalidInput = "INVALID_INPUT" + ErrBookingNotFound = "BOOKING_NOT_FOUND" + ErrBookingNotAssigned = "BOOKING_NOT_ASSIGNED" + ErrConsignmentNotFound = "CONSIGNMENT_NOT_FOUND" + ErrConsignmentNotAssigned = "CONSIGNMENT_NOT_ASSIGNED" + ErrInvalidState = "INVALID_STATE" // action not allowed from the entity's current status + ErrAlreadyPickedUp = "ALREADY_PICKED_UP" // pre-pickup action attempted after pickup + ErrOtpRequired = "OTP_REQUIRED" + ErrOtpInvalid = "OTP_INVALID" + ErrIdempotencyInProgress = "IDEMPOTENCY_IN_PROGRESS" // an identical keyed request is still running + ErrEmailInUse = "EMAIL_IN_USE" +) + // Payment Modes const ( PaymentModeCash = "Cash" diff --git a/controllers/adminController.go b/controllers/adminController.go index 9c074d0..95f4c7d 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -2086,6 +2086,32 @@ func GetAdminBookings(c *fiber.Ctx) error { return utils.Internal(c, "failed to fetch bookings") } + // Surface the live consignment status next to the booking. Once a booking is + // picked up its own status stops moving (it sits at Converted_To_Consignment), + // while the parcel keeps advancing on the consignment — Out_for_Delivery, + // Delivered. Without this the console can only show the frozen booking status + // and a collected order reads as a generic "Active". Batched: one IN query for + // the whole page, not one per row. + consignmentIDs := make([]int, 0, len(bookings)) + for _, b := range bookings { + if b.Consignmentid != nil { + consignmentIDs = append(consignmentIDs, *b.Consignmentid) + } + } + if len(consignmentIDs) > 0 { + var consignments []models.Consignment + db.DB.Select("consignmentid, status").Where("consignmentid IN ?", consignmentIDs).Find(&consignments) + statusByConsignment := make(map[int]string, len(consignments)) + for _, cn := range consignments { + statusByConsignment[cn.Consignmentid] = cn.Status + } + for i := range bookings { + if bookings[i].Consignmentid != nil { + bookings[i].Consignmentstatus = statusByConsignment[*bookings[i].Consignmentid] + } + } + } + pages := int(math.Ceil(float64(total) / float64(pagesize))) return c.JSON(fiber.Map{ diff --git a/controllers/booking_assignment_service.go b/controllers/booking_assignment_service.go index e738c69..044ea38 100644 --- a/controllers/booking_assignment_service.go +++ b/controllers/booking_assignment_service.go @@ -8,6 +8,7 @@ import ( "doormile/constants" "doormile/db" "doormile/internal/notify" + "doormile/internal/routing" "doormile/models" "doormile/utils" @@ -124,6 +125,12 @@ func AssignMilerToBooking(bookingID, milerUserID int, assignedByUserID *int) (*m } publishAssignmentUpdate(booking, milerUserID) + + // Re-sequence the rider's stops now they hold one more. No-op below two active + // stops; runs off the request path so the optimizer's network call never + // blocks or fails a manual assignment. + routing.SequenceMilerStopsAsync(milerUserID) + return booking, nil } diff --git a/controllers/hubController.go b/controllers/hubController.go index e25cf0e..0236767 100644 --- a/controllers/hubController.go +++ b/controllers/hubController.go @@ -1669,7 +1669,7 @@ func buildMilerRoute(mp models.MilerProfile) fiber.Map { switch booking.Status { case constants.BookingPickedUp, constants.BookingConvertedConsignment: status = "completed" - case constants.BookingPickupScheduled: + case constants.BookingPickupScheduled, constants.BookingArrivedAtPickup: status = "in_progress" } diff --git a/controllers/milerAppController.go b/controllers/milerAppController.go index 674ddd1..8c73870 100644 --- a/controllers/milerAppController.go +++ b/controllers/milerAppController.go @@ -313,11 +313,22 @@ func MilerGetMyBookings(c *fiber.Ctx) error { } } + // consignmentid/consignmentstatus so the app can call deliver, skip and + // start-delivery straight from the list without a per-order lookup. + var consignmentStatus string + if b.Consignmentid != nil { + if cn, ok := codByConsignment[*b.Consignmentid]; ok { + consignmentStatus = cn.Status + } + } + row := fiber.Map{ "bookingid": b.Bookingid, "bookingreference": b.Bookingno, "status": b.Status, "stoptype": milerStopType(b.Status), + "consignmentid": b.Consignmentid, + "consignmentstatus": consignmentStatus, "pickupaddress": b.Pickupaddress, "pickuplatitude": b.Pickuplatitude, "pickuplongitude": b.Pickuplongitude, @@ -332,16 +343,22 @@ func MilerGetMyBookings(c *fiber.Ctx) error { "paymentmode": paymentMode, "createdat": b.Createdat, // Route sequencing — 0/empty when the stop was never sequenced. + // sequencedat is the authoritative-order signal: non-null means the + // console/optimizer fixed this stop's position and the app must follow + // step exactly; null means no route was assigned and the app is free to + // fall back to its own nearest-first ordering. "step": 0, "cumulativekms": 0.0, "etaminutes": 0, "cumulativeeta": 0, + "sequencedat": nil, } if a, ok := seqByBooking[b.Bookingid]; ok { row["step"] = a.Step row["cumulativekms"] = a.Cumulativekms row["etaminutes"] = a.Etaminutes row["cumulativeeta"] = a.Cumulativeeta + row["sequencedat"] = a.Sequencedat } response = append(response, row) } @@ -365,6 +382,148 @@ func MilerGetMyBookings(c *fiber.Ctx) error { return utils.List(c, response, int64(len(response))) } +// milerConsignmentForRider loads a consignment and confirms it belongs to this +// rider — either it is linked to a booking currently assigned to them, or they +// are the one who collected it (Createdby). Returns a stable error code on +// failure so the app can branch without parsing messages. +func milerConsignmentForRider(milerUserID, consignmentID int) (*models.Consignment, string, error) { + var consignment models.Consignment + if err := db.DB.First(&consignment, consignmentID).Error; err != nil { + return nil, constants.ErrConsignmentNotFound, err + } + var count int64 + db.DB.Model(&models.PickupBooking{}). + Where("consignmentid = ? AND assignedmileruserid = ?", consignmentID, milerUserID). + Count(&count) + if count == 0 && consignment.Createdby != milerUserID { + return nil, constants.ErrConsignmentNotAssigned, fmt.Errorf("not this rider's consignment") + } + return &consignment, "", nil +} + +// MilerGetConsignment returns a consignment's current state to the app so it can +// decide what the rider may do next (deliver, skip, wait) without replaying the +// whole logs history. Failure cases carry stable codes. +func MilerGetConsignment(c *fiber.Ctx) error { + milerUserID := c.Locals("userid").(int) + consignmentID, err := strconv.Atoi(c.Params("consignmentid")) + if err != nil { + return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "invalid consignment ID") + } + + consignment, code, err := milerConsignmentForRider(milerUserID, consignmentID) + if err != nil { + if code == constants.ErrConsignmentNotFound { + return utils.Fail(c, fiber.StatusNotFound, code, "consignment not found") + } + return utils.Fail(c, fiber.StatusForbidden, code, "this consignment is not assigned to you") + } + + return utils.OK(c, fiber.Map{ + "consignmentid": consignment.Consignmentid, + "trackingno": consignment.Trackingno, + "status": consignment.Status, + "attemptcount": consignment.Attemptcount, + "paymentmode": consignment.Paymentmode, + "codamount": consignment.Codamount, + "codcollected": consignment.Codcollected, + // Derived flags so the app doesn't hardcode the status vocabulary. + "collected": consignment.Status == constants.ConsignmentCollectedByMiler, + "out_for_delivery": consignment.Status == constants.ConsignmentOutForDelivery, + "delivered": consignment.Status == constants.ConsignmentDelivered, + "can_start_delivery": consignment.Status == constants.ConsignmentCollectedByMiler, + "can_deliver": consignment.Status == constants.ConsignmentOutForDelivery, + "can_skip": consignment.Status == constants.ConsignmentOutForDelivery || consignment.Status == constants.ConsignmentCollectedByMiler, + }) +} + +// MilerStartDelivery moves a hyperlocal consignment the rider is carrying from +// Collected_By_Miler to Out_for_Delivery — the explicit "I'm heading to the +// customer now" tap. This is where the receiver OTP is issued (for clients that +// use one) and where the customer is told the parcel is on its way, so neither +// happens a holding-period early at pickup. +func MilerStartDelivery(c *fiber.Ctx) error { + milerUserID := c.Locals("userid").(int) + consignmentID, err := strconv.Atoi(c.Params("id")) + if err != nil { + return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "invalid consignment ID") + } + + consignment, code, err := milerConsignmentForRider(milerUserID, consignmentID) + if err != nil { + if code == constants.ErrConsignmentNotFound { + return utils.Fail(c, fiber.StatusNotFound, code, "consignment not found") + } + return utils.Fail(c, fiber.StatusForbidden, code, "this consignment is not assigned to you") + } + + if consignment.Status != constants.ConsignmentCollectedByMiler { + return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState, + fmt.Sprintf("consignment is %s, not %s", consignment.Status, constants.ConsignmentCollectedByMiler)) + } + + tx := db.DB.Begin() + + // Issue the receiver OTP now, only for clients that ask for one. + if consignment.Deliveryotp == "" { + var tenant models.Tenant + if tx.Where("tenantid = ?", consignment.Tenantid).First(&tenant).Error == nil && tenant.Requiredeliveryotp { + consignment.Deliveryotp = utils.GenerateNumericOTP(6) + } + } + consignment.Status = constants.ConsignmentOutForDelivery + consignment.Updatedat = time.Now() + if err := tx.Save(consignment).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to start delivery") + } + + history := models.ConsignmentHistory{ + Consignmentid: consignment.Consignmentid, + Hubid: consignment.Currenthubid, + Userid: &milerUserID, + Eventstatus: constants.ConsignmentOutForDelivery, + Remarks: "Rider started final-mile delivery", + } + if err := tx.Create(&history).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to record delivery start") + } + + if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). + Update("availabilitystatus", constants.MilerOnDelivery).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 start delivery") + } + + // Tell the customer it's on the way, and hand the receiver their OTP (only the + // receiver — the rider is told it at the door). + var booking models.PickupBooking + if db.DB.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil { + var customer models.AppCustomer + if db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error == nil && customer.Devicetoken != "" { + body := "Your parcel is out for delivery." + payload := map[string]string{"consignment_id": strconv.Itoa(consignment.Consignmentid)} + 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, "Out for Delivery", body, payload); notifyErr != nil { + utils.Warn("FCM: failed to notify customer on delivery start", "consignment_id", consignment.Consignmentid, "error", notifyErr) + } + } + } + + return utils.OK(c, fiber.Map{ + "consignmentid": consignment.Consignmentid, + "status": consignment.Status, + }) +} + // -------------------- // DELIVERY CONFIRMATION // -------------------- @@ -403,7 +562,8 @@ func MilerDeliverConsignment(c *fiber.Ctx) error { } if consignment.Status != constants.ConsignmentOutForDelivery { - return utils.BadRequest(c, "consignment is not out for delivery") + return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState, + "consignment is not out for delivery — start delivery first") } // An OTP is only present when the client asked for one (Tenant.Requiredeliveryotp), @@ -412,10 +572,10 @@ func MilerDeliverConsignment(c *fiber.Ctx) error { // network from before OTPs existed, which would otherwise be unclosable. if consignment.Deliveryotp != "" { if req.Otp == "" { - return utils.BadRequest(c, "otp is required for this delivery") + return utils.Fail(c, fiber.StatusBadRequest, constants.ErrOtpRequired, "otp is required for this delivery") } if req.Otp != consignment.Deliveryotp { - return utils.BadRequest(c, "incorrect delivery OTP") + return utils.Fail(c, fiber.StatusBadRequest, constants.ErrOtpInvalid, "incorrect delivery OTP") } } @@ -571,11 +731,16 @@ func MilerSkipDelivery(c *fiber.Ctx) error { var booking models.PickupBooking if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID). First(&booking).Error; err != nil { - return utils.NotFound(c, "assigned consignment not found") + return utils.Fail(c, fiber.StatusNotFound, constants.ErrConsignmentNotAssigned, "assigned consignment not found") } - if consignment.Status != constants.ConsignmentOutForDelivery { - return utils.BadRequest(c, "consignment is not out for delivery") + // A failed attempt can be reported once the rider is carrying the parcel — + // whether they had already tapped start-delivery (Out_for_Delivery) or not + // (Collected_By_Miler). Both mean "in hand, delivery didn't happen". + if consignment.Status != constants.ConsignmentOutForDelivery && + consignment.Status != constants.ConsignmentCollectedByMiler { + return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState, + "consignment is not in a deliverable state") } tx := db.DB.Begin() diff --git a/controllers/milerController.go b/controllers/milerController.go index 094545e..fdb3210 100644 --- a/controllers/milerController.go +++ b/controllers/milerController.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "math" + "os" "strconv" "strings" "time" @@ -16,6 +17,7 @@ import ( "doormile/dto" "doormile/internal/assignment" "doormile/internal/notify" + "doormile/internal/routing" "doormile/models" "doormile/utils" @@ -246,7 +248,7 @@ func UpdateMilerProfile(c *fiber.Ctx) error { db.DB.Model(&models.AppUser{}). Where("email = ? AND userid <> ?", *req.Email, milerUserID).Count(&clash) if clash > 0 { - return utils.Conflict(c, "that email is already in use") + 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 { @@ -364,7 +366,70 @@ func GetMilerAssignments(c *fiber.Ctx) error { return utils.Internal(c, "failed to fetch assignments") } - return utils.List(c, assignments, int64(len(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 { @@ -437,6 +502,10 @@ func AcceptMilerAssignment(c *fiber.Ctx) error { 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 != "" { @@ -612,7 +681,24 @@ func BookingReachedCustomer(c *fiber.Ctx) error { 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") + return utils.Fail(c, fiber.StatusNotFound, constants.ErrBookingNotAssigned, "assigned booking not found") + } + + // Persist the arrival on the booking itself, not only on the miler's + // availability flag — the reached action used to leave no durable trace, so + // a rider who reopened the app after "I've arrived" was back at the pre- + // arrival step. Gated with the rest of the new flow: the Arrived_At_Pickup + // status only appears once the app understands it. Only advance from a genuine + // pre-pickup state; never walk an already-picked-up booking back. + if collectedStateEnabled() && + booking.Status != constants.BookingPickedUp && + booking.Status != constants.BookingConvertedConsignment { + booking.Status = constants.BookingArrivedAtPickup + booking.Updatedat = time.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). @@ -624,7 +710,10 @@ func BookingReachedCustomer(c *fiber.Ctx) error { if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to confirm arrival") } - return utils.Message(c, "arrival at customer confirmed") + return utils.OK(c, fiber.Map{ + "bookingid": booking.Bookingid, + "status": booking.Status, + }) } func BookingParcelConfirm(c *fiber.Ctx) error { @@ -744,6 +833,24 @@ func isHyperlocal(pickupPincode, deliveryPincode string) bool { return pickupPincode[:3] == deliveryPincode[:3] } +// collectedStateEnabled gates the two-step hyperlocal delivery flow +// (Collected_By_Miler → start-delivery → Out_for_Delivery) and its knock-on +// changes: OTP issued at start-delivery instead of pickup, and Arrived_At_Pickup +// persisted on the reached action. Default OFF so this code deploys without +// changing behaviour — a hyperlocal pickup still goes straight to +// Out_for_Delivery and the *current* rider app keeps working. It MUST stay off +// until a rider-app build that calls start-delivery is live; flipping it early +// would strand every collected parcel in a state the old app can't advance. +// +// Read at request time (env MILER_COLLECTED_STATE_ENABLED=true) so it can be +// turned on the moment the app ships without a redeploy. The additive parts of +// this work — consignmentid on the lists, the GET consignment endpoint, stable +// error codes, idempotency — are NOT gated; they are safe for the old app and +// are what fixes the deliver-button P0 on their own. +func collectedStateEnabled() bool { + return strings.EqualFold(os.Getenv("MILER_COLLECTED_STATE_ENABLED"), "true") +} + func BookingPickupComplete(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) bookingID, err := strconv.Atoi(c.Params("bookingid")) @@ -814,12 +921,22 @@ func BookingPickupComplete(c *fiber.Ctx) error { } } - // Hyperlocal shortcut: pickup and delivery in the same postal area mean - // no hub-to-hub tripsheet leg is needed, so the same miler goes straight - // to final-mile delivery instead of parking the consignment at the hub. + // Hyperlocal shortcut: pickup and delivery in the same postal area mean no + // hub-to-hub tripsheet leg is needed, so the same miler carries it to the + // final mile instead of parking it at the hub. + // + // With the collected-state flow ON it lands in Collected_By_Miler — collected + // but not yet out for delivery — and the rider taps start-delivery to move it + // to Out_for_Delivery, which lets the console tell "collected" from "actively + // delivering". With it OFF (default, and what the current app expects) it goes + // straight to Out_for_Delivery exactly as before. consignmentStatus := constants.ConsignmentInwardedAtHub if isHyperlocal(booking.Pickuppincode, booking.Deliverypincode) { - consignmentStatus = constants.ConsignmentOutForDelivery + if collectedStateEnabled() { + consignmentStatus = constants.ConsignmentCollectedByMiler + } else { + consignmentStatus = constants.ConsignmentOutForDelivery + } } // The consignment's tenant is the booking's own tenant (set explicitly at @@ -871,10 +988,11 @@ func BookingPickupComplete(c *fiber.Ctx) error { } } - // A hyperlocal parcel goes straight out for delivery, so its receiver OTP has - // to exist before this transaction commits. Anything routed via a hub gets - // its OTP when it actually leaves for the final mile instead. Only issued - // for clients that ask for it — see Tenant.Requiredeliveryotp. + // 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 { @@ -906,8 +1024,15 @@ func BookingPickupComplete(c *fiber.Ctx) error { 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", constants.MilerAvailable).Error; err != nil { + Update("availabilitystatus", postPickupAvailability).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to update miler availability") } @@ -916,6 +1041,10 @@ func BookingPickupComplete(c *fiber.Ctx) error { 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) @@ -923,8 +1052,6 @@ func BookingPickupComplete(c *fiber.Ctx) error { "booking_id": strconv.Itoa(bookingID), "tracking_no": trackingNo, } - // The OTP goes to the receiver and only the receiver — the rider has to - // be told it at the door, which is what makes it proof of handover. if consignment.Deliveryotp != "" { body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignment.Deliveryotp) payload["delivery_otp"] = consignment.Deliveryotp @@ -935,12 +1062,31 @@ func BookingPickupComplete(c *fiber.Ctx) error { } return utils.OK(c, fiber.Map{ - "tracking_no": trackingNo, - "consignment_id": consignment.Consignmentid, - "booking_no": booking.Bookingno, + "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")) diff --git a/internal/assignment/crm_assignment.go b/internal/assignment/crm_assignment.go index c66f972..621d778 100644 --- a/internal/assignment/crm_assignment.go +++ b/internal/assignment/crm_assignment.go @@ -8,6 +8,7 @@ import ( "doormile/constants" "doormile/db" + "doormile/internal/routing" "doormile/models" "doormile/utils" @@ -240,6 +241,10 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate, notifyMilerNewAssignment(candidate.profile, booking.Bookingid) notifyCustomerMilerAssigned(booking, candidate.profile.Displayname) + // Order the rider's stops now this one is added. No-op below two active stops; + // best-effort and off this goroutine's critical path. + routing.SequenceMilerStopsAsync(milerUserID) + return nil } diff --git a/internal/assignment/customer_assignment.go b/internal/assignment/customer_assignment.go index cb2431b..d8cbc65 100644 --- a/internal/assignment/customer_assignment.go +++ b/internal/assignment/customer_assignment.go @@ -9,6 +9,7 @@ import ( "doormile/constants" "doormile/db" + "doormile/internal/routing" "doormile/models" "doormile/utils" ) @@ -240,6 +241,10 @@ func commitCustomerAssignment( notifyMilerNewAssignment(miler.profile, booking.Bookingid) notifyCustomerMilerAssigned(booking, miler.profile.Displayname) + // Order the rider's stops now this one is added. No-op below two active stops; + // best-effort and off this goroutine's critical path. + routing.SequenceMilerStopsAsync(milerUserID) + return nil } diff --git a/internal/routing/optimizer.go b/internal/routing/optimizer.go index afac7cb..85b538d 100644 --- a/internal/routing/optimizer.go +++ b/internal/routing/optimizer.go @@ -151,6 +151,27 @@ func SequenceMilerStops(milerUserID int) ([]Result, error) { return results, nil } +// SequenceMilerStopsAsync re-sequences a rider's active stops in the background. +// Fire-and-forget: the assignment paths call this after they commit, so a rider +// who now holds two or more stops gets an ordered route — without the optimizer's +// network call ever sitting inside, or being able to fail, the assignment request +// itself. Below two active stops SequenceMilerStops is a no-op, so calling it +// after every single assignment is safe and cheap. +func SequenceMilerStopsAsync(milerUserID int) { + go func() { + defer func() { + if r := recover(); r != nil { + utils.Error("routing: async sequencing panicked", + "miler_userid", milerUserID, "error", r) + } + }() + if _, err := SequenceMilerStops(milerUserID); err != nil { + utils.Warn("routing: async sequencing failed", + "miler_userid", milerUserID, "error", err) + } + }() +} + // loadActiveStops returns the rider's assignments that still have to be run, // with the coordinates needed to order them. func loadActiveStops(milerUserID int) ([]stop, error) { diff --git a/middlewares/idempotency.go b/middlewares/idempotency.go new file mode 100644 index 0000000..83665ea --- /dev/null +++ b/middlewares/idempotency.go @@ -0,0 +1,82 @@ +package middlewares + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "doormile/constants" + "doormile/db" + + "github.com/gofiber/fiber/v2" +) + +// Idempotency makes a mutation safe to retry over a flaky rider connection. The +// client sends an "Idempotency-Key" header — any stable unique string it picks +// per logical action (e.g. a UUID minted when the rider taps the button). The +// first request with that key runs normally and its response (status + body) is +// cached in Redis; a retry with the same key returns that stored response +// verbatim instead of executing the handler again. So a dropped ack over a bad +// network never turns into a double pickup-complete, a double COD payment or a +// double delivery. +// +// Optional by design: no header, or Redis being unavailable, means the handler +// runs exactly as before — nothing about the existing contract changes for +// clients that don't send a key. The key is namespaced per rider, so one +// rider's key can neither collide with nor read another rider's response. +func Idempotency() fiber.Handler { + const ( + ttl = 24 * time.Hour + lockTTL = 30 * time.Second + sep = "\n" + ) + return func(c *fiber.Ctx) error { + key := c.Get("Idempotency-Key") + if key == "" || db.Rdb == nil { + return c.Next() + } + uid, _ := c.Locals("userid").(int) + base := fmt.Sprintf("idem:%d:%s", uid, key) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // Replay a previously stored response if this key already completed. + if stored, err := db.Rdb.Get(ctx, base).Result(); err == nil { + if idx := strings.Index(stored, sep); idx > 0 { + if status, convErr := strconv.Atoi(stored[:idx]); convErr == nil { + c.Set("Idempotent-Replay", "true") + return c.Status(status).Type("json").SendString(stored[idx+1:]) + } + } + } + + // Claim the key so two concurrent retries don't both execute. The loser + // gets a clear, retryable signal rather than running the mutation twice. + claimed, _ := db.Rdb.SetNX(ctx, base+":lock", "1", lockTTL).Result() + if !claimed { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{ + "success": false, + "code": constants.ErrIdempotencyInProgress, + "message": "an identical request is still being processed", + }) + } + + if err := c.Next(); err != nil { + db.Rdb.Del(context.Background(), base+":lock") + return err + } + + // Cache only deterministic outcomes (2xx/4xx). A 5xx is transient — the + // retry should get a genuine second attempt, not a cached failure. + status := c.Response().StatusCode() + if status < 500 { + body := string(c.Response().Body()) + db.Rdb.Set(context.Background(), base, strconv.Itoa(status)+sep+body, ttl) + } + db.Rdb.Del(context.Background(), base+":lock") + return nil + } +} diff --git a/models/booking.go b/models/booking.go index 4964836..f553ba3 100644 --- a/models/booking.go +++ b/models/booking.go @@ -44,8 +44,14 @@ type PickupBooking struct { Preferredpickupto *time.Time `json:"preferredpickupto" gorm:"column:preferredpickupto"` Assignedmileruserid *int `json:"assignedmileruserid" gorm:"column:assignedmileruserid"` Consignmentid *int `json:"consignmentid" gorm:"column:consignmentid"` - Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` - Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` + // Consignmentstatus is not a column — it is filled in by handlers that want + // the live consignment status alongside the booking (e.g. the admin bookings + // list, so a "Converted_To_Consignment" booking can still show + // Out_for_Delivery / Delivered). omitempty keeps it out of every other + // PickupBooking response that does not populate it. + Consignmentstatus string `json:"consignmentstatus,omitempty" gorm:"-"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` // Relations Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"` diff --git a/models/users.go b/models/users.go index f2441c0..07930c4 100644 --- a/models/users.go +++ b/models/users.go @@ -97,8 +97,8 @@ type MilerProfile struct { // telemetry bridge it existed for was removed — but it is kept as a record // of where each migrated rider came from, which is worth having during the // cutover. Nil for riders created natively in Doormile. - Legacyuserid *int `json:"legacyuserid,omitempty" gorm:"column:legacyuserid;index"` - Defaultvehicletype string `json:"defaultvehicletype" gorm:"column:defaultvehicletype"` + Legacyuserid *int `json:"legacyuserid,omitempty" gorm:"column:legacyuserid;index"` + Defaultvehicletype string `json:"defaultvehicletype" gorm:"column:defaultvehicletype"` // Address is the rider's home/base address, editable from the app's profile // screen. App-only until now, so it had nowhere durable to land. Nullable/ // additive. (Email is not duplicated here — it lives on AppUser.Email, the diff --git a/routes/routes.go b/routes/routes.go index 9627d8c..2b367ae 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -140,8 +140,11 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { milerAuth.Post("/bookings/:bookingid/reached", controllers.BookingReachedCustomer) milerAuth.Post("/bookings/:bookingid/parcel", controllers.BookingParcelConfirm) - milerAuth.Post("/bookings/:bookingid/payment", controllers.BookingPaymentCollect) - milerAuth.Post("/bookings/:bookingid/pickup-complete", controllers.BookingPickupComplete) + // Money and state-conversion mutations are idempotent: a rider retry over a + // bad connection with the same Idempotency-Key replays the first response + // instead of collecting COD twice or minting a second consignment. + milerAuth.Post("/bookings/:bookingid/payment", middlewares.Idempotency(), controllers.BookingPaymentCollect) + milerAuth.Post("/bookings/:bookingid/pickup-complete", middlewares.Idempotency(), controllers.BookingPickupComplete) milerAuth.Post("/bookings/:bookingid/vehicle-required", controllers.BookingVehicleRequiredEscalate) milerAuth.Post("/bookings/:bookingid/cancel", controllers.MilerCancelAssignment) // Pre-pickup skip: defer a not-yet-picked-up booking without releasing it, @@ -171,8 +174,15 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { // Miler's own bookings milerAuth.Get("/bookings", controllers.MilerGetMyBookings) + // Consignment current-state read: lets the app know whether a consignment is + // collected / out-for-delivery / delivered without replaying the logs history. + milerAuth.Get("/consignments/:consignmentid", controllers.MilerGetConsignment) + // Delivery confirmation - milerAuth.Post("/consignments/:id/deliver", controllers.MilerDeliverConsignment) + // start-delivery moves a collected hyperlocal parcel to out-for-delivery (and + // issues the receiver OTP); deliver/skip are the terminal/failed outcomes. + milerAuth.Post("/consignments/:id/start-delivery", middlewares.Idempotency(), controllers.MilerStartDelivery) + milerAuth.Post("/consignments/:id/deliver", middlewares.Idempotency(), controllers.MilerDeliverConsignment) milerAuth.Post("/consignments/:id/skip", controllers.MilerSkipDelivery) // Earnings diff --git a/utils/response.go b/utils/response.go index 7ac8ab4..b3817a5 100644 --- a/utils/response.go +++ b/utils/response.go @@ -41,3 +41,12 @@ func Conflict(c *fiber.Ctx, msg string) error { func Internal(c *fiber.Ctx, msg string) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"success": false, "message": msg}) } + +// Fail is the error response for clients that branch on a stable, machine- +// readable code rather than the human message. The message stays for logs and +// fallback display; `code` is the contract (see constants.Err*). Use this on +// endpoints whose failures the app has to distinguish programmatically; the +// bare BadRequest/NotFound/... helpers remain fine where it doesn't. +func Fail(c *fiber.Ctx, status int, code, msg string) error { + return c.Status(status).JSON(fiber.Map{"success": false, "code": code, "message": msg}) +}