Files
doormile_backend/middlewares/idempotency.go
Suriyakumarvijayanayagam f6d339a33f 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
2026-08-24 10:34:12 +05:30

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
}
}