Assignment decided who carried a booking but never what order to run several of them in -- the one capability jupiter had that Doormile did not. It turns out we already own the solver: routes.workolik.com is a live in-house Route Optimization API backed by Valhalla road-network routing, not the paid third party I had assumed. This is a client for it, not a solver. internal/routing posts a rider's active stops and writes back step, previouskms, cumulativekms and ETA onto BookingAssignment. HubBatchAssign calls it after committing a batch, which is exactly the case it exists for: a rider used to walk away with several bookings and no order to run them in. GetMilerAssignments now returns sequenced stops in step order, falling back to newest-first for anything unsequenced. Contract discovered by probing the live service -- the OpenAPI schema types the body as a bare object array, so the field names are not documented anywhere. They are pickuplat/pickuplong/deliverylat/ deliverylong, NOT pickuplatitude/deliverylatitude. Sending the wrong names does not fail: it returns HTTP 200 with every coordinate defaulted to "0.0", no reordering and all distances zero. That trap is recorded in a comment so the next person does not lose an afternoon to it. Numeric fields come back inconsistently typed -- previouskms as a number, actualkms and eta as strings, some decimal -- so they are decoded loosely and coerced, with tests pinning the coercion. Steps for deliveryids we did not send are discarded rather than written, so an echoed or stale id cannot reorder another rider's work. Sequencing is best-effort throughout and runs after assignments commit. The optimizer is a separate service over the network; it being down must leave bookings assigned but unordered, never undo the batch. Step 0 means "not sequenced", not "first". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
186 lines
5.7 KiB
Go
186 lines
5.7 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"doormile/config"
|
|
"doormile/controllers"
|
|
"doormile/db"
|
|
"doormile/internal/assignment"
|
|
"doormile/internal/notify"
|
|
"doormile/internal/routing"
|
|
"doormile/internal/worker"
|
|
"doormile/middlewares"
|
|
"doormile/migrations"
|
|
"doormile/routes"
|
|
"doormile/utils"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
|
"github.com/gofiber/fiber/v2/middleware/limiter"
|
|
"github.com/gofiber/fiber/v2/middleware/recover"
|
|
"github.com/joho/godotenv"
|
|
)
|
|
|
|
// errorHandler converts anything a handler returns — including a panic already
|
|
// turned into an error by the recover middleware — into the same
|
|
// {success, message} envelope the utils helpers emit, so clients never receive
|
|
// Fiber's default plain-text error body.
|
|
func errorHandler(c *fiber.Ctx, err error) error {
|
|
code := fiber.StatusInternalServerError
|
|
msg := "internal server error"
|
|
|
|
var fe *fiber.Error
|
|
if errors.As(err, &fe) {
|
|
code = fe.Code
|
|
msg = fe.Message
|
|
}
|
|
|
|
// 5xx means we broke, not the caller — log it with the route for triage.
|
|
if code >= fiber.StatusInternalServerError {
|
|
utils.Error("request failed",
|
|
"method", c.Method(),
|
|
"path", c.Path(),
|
|
"status", code,
|
|
"error", err.Error(),
|
|
)
|
|
}
|
|
|
|
return c.Status(code).JSON(fiber.Map{"success": false, "message": msg})
|
|
}
|
|
|
|
func main() {
|
|
// 1. Load configuration env variables
|
|
_ = godotenv.Load()
|
|
cfg := config.Load()
|
|
|
|
utils.Info("Starting Doormile Backend...")
|
|
|
|
// 2. Connect to Postgres, Redis & NATS
|
|
db.Connect(cfg)
|
|
db.InitRedis(cfg)
|
|
db.InitNATS(cfg)
|
|
notify.InitFCM()
|
|
|
|
// 3. Run GORM migrations for logistics tables
|
|
if db.DB != nil {
|
|
err := migrations.Migrate(db.DB)
|
|
if err != nil {
|
|
utils.Error("⚠️ Migration failed, continuing server boot...", "error", err.Error())
|
|
}
|
|
}
|
|
|
|
// 4. Initialize Fiber App
|
|
fiberCfg := fiber.Config{
|
|
AppName: "Doormile Logistics Service API v1",
|
|
ErrorHandler: errorHandler,
|
|
}
|
|
|
|
// Rate limiting keys on c.IP(). Behind a TLS-terminating reverse proxy the
|
|
// socket peer is the proxy, so without this every caller shares a single
|
|
// limit bucket and the whole fleet gets throttled together. Only honour
|
|
// X-Forwarded-For from proxies we explicitly trust — otherwise any client
|
|
// could spoof the header to dodge the limit entirely.
|
|
if cfg.TrustedProxies != "" {
|
|
proxies := strings.Split(cfg.TrustedProxies, ",")
|
|
for i := range proxies {
|
|
proxies[i] = strings.TrimSpace(proxies[i])
|
|
}
|
|
fiberCfg.EnableTrustedProxyCheck = true
|
|
fiberCfg.TrustedProxies = proxies
|
|
fiberCfg.ProxyHeader = fiber.HeaderXForwardedFor
|
|
utils.Info("Trusting X-Forwarded-For from configured proxies", "proxies", proxies)
|
|
} else {
|
|
utils.Warn("TRUSTED_PROXIES is unset — rate limits key on the socket peer address. " +
|
|
"If this service runs behind a reverse proxy, set TRUSTED_PROXIES or all clients will share one limit bucket.")
|
|
}
|
|
|
|
app := fiber.New(fiberCfg)
|
|
|
|
// Panic recovery — must be the outermost middleware so it also catches
|
|
// panics raised inside the ones registered below. Without this a single
|
|
// nil-pointer dereference in any handler takes the whole process down.
|
|
app.Use(recover.New(recover.Config{EnableStackTrace: true}))
|
|
|
|
// CORS policy
|
|
app.Use(cors.New(cors.Config{
|
|
AllowHeaders: "Origin,Content-Type,Accept,Authorization",
|
|
AllowOrigins: "http://localhost:5173,http://localhost:5174,http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:8080,http://localhost:8081,https://doormile.com,https://www.doormile.com,https://admin.doormile.com,https://api.doormile.com,https://crm.doormile.com,https://console.doormile.com,https://app.doormile.com,https://hub.doormile.com",
|
|
AllowCredentials: true,
|
|
AllowMethods: "GET,POST,PUT,DELETE,PATCH,OPTIONS",
|
|
}))
|
|
|
|
// Structured Zap logger middleware
|
|
app.Use(middlewares.ZapLogger())
|
|
|
|
// Global per-IP rate limit. Deliberately generous — this is an abuse
|
|
// backstop, not a quota. Auth endpoints get a much tighter limit of their
|
|
// own in routes.go. Health probes and websocket upgrades are exempt so
|
|
// orchestrator checks and long-lived tracking sockets are never throttled.
|
|
app.Use(limiter.New(limiter.Config{
|
|
Max: 300,
|
|
Expiration: 1 * time.Minute,
|
|
Next: func(c *fiber.Ctx) bool {
|
|
p := c.Path()
|
|
return strings.HasSuffix(p, "/health") ||
|
|
strings.HasSuffix(p, "/ready") ||
|
|
strings.HasPrefix(p, "/ws/")
|
|
},
|
|
LimitReached: func(c *fiber.Ctx) error {
|
|
return c.Status(fiber.StatusTooManyRequests).
|
|
JSON(fiber.Map{"success": false, "message": "too many requests, please slow down"})
|
|
},
|
|
}))
|
|
|
|
// 5. Register routes
|
|
routes.RegisterRoutes(app, cfg)
|
|
|
|
// 6. Pre-warm Redis pricing cache from Postgres
|
|
controllers.WarmPricingCache()
|
|
|
|
// 7. Start NATS booking worker in background
|
|
go worker.StartBookingWorker()
|
|
|
|
// 8. Start the assignment worker. Every replica runs one; they share a
|
|
// durable consumer, so JetStream hands each booking to exactly one of them.
|
|
go assignment.StartAssignmentWorker()
|
|
|
|
// 9. Point the stop sequencer at the Route Optimization API.
|
|
routing.BaseURL = cfg.RouteOptimizerURL
|
|
|
|
// 7. Startup server in a background thread
|
|
go func() {
|
|
utils.Info("Server starting", "port", cfg.Port)
|
|
if err := app.Listen(":" + cfg.Port); err != nil {
|
|
utils.Logger.Fatalf("Server failed to bind: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Graceful shutdown listener
|
|
gracefulShutdown(app)
|
|
}
|
|
|
|
func gracefulShutdown(app *fiber.App) {
|
|
c := make(chan os.Signal, 1)
|
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
|
|
|
<-c
|
|
utils.Info("Shutting down Doormile Backend...")
|
|
|
|
// Shutdown fiber
|
|
if err := app.Shutdown(); err != nil {
|
|
utils.Error("Fiber shutdown failed", "error", err)
|
|
}
|
|
|
|
// Close database pools
|
|
db.CloseDB()
|
|
|
|
time.Sleep(1 * time.Second)
|
|
utils.Info("Shutdown completed cleanly.")
|
|
}
|