Files
doormile_backend/routes/routes.go
Suriyakumarvijayanayagam f09efcaf59 feat: express-batch dispatch — manual trigger for the AI agent
Adds the backend half of the ExpressDispatchAgent flow. Express orders can now
be created batch by batch (bulk create only accumulates them, unassigned), then
an operator hits one endpoint to hand the whole pending set to the agent for
tenant-scoped assignment + road sequencing. The normal B2C flow is untouched.

- POST /admin/expressbooking/dispatch: manual trigger. Console-auth, tenant-
  scoped; gathers the tenant's pending unassigned express orders (or a chosen
  subset) and publishes express.dispatch_requested.
- internal API for the agent: GET /internal/express/riders (tenant's available
  riders), GET /internal/express/bookings, POST /internal/express/assign (writes
  the agent's decided assignments with their sequence; re-checks the already-
  assigned guard so the agent can't double-assign).
- booking_assignment_service.go: extracted a behavior-preserving assignMilerTx
  core; AssignMilerToBooking is unchanged in behavior. assignExpressStops writes
  a batch, one FCM per rider instead of one per stop.
- EXPRESS JetStream stream / express.dispatch_requested subject.
- Gated behind EXPRESS_AGENT_ENABLED (default off): deploying this changes
  nothing until the agent is confirmed running and the flag is flipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 19:33:48 +05:30

443 lines
20 KiB
Go

package routes
import (
"context"
"time"
"doormile/config"
"doormile/controllers"
"doormile/db"
"doormile/internal/ws"
"doormile/middlewares"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gofiber/websocket/v2"
)
// authLimiter throttles credential-checking endpoints per IP. Miler and
// customer PINs are 4 digits — only 10,000 combinations — so without this an
// attacker can walk the whole keyspace in seconds. 10/minute keeps a genuine
// user's retries and typos working while making enumeration impractical.
func authLimiter() fiber.Handler {
return limiter.New(limiter.Config{
Max: 10,
Expiration: 1 * time.Minute,
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).
JSON(fiber.Map{"success": false, "message": "too many attempts, please try again in a minute"})
},
})
}
func RegisterRoutes(app *fiber.App, cfg *config.Config) {
api := app.Group("/api/v1")
// One shared instance, so the 10/minute budget is spent across all
// credential endpoints combined — an attacker can't reset it by rotating
// between /login, /verify-pin and /reset-pin.
authThrottle := authLimiter()
// --------------------
// HEALTH & READINESS PROBES
// --------------------
api.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "OK", "time": time.Now()})
})
api.Get("/ready", func(c *fiber.Ctx) error {
dbStatus := "connected"
sqlDB, err := db.DB.DB()
if err != nil || sqlDB.Ping() != nil {
dbStatus = "unavailable"
}
redisStatus := "connected"
if db.Rdb == nil {
redisStatus = "unavailable"
} else {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if _, pingErr := db.Rdb.Ping(ctx).Result(); pingErr != nil {
redisStatus = "unavailable"
}
}
status := fiber.StatusOK
if dbStatus == "unavailable" || redisStatus == "unavailable" {
status = fiber.StatusServiceUnavailable
}
return c.Status(status).JSON(fiber.Map{
"status": "ready",
"checks": fiber.Map{
"postgres": dbStatus,
"redis": redisStatus,
},
})
})
// --------------------
// CUSTOMER APIS
// --------------------
customer := api.Group("/customer")
customer.Post("/register", controllers.RegisterCustomer(cfg))
customer.Post("/login", authThrottle, controllers.LoginCustomer)
customer.Post("/verify-pin", authThrottle, controllers.VerifyCustomerPin(cfg))
customer.Post("/reset-pin", authThrottle, controllers.ResetCustomerPin)
customer.Post("/send-email-otp", authThrottle, controllers.SendCustomerEmailOtp(cfg))
customer.Post("/verify-email-otp", authThrottle, controllers.VerifyCustomerEmailOtp())
// Authenticated Customer App routes
customerAuth := customer.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(9))
customerAuth.Get("/profile", controllers.GetCustomerProfile)
customerAuth.Put("/profile", controllers.UpdateCustomerProfile)
customerAuth.Get("/locations", controllers.GetCustomerLocations)
customerAuth.Post("/locations", controllers.CreateCustomerLocation)
customerAuth.Put("/locations/:id", controllers.UpdateCustomerLocation)
customerAuth.Delete("/locations/:id", controllers.DeleteCustomerLocation)
customerAuth.Put("/device-token", controllers.SaveCustomerDeviceToken)
customerAuth.Post("/bookings", middlewares.CityGateMiddleware, controllers.CreateCustomerBooking)
customerAuth.Get("/bookings", controllers.GetCustomerBookings)
customerAuth.Get("/bookings/:bookingid", controllers.GetCustomerBookingDetails)
customerAuth.Post("/bookings/:bookingid/cancel", controllers.CancelCustomerBooking)
customerAuth.Get("/bookings/:bookingid/price", controllers.GetCustomerBookingQuote)
customerAuth.Get("/track/:trackingno", controllers.TrackConsignment)
// --------------------
// MILER APIS
// --------------------
miler := api.Group("/miler")
miler.Post("/login", authThrottle, controllers.LoginMiler(cfg))
miler.Post("/verify-pin", authThrottle, controllers.VerifyMilerPin(cfg))
// PIN reset is console-operated, NOT self-service: ResetMilerPin overwrites
// the PIN given only a phone number, and phone numbers are the miler login
// identifier rather than a secret. Left unauthenticated, two calls
// (reset-pin then verify-pin) take over any miler account. Ops resets a
// rider's PIN on request instead, so this carries admin/manager/executive
// auth even though it sits under the /miler prefix.
miler.Post("/reset-pin", authThrottle,
middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(1, 3, 4),
controllers.ResetMilerPin)
// Authenticated Miler App routes
milerAuth := miler.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(5))
milerAuth.Get("/profile", controllers.GetMilerProfile)
milerAuth.Put("/profile", controllers.UpdateMilerProfile)
milerAuth.Put("/device-token", controllers.SaveMilerDeviceToken)
milerAuth.Put("/location", controllers.UpdateMilerLocation)
milerAuth.Put("/availability", controllers.UpdateMilerAvailability)
milerAuth.Get("/assignments", controllers.GetMilerAssignments)
milerAuth.Get("/assignments/:id", controllers.GetMilerAssignmentDetails)
milerAuth.Post("/assignments/:id/accept", controllers.AcceptMilerAssignment)
milerAuth.Post("/assignments/:id/reject", controllers.RejectMilerAssignment)
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)
milerAuth.Post("/bookings/:bookingid/vehicle-required", controllers.BookingVehicleRequiredEscalate)
milerAuth.Post("/bookings/:bookingid/cancel", controllers.MilerCancelAssignment)
// Redis periodic telemetry and status logs
milerAuth.Post("/logs", controllers.CreateMilerPeriodicLog)
milerAuth.Get("/logs", controllers.GetMilerPeriodicLogs)
milerAuth.Post("/status", controllers.CreateMilerStatus)
milerAuth.Get("/status", controllers.GetMilerStatus)
// Redis consignment tracking and telemetry logs
milerAuth.Post("/consignments/logs", controllers.PublishConsignmentLogs)
milerAuth.Get("/consignments/logs/:consignmentid", controllers.GetConsignmentLogs)
milerAuth.Get("/consignments/userlogs/:userid", controllers.GetUserConsignmentLogs)
// Duty management
milerAuth.Post("/duty/start", controllers.MilerStartDuty)
milerAuth.Put("/duty/end", controllers.MilerEndDuty)
milerAuth.Get("/duty/current", controllers.MilerGetDutyStatus)
// Break management
milerAuth.Post("/breaks/start", controllers.MilerStartBreak)
milerAuth.Put("/breaks/end", controllers.MilerEndBreak)
// Miler's own bookings
milerAuth.Get("/bookings", controllers.MilerGetMyBookings)
// Delivery confirmation
milerAuth.Post("/consignments/:id/deliver", controllers.MilerDeliverConsignment)
milerAuth.Post("/consignments/:id/skip", controllers.MilerSkipDelivery)
// Earnings
milerAuth.Get("/earnings", controllers.MilerGetEarnings)
// Notifications
milerAuth.Get("/notifications", controllers.MilerGetNotifications)
milerAuth.Patch("/notifications/:id/read", controllers.MilerMarkNotificationRead)
// Support
milerAuth.Post("/support", controllers.MilerCreateTicket)
milerAuth.Get("/support", controllers.MilerGetTickets)
// --------------------
// ADMIN CONSOLE APIS — all open, no token required
// --------------------
admin := api.Group("/admin")
admin.Post("/login", authThrottle, controllers.LoginAdmin(cfg))
// Authenticated Admin Console routes
adminAuth := admin.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(1, 3, 4))
adminAuth.Get("/dashboard", controllers.GetAdminDashboard)
adminAuth.Get("/reports", controllers.GetAdminReports)
adminAuth.Get("/profile", controllers.GetAdminProfile)
adminAuth.Get("/me", controllers.GetAdminProfile)
adminAuth.Put("/profile/password", controllers.AdminChangePassword)
// App Users Management
adminAuth.Get("/users", controllers.GetAppUsers)
adminAuth.Post("/users", controllers.CreateAppUser)
adminAuth.Put("/users/:id", controllers.UpdateAppUser)
adminAuth.Delete("/users/:id", controllers.DeleteAppUser)
// Partner management (fleet/rider suppliers — distinct from tenants,
// which are the client companies Doormile delivers for)
adminAuth.Get("/partners", controllers.GetPartners)
adminAuth.Post("/partners", controllers.CreatePartner)
adminAuth.Get("/partners/:id", controllers.GetPartnerDetails)
adminAuth.Put("/partners/:id", controllers.UpdatePartner)
adminAuth.Delete("/partners/:id", controllers.DeletePartner)
// Tenant management
adminAuth.Get("/tenants", controllers.GetTenants)
adminAuth.Post("/tenants", controllers.CreateTenant)
adminAuth.Get("/tenants/:id", controllers.GetTenantDetails)
adminAuth.Put("/tenants/:id", controllers.UpdateTenant)
adminAuth.Delete("/tenants/:id", controllers.DeleteTenant)
adminAuth.Get("/tenants/:id/locations", controllers.GetTenantLocations)
adminAuth.Post("/tenants/:id/locations", controllers.CreateTenantLocation)
adminAuth.Put("/tenantlocations/:id", controllers.UpdateTenantLocation)
// Per-site performance — jupiter's getlocationsummary. Needs ?tenantid= for
// Doormile staff; a client login is already pinned to its own sites.
adminAuth.Get("/locations/summary", controllers.GetLocationSummary)
// Tenant customers
adminAuth.Get("/tenantcustomers", controllers.GetTenantCustomers)
adminAuth.Post("/tenantcustomers", controllers.CreateTenantCustomer)
adminAuth.Get("/tenantcustomers/:id", controllers.GetTenantCustomerDetails)
adminAuth.Put("/tenantcustomers/:id", controllers.UpdateTenantCustomer)
adminAuth.Delete("/tenantcustomers/:id", controllers.DeleteTenantCustomer)
// B2C App customers
adminAuth.Get("/customers", controllers.GetAdminCustomers)
adminAuth.Get("/customers/summary", controllers.GetAdminCustomersSummary)
adminAuth.Patch("/customers/:id", controllers.UpdateAdminCustomer)
// Hubs
adminAuth.Get("/hubs", controllers.GetHubs)
adminAuth.Post("/hubs", controllers.CreateHub)
adminAuth.Get("/hubs/:id", controllers.GetHubDetails)
adminAuth.Put("/hubs/:id", controllers.UpdateHub)
adminAuth.Delete("/hubs/:id", controllers.DeleteHub)
// Vehicles
adminAuth.Get("/vehicles", controllers.GetVehicles)
adminAuth.Post("/vehicles", controllers.CreateVehicle)
adminAuth.Get("/vehicles/:id", controllers.GetVehicleDetails)
adminAuth.Put("/vehicles/:id", controllers.UpdateVehicle)
adminAuth.Delete("/vehicles/:id", controllers.DeleteVehicle)
// Milers
adminAuth.Get("/milers", controllers.GetMilers)
// Registered before /milers/:id — Fiber matches in registration order, so
// the literal path has to come first or ":id" swallows "summary".
adminAuth.Get("/milers/summary", controllers.GetMilerSummary)
adminAuth.Post("/milers", controllers.CreateMiler)
adminAuth.Get("/milers/:id", controllers.GetMilerDetails)
adminAuth.Get("/milers/:id/logs", controllers.GetMilerLogs)
adminAuth.Get("/milers/:id/activity", controllers.GetMilerActivity)
adminAuth.Put("/milers/:id", controllers.UpdateMiler)
adminAuth.Put("/milers/:id/block", controllers.BlockMiler)
adminAuth.Put("/milers/:id/assign-vehicle", controllers.AssignMilerVehicle)
adminAuth.Post("/milers/:id/notify", controllers.AdminNotifyMiler)
// Bookings
adminAuth.Get("/bookings", controllers.GetAdminBookings)
adminAuth.Post("/expressbooking", middlewares.CityGateMiddleware, controllers.CreateExpressBooking)
adminAuth.Post("/expressbooking/bulk", middlewares.CityGateMiddleware, controllers.AdminBulkCreateBookings)
// Manual trigger: hand all pending express orders for the tenant to the
// ExpressDispatchAgent (assign + road-sequence). Operator-controlled, after
// orders have accumulated batch by batch.
adminAuth.Post("/expressbooking/dispatch", controllers.DispatchExpressBatch)
adminAuth.Get("/bookings/:id", controllers.GetAdminBookingDetails)
adminAuth.Get("/bookings/:id/track", controllers.GetAdminBookingTrack)
adminAuth.Post("/bookings/:id/assign-miler", controllers.AdminAssignMiler)
adminAuth.Post("/bookings/:id/assign-vehicle", controllers.AdminAssignVehicle)
adminAuth.Put("/bookings/:id/status", controllers.AdminUpdateBookingStatus)
adminAuth.Post("/bookings/:id/cancel", controllers.AdminCancelBooking)
adminAuth.Post("/bookings/bulk-cancel", controllers.AdminBulkCancelBookings)
// Consignments
adminAuth.Get("/consignments", controllers.GetAdminConsignments)
adminAuth.Get("/consignments/:id", controllers.GetAdminConsignmentDetails)
adminAuth.Get("/consignments/:id/logs", controllers.GetAdminConsignmentLogs)
adminAuth.Get("/consignments/track/:trackingno", controllers.GetAdminConsignmentTracking)
adminAuth.Put("/consignments/:id/status", controllers.AdminUpdateConsignmentStatus)
// Tripsheets
adminAuth.Get("/tripsheets", controllers.GetTripsheets)
adminAuth.Post("/tripsheets", controllers.CreateTripsheet)
adminAuth.Get("/tripsheets/:id", controllers.GetTripsheetDetails)
adminAuth.Post("/tripsheets/:id/items", controllers.AddTripsheetItem)
adminAuth.Delete("/tripsheets/:id/items/:itemid", controllers.DeleteTripsheetItem)
adminAuth.Put("/tripsheets/:id/dispatch", controllers.DispatchTripsheet)
adminAuth.Put("/tripsheets/:id/arrive", controllers.ArriveTripsheet)
// Competitor branch survey data (from Enquiry.xlsx Sheet 1)
adminAuth.Get("/competitor-branches", controllers.GetCompetitorBranches)
adminAuth.Post("/competitor-branches", controllers.CreateCompetitorBranch)
adminAuth.Put("/competitor-branches/:id", controllers.UpdateCompetitorBranch)
adminAuth.Delete("/competitor-branches/:id", controllers.DeleteCompetitorBranch)
// Carrier pricing benchmarks (from Enquiry.xlsx Sheet 2)
adminAuth.Get("/carrier-pricing", controllers.GetCarrierPricing)
adminAuth.Post("/carrier-pricing", controllers.CreateCarrierPricing)
adminAuth.Put("/carrier-pricing/:id", controllers.UpdateCarrierPricing)
adminAuth.Delete("/carrier-pricing/:id", controllers.DeleteCarrierPricing)
// Pricing
adminAuth.Get("/pricing", controllers.GetPricing)
adminAuth.Post("/pricing", controllers.CreatePricing)
adminAuth.Put("/pricing/:id", controllers.UpdatePricing)
adminAuth.Delete("/pricing/:id", controllers.DeletePricing)
adminAuth.Post("/pricing/simulate", controllers.GetPricingQuoteSimulate)
adminAuth.Post("/pricing/quote", controllers.GetPricingQuoteSimulate)
// Doormile pricing bands
adminAuth.Get("/doormile-pricing", controllers.GetDoormilePricing)
adminAuth.Post("/doormile-pricing", controllers.CreateDoormilePricing)
adminAuth.Put("/doormile-pricing/:id", controllers.UpdateDoormilePricing)
adminAuth.Delete("/doormile-pricing/:id", controllers.DeleteDoormilePricing)
// Exceptions
adminAuth.Get("/exceptions", controllers.GetExceptions)
adminAuth.Post("/exceptions", controllers.CreateException)
adminAuth.Get("/exceptions/:id", controllers.GetExceptionDetails)
adminAuth.Put("/exceptions/:id/status", controllers.ResolveException)
// --------------------
// HUB CONSOLE APIS
// --------------------
hub := api.Group("/hub")
hub.Post("/login", authThrottle, controllers.HubStaffLogin(cfg))
// Authenticated Hub Console routes (role 6)
hubAuth := hub.Use(middlewares.HubStaffAuth(cfg))
hubAuth.Get("/dashboard", controllers.GetHubDashboardStats)
hubAuth.Get("/bookings/unassigned", controllers.GetHubUnassignedBookings)
hubAuth.Get("/bookings", controllers.GetHubBookingsRange)
hubAuth.Get("/inbound/today", controllers.GetHubInboundToday)
hubAuth.Get("/inbound", controllers.GetHubInboundRange)
hubAuth.Post("/bookings/:id/inbound", controllers.CreateInboundScan)
hubAuth.Post("/bookings/:id/assign-miler", controllers.HubAssignMiler)
hubAuth.Post("/bookings/:id/auto-assign", controllers.HubAutoAssign)
hubAuth.Post("/bookings/batch-assign", controllers.HubBatchAssign)
hubAuth.Get("/batches", controllers.GetHubBatches)
hubAuth.Post("/batches", controllers.CreateHubBatch)
hubAuth.Patch("/batches/:id/status", controllers.UpdateBatchStatus)
hubAuth.Get("/milers", controllers.GetHubMilers)
// Hub management — enforced Doormile-staff-only inside each handler
hubAuth.Post("/staff", controllers.CreateHubStaffAccount)
hubAuth.Get("/hubs", controllers.GetHubsInCity)
hubAuth.Post("/hubs", controllers.CreateCityHub)
hubAuth.Get("/tripsheets/in-transit", controllers.GetInTransitTripsheets)
hubAuth.Get("/inbound/vehicles", controllers.GetInboundVehicles)
hubAuth.Get("/activity", controllers.GetHubActivity)
hubAuth.Get("/zones", controllers.GetHubZones)
hubAuth.Get("/notifications", controllers.GetHubNotifications)
hubAuth.Patch("/notifications/:id/read", controllers.MarkNotificationRead)
hubAuth.Get("/routing/:trackingno", controllers.GetRoutingInfo)
hubAuth.Get("/rider-routes", controllers.GetAllRiderRoutes)
hubAuth.Get("/milers/:id/route", controllers.GetMilerRoute)
hubAuth.Get("/messages", controllers.GetHubMessages)
hubAuth.Get("/messages/:id", controllers.GetHubMessageThread)
hubAuth.Post("/messages/:id", controllers.SendHubMessage)
hubAuth.Patch("/messages/:id/read", controllers.MarkHubMessagesRead)
hubAuth.Get("/report", controllers.GetHubReport)
// Redis active user caching utilities — console/ops only. Open CRUD on a
// user cache with no authentication has no legitimate anonymous caller;
// the store is currently empty, so closing it breaks nothing.
redisUsers := api.Group("/utils/users/redis",
middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(1, 3, 4))
redisUsers.Post("/", controllers.CreateUserRedis)
redisUsers.Get("/", controllers.GetUserRedis)
redisUsers.Put("/:userid", controllers.UpdateUserRedis)
redisUsers.Delete("/:userid", controllers.DeleteUserRedis)
// --------------------
// DOORMILE PRICING — public check & meta (no auth)
// --------------------
api.Get("/pricing/meta", controllers.GetPricingMeta)
api.Post("/pricing/check", controllers.CheckPrice)
// --------------------
// BOOKING CACHE APIS — console/ops only
// --------------------
// Previously open "for testing", but live: listing the cache returns real
// bookings including customer delivery addresses, and the per-customer route
// takes a customer id straight from the URL. Behind console auth now.
bookingCache := api.Group("/bookings/cache",
middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(1, 3, 4))
bookingCache.Get("/", controllers.ListAllBookingsFromCache)
bookingCache.Get("/customer/:customer_id", controllers.GetCustomerBookingsFromCache)
bookingCache.Get("/:booking_id", controllers.GetBookingFromCache)
// --------------------
// CRM APIS — all open (field reps register from Flutter; web console reads without a separate CRM login)
// --------------------
crm := api.Group("/crm")
crm.Post("/clients", controllers.RegisterClient)
crm.Get("/clients", controllers.GetClients)
crm.Get("/clients/:id", controllers.GetClientDetails)
crm.Put("/clients/:id", controllers.UpdateClient)
crm.Delete("/clients/:id", controllers.DeleteClient)
// --------------------
// INTERNAL — machine-to-machine endpoints (API key auth, no JWT)
// --------------------
internal := api.Group("/internal", middlewares.InternalKeyAuth)
internal.Post("/notify", controllers.InternalNotify)
internal.Post("/bookings/:id/reassign", controllers.InternalReassign)
internal.Post("/agent-decisions", controllers.CreateAgentDecision)
internal.Get("/agent-decisions/similar", controllers.FindSimilarDecisions)
internal.Patch("/agent-decisions/:id/outcome", controllers.UpdateDecisionOutcome)
// Express-batch dispatch: the ExpressDispatchAgent reads a tenant's riders
// and the batch's bookings, then writes back the assignments it decided.
internal.Get("/express/riders", controllers.GetExpressRiders)
internal.Get("/express/bookings", controllers.GetExpressBookings)
internal.Post("/express/assign", controllers.AssignExpressBatch)
// --------------------
// WEBSOCKET — live miler tracking (no auth, public tracking link)
// --------------------
app.Use("/ws", func(c *fiber.Ctx) error {
if websocket.IsWebSocketUpgrade(c) {
return c.Next()
}
return fiber.ErrUpgradeRequired
})
app.Get("/ws/bookings/:bookingid/track", websocket.New(ws.TrackingHandler))
app.Get("/ws/bookings/:bookingid/chat", middlewares.WsChatAuth(cfg), websocket.New(ws.ChatHandler))
}