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
83 lines
2.7 KiB
Go
83 lines
2.7 KiB
Go
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
|
|
}
|
|
}
|