Files
doormile_backend/routes/routes.go
Suriya c272a33fa6 feat: 14 new endpoints closing jupiter->Doormile API gaps, plus two tenant-scoping bug fixes
New endpoints:
- Admin: partner CRUD (GET/POST /admin/partners, GET/PUT/DELETE
  /admin/partners/:id), bulk express booking create
  (POST /admin/expressbooking/bulk), bulk cancel
  (POST /admin/bookings/bulk-cancel), reports (GET /admin/reports),
  password change (PUT /admin/profile/password), miler notify
  (POST /admin/milers/:id/notify)
- Miler: PIN reset (POST /miler/reset-pin), cancel assignment
  (POST /miler/bookings/:bookingid/cancel), skip delivery
  (POST /miler/consignments/:id/skip)
- Hub: batch assign (POST /hub/bookings/batch-assign) - greedy
  nearest-rider queue clearing, capped per rider

Bug fixes:
- BookingPickupComplete now sets Consignment.Tenantid from the
  booking's tenant instead of the completing miler's own tenant
  (fixes cross-tenant shipment mis-attribution)
- GetHubUnassignedBookings/GetHubBookingsRange now scoped via
  scopeBookingsToOwnTenant (fixes partner hub staff seeing other
  tenants' bookings)

Also: CRM booking routes renamed to expressbooking to end the naming
collision with the separate CRM clients feature; PickupBooking gains
nullable Tenantid; adds CLAUDE.md project memory.

Verified: go build ./... and go vet ./... both clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 12:22:39 +05:30

407 lines
18 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))
miler.Post("/reset-pin", authThrottle, 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)
// 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.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)
adminAuth.Post("/milers", controllers.CreateMiler)
adminAuth.Get("/milers/:id", controllers.GetMilerDetails)
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)
adminAuth.Get("/bookings/:id", controllers.GetAdminBookingDetails)
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/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
redisUsers := api.Group("/utils/users/redis")
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 — no auth required (testing)
// --------------------
bookingCache := api.Group("/bookings/cache")
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)
// --------------------
// 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))
}