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:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user