feat: miler delivery-leg fixes — consignmentid, auto route sequencing, admin consignment status
Miler app P0 + contract gaps found in the live audit: - GET /miler/bookings now returns consignmentid + consignmentstatus on every row (nullable), so the app can call deliver/skip/start-delivery straight from the list. /miler/assignments is the active-only queue, so this is the authoritative fix for stops that have moved onto the delivery leg. - GET /miler/bookings now returns sequencedat per row: non-null means the console/optimizer fixed this stop's order and the app follows step exactly; null means no route assigned and the app may fall back to nearest-first. - Route sequencing (internal/routing) now runs automatically after every assignment — customer auto-assign, express auto-assign, manual assign, and accept — via SequenceMilerStopsAsync (fire-and-forget, no-op below two active stops). Previously only hub batch-assign sequenced, so most riders saw step=0. - GET /admin/bookings now surfaces the live consignmentstatus alongside the frozen booking status, so a Converted_To_Consignment booking can still show Out_for_Delivery / Delivered instead of a generic "Active". Two-step hyperlocal flow (Arrived_At_Pickup, Collected_By_Miler, start-delivery) stays gated behind MILER_COLLECTED_STATE_ENABLED (default off) until the app ships; consignmentid/status, GET /miler/consignments/:id, stable error codes and Idempotency-Key handling are unconditional and safe on the current app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
82
middlewares/idempotency.go
Normal file
82
middlewares/idempotency.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user