From c8adaf781524443375135275eaaded21dc9a0ee3 Mon Sep 17 00:00:00 2001 From: Suriya Date: Sat, 4 Jul 2026 11:10:52 +0530 Subject: [PATCH] hub apis --- .env | 3 + CUsersAdminAppDataLocalTempclaudeserver.log | 37 ++ Dockerfile | 2 +- config/config.go | 62 +- constants/constants.go | 1 + controllers/adminController.go | 4 + controllers/hubController.go | 593 +++++++++++++++++++ internal/assignment/ai_layer.go | 330 +++++++++++ internal/assignment/crm_assignment.go | 61 +- internal/assignment/customer_assignment.go | 13 +- middlewares/auth.go | 44 ++ migrations/migrate.go | 12 + models/audit.go | 2 + models/booking.go | 1 + models/transit.go | 5 +- models/users.go | 19 + routes/routes.go | 22 + utils/helper.go | 21 + 18 files changed, 1140 insertions(+), 92 deletions(-) create mode 100644 CUsersAdminAppDataLocalTempclaudeserver.log create mode 100644 controllers/hubController.go create mode 100644 internal/assignment/ai_layer.go diff --git a/.env b/.env index 5c5631e..2dc260f 100644 --- a/.env +++ b/.env @@ -16,3 +16,6 @@ REDIS_HOST=31.97.228.132 REDIS_PORT=6379 REDIS_USER=admin REDIS_PASSWORD=Package@321# + +# AI Decision Engine +AI_LAYER_BASE_URL=https://routemate.workolik.com diff --git a/CUsersAdminAppDataLocalTempclaudeserver.log b/CUsersAdminAppDataLocalTempclaudeserver.log new file mode 100644 index 0000000..ee11e1a --- /dev/null +++ b/CUsersAdminAppDataLocalTempclaudeserver.log @@ -0,0 +1,37 @@ +{"level":"INFO","time":"2026-06-30T15:23:23+05:30","caller":"utils/logger.go:24","msg":"Starting Doormile Backend..."} +{"level":"INFO","time":"2026-06-30T15:23:23+05:30","caller":"utils/logger.go:24","msg":"Connecting to database","attempt":1,"host":"31.97.228.132","port":"5433"} +{"level":"INFO","time":"2026-06-30T15:23:24+05:30","caller":"utils/logger.go:24","msg":"✅ Database connected successfully"} +redis: 2026/06/30 15:23:24 redis.go:478: auto mode fallback: maintnotifications disabled due to handshake error: ERR unknown subcommand 'maint_notifications'. Try CLIENT HELP. +{"level":"INFO","time":"2026-06-30T15:23:24+05:30","caller":"utils/logger.go:24","msg":"✅ Redis connected successfully","addr":"31.97.228.132:6379"} +{"level":"INFO","time":"2026-06-30T15:23:24+05:30","caller":"utils/logger.go:24","msg":"✅ NATS JetStream connected successfully","url":"nats://66.116.226.161:4223"} +{"level":"WARN","time":"2026-06-30T15:23:24+05:30","caller":"utils/logger.go:36","msg":"FCM: FIREBASE_SERVICE_ACCOUNT_PATH not set — push notifications disabled"} +{"level":"INFO","time":"2026-06-30T15:23:24+05:30","caller":"utils/logger.go:24","msg":"Starting database auto-migrations for 21 logistics tables..."} +{"level":"INFO","time":"2026-06-30T15:23:45+05:30","caller":"utils/logger.go:24","msg":"✅ Database migration completed successfully!"} +{"level":"INFO","time":"2026-06-30T15:23:45+05:30","caller":"utils/logger.go:24","msg":"✅ context_embedding vector column ready"} +{"level":"INFO","time":"2026-06-30T15:23:45+05:30","caller":"utils/logger.go:24","msg":"✅ ivfflat index on context_embedding ready"} +{"level":"INFO","time":"2026-06-30T15:23:45+05:30","caller":"utils/logger.go:24","msg":"WarmPricingCache: warming pricing cache from Postgres..."} +{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"Pricing cache warmed","zone":"Local","servicetype":"Normal","count":10} +{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"Pricing cache warmed","zone":"Local","servicetype":"Express","count":9} +{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"Pricing cache warmed","zone":"Regional","servicetype":"Normal","count":9} +{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"Pricing cache warmed","zone":"Regional","servicetype":"Express","count":9} +{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"Pricing cache warmed","zone":"National","servicetype":"Normal","count":9} +{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"Pricing cache warmed","zone":"National","servicetype":"Express","count":9} +{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"WarmPricingCache: done","slabs_warmed":6} +{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"Server starting","port":"8081"} + + ┌───────────────────────────────────────────────────┐ + │ Doormile Logistics Service API v1 │ + │ Fiber v2.52.10 │ + │ http://127.0.0.1:8081 │ + │ (bound on host 0.0.0.0 and port 8081) │ + │ │ + │ Handlers ........... 206 Processes ........... 1 │ + │ Prefork ....... Disabled PID .............. 9448 │ + └───────────────────────────────────────────────────┘ + +{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"BookingWorker: subscribed","subject":"api.v1.bookings.create","consumer":"bookings_create"} +{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"BookingWorker: subscribed","subject":"api.v1.bookings.update","consumer":"bookings_update"} +{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"BookingWorker: subscribed","subject":"api.v1.bookings.cancel","consumer":"bookings_cancel"} +{"level":"INFO","time":"2026-06-30T15:23:46+05:30","caller":"utils/logger.go:24","msg":"BookingWorker: running, waiting for messages..."} +{"level":"INFO","time":"2026-06-30T15:26:06+05:30","caller":"utils/logger.go:24","msg":"API request success","method":"GET","path":"/api/v1/health","status":200,"latency":0} +exit status 0xffffffff diff --git a/Dockerfile b/Dockerfile index 28753bc..1153667 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ FROM golang:1.25 RUN mkdir /app ADD . /app/ WORKDIR /app -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server . +RUN CGO_ENABLED=0 GOOS=linux go build -o server . # Second Stage: Run the compiled binary inside alpine FROM alpine diff --git a/config/config.go b/config/config.go index c639571..3b07185 100644 --- a/config/config.go +++ b/config/config.go @@ -5,40 +5,42 @@ import ( ) type Config struct { - Env string - Port string - DBName string - DBUser string - DBPassword string - DBPort string - DBHost string - RedisHost string - RedisPort string - RedisUser string - RedisPassword string - JWTSecret string - NatsURL string - NatsUser string - NatsPassword string + Env string + Port string + DBName string + DBUser string + DBPassword string + DBPort string + DBHost string + RedisHost string + RedisPort string + RedisUser string + RedisPassword string + JWTSecret string + NatsURL string + NatsUser string + NatsPassword string + AILayerBaseURL string // AI decision-engine service base URL (e.g. http://rider-api:8082) } func Load() *Config { return &Config{ - Env: getEnv("ENV", "development"), - Port: getEnv("APP_PORT", "8081"), - DBName: getEnv("DB_NAME", "logistics"), - DBUser: getEnv("DB_USER", "admin"), - DBPassword: getEnv("DB_PASSWORD", "Package@321#"), - DBPort: getEnv("DB_PORT", "5433"), - DBHost: getEnv("DB_HOST", "127.0.0.1"), - RedisHost: getEnv("REDIS_HOST", "127.0.0.1"), - RedisPort: getEnv("REDIS_PORT", "6379"), - RedisUser: getEnv("REDIS_USER", ""), - RedisPassword: getEnv("REDIS_PASSWORD", ""), - JWTSecret: getEnv("JWT_SECRET_KEY", "DoormileSuperSecretJWTKey2026!"), - NatsURL: getEnv("NATS_URL", "nats://66.116.226.161:4223"), - NatsUser: getEnv("NATS_USER", "doormile"), - NatsPassword: getEnv("NATS_PASSWORD", "Package@321#"), + Env: getEnv("ENV", "development"), + Port: getEnv("APP_PORT", "8081"), + DBName: getEnv("DB_NAME", "logistics"), + DBUser: getEnv("DB_USER", "admin"), + DBPassword: getEnv("DB_PASSWORD", "Package@321#"), + DBPort: getEnv("DB_PORT", "5433"), + DBHost: getEnv("DB_HOST", "127.0.0.1"), + RedisHost: getEnv("REDIS_HOST", "127.0.0.1"), + RedisPort: getEnv("REDIS_PORT", "6379"), + RedisUser: getEnv("REDIS_USER", ""), + RedisPassword: getEnv("REDIS_PASSWORD", ""), + JWTSecret: getEnv("JWT_SECRET_KEY", "DoormileSuperSecretJWTKey2026!"), + NatsURL: getEnv("NATS_URL", "nats://66.116.226.161:4223"), + NatsUser: getEnv("NATS_USER", "doormile"), + NatsPassword: getEnv("NATS_PASSWORD", "Package@321#"), + AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"), } } diff --git a/constants/constants.go b/constants/constants.go index 8c8f4dd..db2935a 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -67,6 +67,7 @@ const ( // Tripsheet Statuses const ( TripsheetDraft = "Draft" + TripsheetReady = "Ready" TripsheetDispatched = "Dispatched" TripsheetArrived = "Arrived" TripsheetCancelled = "Cancelled" diff --git a/controllers/adminController.go b/controllers/adminController.go index d8bc58b..03ebd2a 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -818,6 +818,7 @@ func UpdateMiler(c *fiber.Ctx) error { type MilerUpdate struct { Displayname string `json:"displayname"` Defaultvehicletype string `json:"defaultvehicletype"` + Hubid *int `json:"hubid"` } req := new(MilerUpdate) @@ -831,6 +832,9 @@ func UpdateMiler(c *fiber.Ctx) error { if req.Defaultvehicletype != "" { profile.Defaultvehicletype = req.Defaultvehicletype } + if req.Hubid != nil { + profile.Hubid = req.Hubid + } profile.Updatedat = time.Now() if err := db.DB.Save(&profile).Error; err != nil { diff --git a/controllers/hubController.go b/controllers/hubController.go new file mode 100644 index 0000000..091ba91 --- /dev/null +++ b/controllers/hubController.go @@ -0,0 +1,593 @@ +package controllers + +import ( + "fmt" + "strconv" + "strings" + "time" + + "doormile/config" + "doormile/constants" + "doormile/db" + "doormile/dto" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" +) + +// todayMidnight returns the start of the current day in server local time, +// used to scope "today" counters on the hub dashboard. +func todayMidnight() time.Time { + now := time.Now() + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) +} + +// hubPincodePrefix returns the first 3 digits of a hub's pincode, following +// the same zone-prefix convention used in pricing_helpers.go and city_gate.go. +func hubPincodePrefix(hubID int) string { + var hub models.Hub + if err := db.DB.Where("hubid = ?", hubID).First(&hub).Error; err != nil || len(hub.Pincode) < 3 { + return "" + } + return hub.Pincode[:3] +} + +func HubStaffLogin(cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` + } + req := new(LoginRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + + if req.Email == "" || req.Password == "" { + return utils.BadRequest(c, "email and password are required") + } + + var staff models.HubStaffAccount + if err := db.DB.Where("email = ? AND isactive = ?", req.Email, true).First(&staff).Error; err != nil { + return utils.Unauthorized(c, "incorrect email or password") + } + + if !utils.CheckPasswordHash(req.Password, staff.Passwordhash) { + return utils.Unauthorized(c, "incorrect email or password") + } + + token, err := utils.GenerateHubStaffToken(staff.Hubstaffaccountid, staff.Email, staff.Hubid, cfg.JWTSecret) + if err != nil { + return utils.Internal(c, "failed to generate token") + } + + var hub models.Hub + db.DB.Where("hubid = ?", staff.Hubid).First(&hub) + + var loc models.AppLocation + db.DB.Where("applocationid = ?", hub.Applocationid).First(&loc) + + return c.JSON(fiber.Map{ + "success": true, + "token": token, + "hub": fiber.Map{ + "hubid": hub.Hubid, + "hubname": hub.Hubname, + "hubtype": hub.Hubtype, + "city": loc.Applocationname, + "capacity": hub.Capacity, + }, + "staff": fiber.Map{ + "displayname": staff.Displayname, + "email": staff.Email, + "role": staff.Roleid, + }, + "is_doormile_staff": staff.Tenantid == nil, + }) + } +} + +// getCurrentHubStaff reloads the requesting hub staff account fresh from the +// DB (rather than trusting JWT claims) so tenantid changes take effect +// immediately without requiring the staff member to re-login. +func getCurrentHubStaff(c *fiber.Ctx) (*models.HubStaffAccount, error) { + staffID := c.Locals("userid").(int) + var staff models.HubStaffAccount + if err := db.DB.Where("hubstaffaccountid = ?", staffID).First(&staff).Error; err != nil { + return nil, err + } + return &staff, nil +} + +func isDoormileStaff(staff *models.HubStaffAccount) bool { + return staff.Tenantid == nil +} + +func GetHubDashboardStats(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + midnight := todayMidnight() + prefix := hubPincodePrefix(hubID) + + var parcelsReceivedToday int64 + db.DB.Model(&models.Consignment{}). + Where("currenthubid = ? AND status = ? AND updatedat >= ?", hubID, constants.ConsignmentInwardedAtHub, midnight). + Count(&parcelsReceivedToday) + + var milersAvailable int64 + db.DB.Model(&models.MilerProfile{}). + Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerAvailable). + Count(&milersAvailable) + + var milersOnDuty int64 + db.DB.Model(&models.MilerProfile{}). + Where("hubid = ? AND availabilitystatus != ?", hubID, constants.MilerOffline). + Count(&milersOnDuty) + + pendingQuery := db.DB.Model(&models.PickupBooking{}).Where("status = ?", constants.BookingPendingPickup) + if prefix != "" { + pendingQuery = pendingQuery.Where("pickuppincode LIKE ?", prefix+"%") + } + var pendingPickups int64 + pendingQuery.Count(&pendingPickups) + + var batchesSentToday int64 + db.DB.Model(&models.Tripsheet{}). + Where("sourcehubid = ? AND dispatchtime >= ?", hubID, midnight). + Count(&batchesSentToday) + + var exceptions int64 + db.DB.Model(&models.ConsignmentException{}). + Where("hubid = ? AND createdat >= ?", hubID, midnight). + Count(&exceptions) + + return utils.OK(c, fiber.Map{ + "parcels_received_today": parcelsReceivedToday, + "milers_available": milersAvailable, + "milers_on_duty": milersOnDuty, + "pending_pickups": pendingPickups, + "batches_sent_today": batchesSentToday, + "exceptions": exceptions, + }) +} + +func GetHubUnassignedBookings(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + prefix := hubPincodePrefix(hubID) + + query := db.DB.Preload("Parcels"). + Where("assignedmileruserid IS NULL AND status = ?", constants.BookingPendingPickup) + if prefix != "" { + query = query.Where("pickuppincode LIKE ?", prefix+"%") + } + + var bookings []models.PickupBooking + if err := query.Order("createdat DESC").Find(&bookings).Error; err != nil { + return utils.Internal(c, "failed to fetch unassigned bookings") + } + + response := make([]fiber.Map, 0, len(bookings)) + for _, b := range bookings { + var customer models.AppCustomer + db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer) + + response = append(response, fiber.Map{ + "bookingid": b.Bookingid, + "bookingno": b.Bookingno, + "customer_name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname), + "pickup_address": b.Pickupaddress, + "pickup_pincode": b.Pickuppincode, + "delivery_address": b.Deliveryaddress, + "parcels": b.Parcels, + "created_at": b.Createdat, + }) + } + + return utils.List(c, response, int64(len(response))) +} + +func GetHubInboundToday(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + midnight := todayMidnight() + + var consignments []models.Consignment + if err := db.DB.Where("currenthubid = ? AND status = ? AND updatedat >= ?", hubID, constants.ConsignmentInwardedAtHub, midnight). + Order("updatedat DESC").Find(&consignments).Error; err != nil { + return utils.Internal(c, "failed to fetch inbound consignments") + } + + return utils.List(c, consignments, int64(len(consignments))) +} + +func CreateInboundScan(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + idParam := c.Params("id") + + type InboundRequest struct { + TrackingID string `json:"tracking_id"` + Condition string `json:"condition"` + Temperature string `json:"temperature"` + Shelf string `json:"shelf"` + Weight string `json:"weight"` + } + req := new(InboundRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + + var consignment models.Consignment + found := false + if id, err := strconv.Atoi(idParam); err == nil { + if db.DB.Where("consignmentid = ?", id).First(&consignment).Error == nil { + found = true + } + } + if !found && idParam != "" { + if db.DB.Where("trackingno = ?", idParam).First(&consignment).Error == nil { + found = true + } + } + if !found && req.TrackingID != "" { + if db.DB.Where("trackingno = ?", req.TrackingID).First(&consignment).Error == nil { + found = true + } + } + if !found { + return utils.NotFound(c, "consignment not found") + } + + recommendedShelf := req.Shelf + if recommendedShelf == "" { + switch { + case strings.Contains(req.Condition, "Damaged"): + recommendedShelf = "Exception Area" + case req.Temperature != "" && req.Temperature != "N/A": + recommendedShelf = "Zone C (Cold Room)" + default: + recommendedShelf = "Zone A" + if n := len(consignment.Deliverypincode); n > 0 { + lastDigit := consignment.Deliverypincode[n-1] + if lastDigit >= '0' && lastDigit <= '9' && (lastDigit-'0')%2 == 0 { + recommendedShelf = "Zone B" + } + } + } + } + + consignment.Status = constants.ConsignmentInwardedAtHub + consignment.Currenthubid = &hubID + consignment.Condition = req.Condition + consignment.Shelf = recommendedShelf + consignment.Updatedat = time.Now() + + if err := db.DB.Save(&consignment).Error; err != nil { + return utils.Internal(c, "failed to update consignment") + } + + history := models.ConsignmentHistory{ + Consignmentid: consignment.Consignmentid, + Hubid: &hubID, + Eventstatus: constants.ConsignmentInwardedAtHub, + Remarks: fmt.Sprintf("Inbound scan at hub: condition=%s, shelf=%s", req.Condition, recommendedShelf), + } + db.DB.Create(&history) + + if strings.Contains(req.Condition, "Damaged") { + exception := models.ConsignmentException{ + Consignmentid: consignment.Consignmentid, + Hubid: &hubID, + Exceptiontype: constants.ExceptionDamaged, + Description: fmt.Sprintf("Flagged during hub inbound scan: %s", req.Condition), + } + db.DB.Create(&exception) + } + + return utils.OK(c, fiber.Map{ + "consignment": consignment, + "recommended_shelf": recommendedShelf, + }) +} + +// -------------------- +// HUB BATCHES (DISPATCH) — backed by the existing Tripsheet model +// -------------------- + +func GetHubBatches(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + + var tripsheets []models.Tripsheet + if err := db.DB.Where("sourcehubid = ? AND deletedat IS NULL", hubID). + Order("createdat DESC").Find(&tripsheets).Error; err != nil { + return utils.Internal(c, "failed to fetch batches") + } + + response := make([]fiber.Map, 0, len(tripsheets)) + for _, ts := range tripsheets { + var itemCount int64 + db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&itemCount) + + response = append(response, fiber.Map{ + "tripsheetid": ts.Tripsheetid, + "tripsheetno": ts.Tripsheetno, + "route": ts.Batchlabel, + "destination": ts.Destinationlabel, + "kind": ts.Batchkind, + "status": ts.Status, + "item_count": itemCount, + "dispatchtime": ts.Dispatchtime, + "createdat": ts.Createdat, + }) + } + + return utils.List(c, response, int64(len(response))) +} + +// resolveDestinationHubID best-effort matches a free-text destination (e.g. +// "Mumbai Hub (BOM-02)") against a real Hub row by name, since +// tripsheets.destinationhubid carries a NOT NULL foreign key to hubs. When no +// internal hub matches (interstate transfer to a city we don't operate a hub +// in yet), it falls back to the batch's own source hub, which is always a +// valid id; the human-readable destination is preserved in Destinationlabel. +func resolveDestinationHubID(destination string, fallback int) int { + if destination == "" { + return fallback + } + var hub models.Hub + if err := db.DB.Where("hubname ILIKE ?", "%"+destination+"%").First(&hub).Error; err == nil { + return hub.Hubid + } + return fallback +} + +func CreateHubBatch(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + staffID := c.Locals("userid").(int) + + type BatchRequest struct { + Route string `json:"route"` + Destination string `json:"destination"` + Vehicle string `json:"vehicle"` + ParcelsCount int `json:"parcels_count"` + Kind string `json:"kind"` + } + req := new(BatchRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + + if req.Route == "" { + return utils.BadRequest(c, "route is required") + } + + kind := req.Kind + if kind == "" { + kind = "local" + } + + tripsheet := models.Tripsheet{ + Tripsheetno: generateTripsheetNo(), + Sourcehubid: hubID, + Destinationhubid: resolveDestinationHubID(req.Destination, hubID), + Batchlabel: req.Route, + Batchkind: kind, + Destinationlabel: req.Destination, + Status: constants.TripsheetDraft, + Createdby: staffID, + Updatedby: staffID, + } + + if err := db.DB.Create(&tripsheet).Error; err != nil { + return utils.Internal(c, "failed to create batch") + } + + return utils.Created(c, fiber.Map{ + "tripsheetid": tripsheet.Tripsheetid, + "tripsheetno": tripsheet.Tripsheetno, + "route": tripsheet.Batchlabel, + "destination": tripsheet.Destinationlabel, + "kind": tripsheet.Batchkind, + "status": tripsheet.Status, + "vehicle": req.Vehicle, + "parcels_count": req.ParcelsCount, + }) +} + +func UpdateBatchStatus(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + id, _ := strconv.Atoi(c.Params("id")) + + type StatusUpdate struct { + Status string `json:"status"` + } + req := new(StatusUpdate) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + + validStatuses := map[string]bool{ + constants.TripsheetDraft: true, + constants.TripsheetReady: true, + constants.TripsheetDispatched: true, + } + if !validStatuses[req.Status] { + return utils.BadRequest(c, "status must be one of Draft, Ready, Dispatched") + } + + var tripsheet models.Tripsheet + if err := db.DB.Where("tripsheetid = ? AND sourcehubid = ? AND deletedat IS NULL", id, hubID).First(&tripsheet).Error; err != nil { + return utils.NotFound(c, "batch not found") + } + + tripsheet.Status = req.Status + tripsheet.Updatedat = time.Now() + if req.Status == constants.TripsheetDispatched { + now := time.Now() + tripsheet.Dispatchtime = &now + } + + if err := db.DB.Save(&tripsheet).Error; err != nil { + return utils.Internal(c, "failed to update batch status") + } + + return utils.OK(c, tripsheet) +} + +// GetHubMilers is the hub-scoped equivalent of admin's GetMilers, filtered to +// only the milers assigned to the requesting hub staff's own hub. +func GetHubMilers(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + + var profiles []models.MilerProfile + if err := db.DB.Where("hubid = ?", hubID).Find(&profiles).Error; err != nil { + return utils.Internal(c, "failed to fetch milers") + } + return utils.List(c, profiles, int64(len(profiles))) +} + +// -------------------- +// HUB MANAGEMENT (Doormile staff only) +// -------------------- + +// CreateHubStaffAccount lets Doormile staff (tenantid == nil) provision new +// hub staff accounts, including partner-tenant accounts scoped to one hub. +func CreateHubStaffAccount(c *fiber.Ctx) error { + current, err := getCurrentHubStaff(c) + if err != nil { + return utils.Internal(c, "failed to verify requesting staff account") + } + if !isDoormileStaff(current) { + return utils.Forbidden(c, "only Doormile staff can create hub staff accounts") + } + + type CreateStaffRequest struct { + Hubid int `json:"hubid"` + Email string `json:"email"` + Password string `json:"password"` + Displayname string `json:"displayname"` + Tenantid *int `json:"tenantid"` + } + req := new(CreateStaffRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + + if req.Hubid == 0 || req.Email == "" || req.Password == "" { + return utils.BadRequest(c, "hubid, email and password are required") + } + + var hub models.Hub + if err := db.DB.Where("hubid = ? AND deletedat IS NULL", req.Hubid).First(&hub).Error; err != nil { + return utils.NotFound(c, "hub not found") + } + + passHash, err := utils.HashPassword(req.Password) + if err != nil { + return utils.Internal(c, "failed to hash password") + } + + staff := models.HubStaffAccount{ + Hubid: req.Hubid, + Email: req.Email, + Passwordhash: passHash, + Displayname: req.Displayname, + Roleid: 6, + Isactive: true, + Tenantid: req.Tenantid, + } + + if err := db.DB.Create(&staff).Error; err != nil { + return utils.Internal(c, "failed to create hub staff account") + } + + return utils.Created(c, fiber.Map{ + "hubstaffaccountid": staff.Hubstaffaccountid, + "hubid": staff.Hubid, + "email": staff.Email, + "displayname": staff.Displayname, + "tenantid": staff.Tenantid, + }) +} + +// GetHubsInCity lists every hub in the requesting staff's city (same +// applocationid as their own hub), noting whether each already has a staff +// account so the console can flag hubs still needing one. +func GetHubsInCity(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + + var myHub models.Hub + if err := db.DB.Where("hubid = ?", hubID).First(&myHub).Error; err != nil { + return utils.Internal(c, "failed to resolve current hub") + } + + var hubs []models.Hub + if err := db.DB.Where("applocationid = ? AND deletedat IS NULL", myHub.Applocationid). + Order("hubid").Find(&hubs).Error; err != nil { + return utils.Internal(c, "failed to fetch hubs") + } + + response := make([]fiber.Map, 0, len(hubs)) + for _, h := range hubs { + var staffCount int64 + db.DB.Model(&models.HubStaffAccount{}).Where("hubid = ?", h.Hubid).Count(&staffCount) + + response = append(response, fiber.Map{ + "hubid": h.Hubid, + "hubname": h.Hubname, + "hubtype": h.Hubtype, + "pincode": h.Pincode, + "status": h.Status, + "capacity": h.Capacity, + "has_staff": staffCount > 0, + }) + } + + return utils.List(c, response, int64(len(response))) +} + +// CreateCityHub lets Doormile staff add a new hub, forced into their own +// city (applocationid) regardless of what the request body sends. +func CreateCityHub(c *fiber.Ctx) error { + current, err := getCurrentHubStaff(c) + if err != nil { + return utils.Internal(c, "failed to verify requesting staff account") + } + if !isDoormileStaff(current) { + return utils.Forbidden(c, "only Doormile staff can create new hubs") + } + + hubID := c.Locals("hubid").(int) + var myHub models.Hub + if err := db.DB.Where("hubid = ?", hubID).First(&myHub).Error; err != nil { + return utils.Internal(c, "failed to resolve current hub") + } + + req := new(dto.HubCreateRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + + if req.Hubname == "" || req.Hubtype == "" { + return utils.BadRequest(c, "hubname and hubtype are required") + } + + hub := models.Hub{ + Hubname: req.Hubname, + Hubtype: req.Hubtype, + Applocationid: myHub.Applocationid, + Contactno: req.Contactno, + Address: req.Address, + Latitude: req.Latitude, + Longitude: req.Longitude, + Pincode: req.Pincode, + Status: req.Status, + } + if hub.Status == "" { + hub.Status = "Active" + } + + if err := db.DB.Create(&hub).Error; err != nil { + return utils.Internal(c, "failed to create hub") + } + + return utils.Created(c, hub) +} diff --git a/internal/assignment/ai_layer.go b/internal/assignment/ai_layer.go new file mode 100644 index 0000000..2b18200 --- /dev/null +++ b/internal/assignment/ai_layer.go @@ -0,0 +1,330 @@ +package assignment + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "time" + + "doormile/constants" + "doormile/db" + "doormile/models" + "doormile/utils" + + "github.com/redis/go-redis/v9" +) + +// ─── Request / response types ──────────────────────────────────────────────── + +type aiCandidate struct { + MilerID int `json:"miler_id"` + DistanceKm float64 `json:"distance_km"` + Rating float64 `json:"rating"` + ActiveBookings int64 `json:"active_bookings"` + OnTimeRate30d float64 `json:"on_time_rate_30d"` + CompletedToday int64 `json:"completed_today"` + HubID int `json:"hub_id"` + HubLoad int64 `json:"hub_load"` + HubCapacity int `json:"hub_capacity"` +} + +type aiDecisionRequest struct { + Booking aiBookingInfo `json:"booking"` + Candidates []aiCandidate `json:"candidates"` + Context aiRequestCtx `json:"context"` +} + +type aiBookingInfo struct { + PickupLat float64 `json:"pickup_lat"` + PickupLon float64 `json:"pickup_lon"` + DeliveryLat float64 `json:"delivery_lat"` + DeliveryLon float64 `json:"delivery_lon"` + ItemCategory string `json:"item_category"` + Weight float64 `json:"weight"` + ServiceType string `json:"service_type"` +} + +type aiRequestCtx struct { + Hour int `json:"hour"` + IsPeak bool `json:"is_peak"` + Zone string `json:"zone"` +} + +type aiDecisionResponse struct { + ChosenMilerID int `json:"chosen_miler_id"` + Escalate bool `json:"escalate"` + AgentDecisionID uint64 `json:"agent_decision_id"` + Reasoning string `json:"reasoning"` +} + +// ─── Main entry point ──────────────────────────────────────────────────────── + +// selectMilerWithAI collects all eligible milers from the GEORADIUS result, calls +// the AI decision engine, and returns the chosen miler plus the decision ID for +// audit. Falls back to the original distance/load/rating formula when the AI +// layer is unreachable or times out. Returns (nil, nil, false) when there are no +// eligible candidates or when the AI layer escalates the booking. +func selectMilerWithAI(booking *models.PickupBooking, nearby []redis.GeoLocation) (*milerCandidate, *uint64, bool) { + candidates, aiCandidates := collectEligibleCandidates(nearby) + if len(candidates) == 0 { + return nil, nil, false + } + + decision, err := callDecisionEngine(booking, aiCandidates) + if err != nil { + utils.Warn("AI_LAYER_FALLBACK: decide-assignment unreachable, using legacy scoring", + "booking_id", booking.Bookingid, "error", err) + best := pickBestFromCandidates(candidates) + return best, nil, best != nil + } + + utils.Info("Assignment: AI layer responded", + "booking_id", booking.Bookingid, + "escalate", decision.Escalate, + "chosen_miler_id", decision.ChosenMilerID, + "agent_decision_id", decision.AgentDecisionID, + "reasoning", decision.Reasoning, + ) + + if decision.Escalate { + utils.Warn("Assignment: AI layer escalated — skipping assignment this attempt", + "booking_id", booking.Bookingid, + "reasoning", decision.Reasoning, + ) + return nil, nil, false + } + + var decisionID *uint64 + if decision.AgentDecisionID != 0 { + id := decision.AgentDecisionID + decisionID = &id + } + + for _, c := range candidates { + if c.profile.Userid == decision.ChosenMilerID { + return c, decisionID, true + } + } + + // AI returned an ID that is not in our eligibility set — fall back safely. + utils.Warn("AI_LAYER_FALLBACK: chosen miler not in eligible set, using legacy scoring", + "booking_id", booking.Bookingid, + "chosen_miler_id", decision.ChosenMilerID, + ) + best := pickBestFromCandidates(candidates) + return best, nil, best != nil +} + +// ─── Candidate collection ──────────────────────────────────────────────────── + +// collectEligibleCandidates iterates the GEORADIUS result, applies eligibility +// filters (availability, active-booking cap), and fetches per-miler stats needed +// by the AI layer. Returns parallel slices so callers can use either. +func collectEligibleCandidates(nearby []redis.GeoLocation) ([]*milerCandidate, []aiCandidate) { + var candidates []*milerCandidate + var aiCandidates []aiCandidate + + for _, loc := range nearby { + milerUserID, err := strconv.Atoi(loc.Name) + if err != nil { + utils.Warn("Assignment: skipping non-numeric GEO member", "name", loc.Name) + continue + } + + var profile models.MilerProfile + if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil { + continue + } + + if profile.Availabilitystatus != constants.MilerAvailable { + continue + } + + var activeCount int64 + db.DB.Model(&models.BookingAssignment{}). + Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID, []string{ + constants.AssignmentAssigned, + constants.AssignmentAccepted, + }). + Count(&activeCount) + + if activeCount >= maxActive { + continue + } + + onTimeRate, completedToday := fetchMilerStats(milerUserID) + hubID, hubLoad, hubCapacity := fetchHubData(profile.Hubid) + + candidates = append(candidates, &milerCandidate{ + profile: profile, + distanceKm: loc.Dist, + activeBookings: activeCount, + }) + aiCandidates = append(aiCandidates, aiCandidate{ + MilerID: milerUserID, + DistanceKm: loc.Dist, + Rating: profile.Rating, + ActiveBookings: activeCount, + OnTimeRate30d: onTimeRate, + CompletedToday: completedToday, + HubID: hubID, + HubLoad: hubLoad, + HubCapacity: hubCapacity, + }) + } + + return candidates, aiCandidates +} + +// ─── Per-miler stats ───────────────────────────────────────────────────────── + +func fetchMilerStats(milerUserID int) (onTimeRate float64, completedToday int64) { + type statsRow struct { + Total int64 + OnTime int64 + } + var row statsRow + db.DB.Raw(` + SELECT + COUNT(*) AS total, + COUNT(CASE WHEN assignmentstatus = 'Completed_OnTime' THEN 1 END) AS on_time + FROM bookingassignments + WHERE mileruserid = ? + AND createdat > NOW() - INTERVAL '30 days' + `, milerUserID).Scan(&row) + + if row.Total == 0 { + onTimeRate = 0.85 // neutral assumption for milers with no recent history + } else { + onTimeRate = float64(row.OnTime) / float64(row.Total) + } + + today := time.Now().Truncate(24 * time.Hour) + db.DB.Model(&models.BookingAssignment{}). + Where("mileruserid = ? AND createdat >= ? AND assignmentstatus = ?", + milerUserID, today, constants.AssignmentCompleted). + Count(&completedToday) + + return +} + +// ─── Hub stats ─────────────────────────────────────────────────────────────── + +// fetchHubData returns the hub_load (active assignments across the hub) and +// hub_capacity for the given hub. Defaults to 50 for capacity when the column +// doesn't exist or the miler has no assigned hub. +func fetchHubData(hubID *int) (resolvedHubID int, hubLoad int64, hubCapacity int) { + hubCapacity = 50 // safe default — also used if hubs.capacity column is absent + if hubID == nil { + return 0, 0, hubCapacity + } + resolvedHubID = *hubID + + // hub_load: total active assignments whose miler belongs to this hub + db.DB.Raw(` + SELECT COUNT(*) FROM bookingassignments ba + JOIN milerprofiles mp ON ba.mileruserid = mp.userid + WHERE mp.hubid = ? + AND ba.assignmentstatus IN ('Assigned', 'Accepted', 'Pickup_Scheduled') + `, resolvedHubID).Scan(&hubLoad) + + // hub_capacity: gracefully handle column not existing yet + var cap int + if err := db.DB.Raw(`SELECT capacity FROM hubs WHERE hubid = ?`, resolvedHubID).Scan(&cap).Error; err == nil && cap > 0 { + hubCapacity = cap + } + + return +} + +// ─── Fallback scorer ───────────────────────────────────────────────────────── + +// pickBestFromCandidates applies the original formula to an already-collected +// eligible slice: score = distance_km*1.0 + active_bookings*2.0 - rating*0.5 +func pickBestFromCandidates(candidates []*milerCandidate) *milerCandidate { + var best *milerCandidate + bestScore := 1e18 + + for _, c := range candidates { + score := c.distanceKm*1.0 + float64(c.activeBookings)*2.0 - c.profile.Rating*0.5 + if score < bestScore { + bestScore = score + best = c + } + } + + return best +} + +// ─── AI layer HTTP call ────────────────────────────────────────────────────── + +func callDecisionEngine(booking *models.PickupBooking, candidates []aiCandidate) (aiDecisionResponse, error) { + baseURL := os.Getenv("AI_LAYER_BASE_URL") + if baseURL == "" { + baseURL = "https://routemate.workolik.com" + } + + now := time.Now() + hour := now.Hour() + isPeak := (hour >= 7 && hour <= 10) || (hour >= 17 && hour <= 21) + + reqBody := aiDecisionRequest{ + Booking: aiBookingInfo{ + PickupLat: booking.Pickuplatitude, + PickupLon: booking.Pickuplongitude, + DeliveryLat: booking.Deliverylatitude, + DeliveryLon: booking.Deliverylongitude, + ItemCategory: b2cFirstParcelCategory(booking), + Weight: b2cChargeableWeight(booking), + ServiceType: b2cFirstServiceType(booking), + }, + Candidates: candidates, + Context: aiRequestCtx{ + Hour: hour, + IsPeak: isPeak, + Zone: booking.Deliverycity, + }, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return aiDecisionResponse{}, fmt.Errorf("marshal AI request: %w", err) + } + + utils.Info("Assignment: AI layer outgoing payload", + "booking_id", booking.Bookingid, + "payload", string(body), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + baseURL+"/api/v1/doormile/decide-assignment", + bytes.NewReader(body)) + if err != nil { + return aiDecisionResponse{}, fmt.Errorf("build AI request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return aiDecisionResponse{}, fmt.Errorf("AI layer call: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return aiDecisionResponse{}, fmt.Errorf("AI layer HTTP %d", resp.StatusCode) + } + + var decision aiDecisionResponse + if err := json.NewDecoder(resp.Body).Decode(&decision); err != nil { + return aiDecisionResponse{}, fmt.Errorf("decode AI response: %w", err) + } + + return decision, nil +} diff --git a/internal/assignment/crm_assignment.go b/internal/assignment/crm_assignment.go index 2941149..7ed54d8 100644 --- a/internal/assignment/crm_assignment.go +++ b/internal/assignment/crm_assignment.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "strconv" "time" "doormile/constants" @@ -74,7 +73,7 @@ func AssignCRMMiler(bookingID int) { // Returns (false, err) on hard errors (booking missing, DB failure). func tryAssign(bookingID int) (bool, error) { var booking models.PickupBooking - if err := db.DB.First(&booking, bookingID).Error; err != nil { + if err := db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, bookingID).Error; err != nil { return false, fmt.Errorf("load booking: %w", err) } @@ -100,12 +99,12 @@ func tryAssign(bookingID int) (bool, error) { return false, nil } - candidate, found := pickBestMiler(nearby) + candidate, agentDecisionID, found := selectMilerWithAI(&booking, nearby) if !found { return false, nil } - if err := commitAssignment(&booking, candidate); err != nil { + if err := commitAssignment(&booking, candidate, agentDecisionID); err != nil { return false, fmt.Errorf("commit: %w", err) } @@ -140,58 +139,9 @@ func queryNearbyMilers(lat, lon float64) ([]redis.GeoLocation, error) { return locs, nil } -// pickBestMiler filters the nearby list for eligibility, then returns the candidate -// with the lowest score. score = distance_km*1.0 + active_bookings*2.0 - rating*0.5 -func pickBestMiler(nearby []redis.GeoLocation) (*milerCandidate, bool) { - var best *milerCandidate - bestScore := 1e18 - - for _, loc := range nearby { - milerUserID, err := strconv.Atoi(loc.Name) - if err != nil { - utils.Warn("CRMAssignment: skipping non-numeric GEO member", "name", loc.Name) - continue - } - - var profile models.MilerProfile - if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil { - continue - } - - if profile.Availabilitystatus != constants.MilerAvailable { - continue - } - - var activeCount int64 - db.DB.Model(&models.BookingAssignment{}). - Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID, []string{ - constants.AssignmentAssigned, - constants.AssignmentAccepted, - }). - Count(&activeCount) - - if activeCount >= maxActive { - continue - } - - score := loc.Dist*1.0 + float64(activeCount)*2.0 - profile.Rating*0.5 - - if score < bestScore { - bestScore = score - best = &milerCandidate{ - profile: profile, - distanceKm: loc.Dist, - activeBookings: activeCount, - } - } - } - - return best, best != nil -} - // commitAssignment writes the BookingAssignment row, updates the booking and the // miler's availability status in a single transaction, then publishes to NATS. -func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate) error { +func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate, agentDecisionID *uint64) error { milerUserID := candidate.profile.Userid tx := db.DB.Begin() @@ -201,6 +151,7 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate) Mileruserid: milerUserID, Assignmentstatus: constants.AssignmentAssigned, Assignedat: time.Now(), + AgentDecisionID: agentDecisionID, } if err := tx.Create(&assignment).Error; err != nil { tx.Rollback() @@ -231,7 +182,7 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate) "miler_id", milerUserID, "distance_km", candidate.distanceKm, "active_bookings", candidate.activeBookings, - "score", candidate.distanceKm*1.0+float64(candidate.activeBookings)*2.0-candidate.profile.Rating*0.5, + "agent_decision_id", agentDecisionID, ) publishAssignment(booking, milerUserID) diff --git a/internal/assignment/customer_assignment.go b/internal/assignment/customer_assignment.go index cb84865..1c0e72b 100644 --- a/internal/assignment/customer_assignment.go +++ b/internal/assignment/customer_assignment.go @@ -82,7 +82,7 @@ func tryCustomerAssign(bookingID int) (bool, error) { return false, fmt.Errorf("booking %d has no pickup coordinates", bookingID) } - // Step 1 — Find the best nearby miler (shared GEO logic from crm_assignment.go). + // Step 1 — Find the best nearby miler via AI decision engine (GEORADIUS unchanged). nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude) if err != nil { utils.Warn("B2CAssignment: GEO query failed", "booking_id", bookingID, "error", err) @@ -92,7 +92,7 @@ func tryCustomerAssign(bookingID int) (bool, error) { return false, nil } - miler, found := pickBestMiler(nearby) + miler, agentDecisionID, found := selectMilerWithAI(&booking, nearby) if !found { return false, nil } @@ -113,11 +113,11 @@ func tryCustomerAssign(bookingID int) (bool, error) { provider = providerResult{company: "Doormile"} } - // Step 6 — ETA based on miler-to-pickup distance. + // Step 3 — ETA based on miler-to-pickup distance. etaMinutes := calculateETA(miler.distanceKm) - // Steps 3–5 — Commit to DB and publish to NATS. - if err := commitCustomerAssignment(&booking, miler, provider, etaMinutes); err != nil { + // Steps 4–6 — Commit to DB and publish to NATS. + if err := commitCustomerAssignment(&booking, miler, provider, etaMinutes, agentDecisionID); err != nil { return false, fmt.Errorf("commit: %w", err) } @@ -203,6 +203,7 @@ func commitCustomerAssignment( miler *milerCandidate, provider providerResult, etaMinutes float64, + agentDecisionID *uint64, ) error { milerUserID := miler.profile.Userid @@ -213,6 +214,7 @@ func commitCustomerAssignment( Mileruserid: milerUserID, Assignmentstatus: constants.AssignmentAssigned, Assignedat: time.Now(), + AgentDecisionID: agentDecisionID, } if err := tx.Create(&ba).Error; err != nil { tx.Rollback() @@ -249,6 +251,7 @@ func commitCustomerAssignment( "estimated_price", provider.estimatedPrice, "eta_minutes", etaMinutes, "miler_distance_km", miler.distanceKm, + "agent_decision_id", agentDecisionID, ) publishCustomerAssignment(booking, milerUserID, provider, etaMinutes) diff --git a/middlewares/auth.go b/middlewares/auth.go index b92d814..e45d9fe 100644 --- a/middlewares/auth.go +++ b/middlewares/auth.go @@ -45,6 +45,50 @@ func AuthMiddleware(cfg *config.Config) fiber.Handler { } } +// HubStaffAuth validates the JWT and requires role 6 (hub staff), then +// exposes hubid via c.Locals so hub handlers can scope all queries to it. +func HubStaffAuth(cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + authHeader := c.Get("Authorization") + if authHeader == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "message": "authorization header is required", + }) + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "message": "authorization header must be in format: Bearer ", + }) + } + + claims, err := utils.ParseToken(parts[1], cfg.JWTSecret) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "success": false, + "message": "invalid or expired token", + }) + } + + if claims.RoleID != 6 { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "success": false, + "message": "access restricted to hub staff", + }) + } + + c.Locals("userid", claims.UserID) + c.Locals("email", claims.Email) + c.Locals("roleid", claims.RoleID) + c.Locals("hubid", claims.HubID) + + return c.Next() + } +} + func RoleCheckMiddleware(allowedRoles ...int) fiber.Handler { return func(c *fiber.Ctx) error { roleID, ok := c.Locals("roleid").(int) diff --git a/migrations/migrate.go b/migrations/migrate.go index 45d00af..3c42775 100644 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -41,6 +41,7 @@ func Migrate(db *gorm.DB) error { &models.CarrierPricing{}, &models.DoormilePricing{}, &models.AgentDecision{}, + &models.HubStaffAccount{}, ) if err != nil { @@ -62,5 +63,16 @@ func Migrate(db *gorm.DB) error { utils.Info("✅ ivfflat index on context_embedding ready") } + // The tripsheets.status CHECK constraint predates this codebase (not derived + // from any gorm tag) and only allowed Draft/Dispatched/Arrived/Cancelled. + // Hub batches need an intermediate "Ready" stage before dispatch. + db.Exec(`ALTER TABLE tripsheets DROP CONSTRAINT IF EXISTS tripsheets_status_check`) + if res := db.Exec(`ALTER TABLE tripsheets ADD CONSTRAINT tripsheets_status_check + CHECK (status::text = ANY (ARRAY['Draft','Ready','Dispatched','Arrived','Cancelled']::text[]))`); res.Error != nil { + utils.Error("❌ Failed to widen tripsheets_status_check constraint", "error", res.Error) + } else { + utils.Info("✅ tripsheets_status_check constraint includes Ready") + } + return nil } diff --git a/models/audit.go b/models/audit.go index a67195a..8721b90 100644 --- a/models/audit.go +++ b/models/audit.go @@ -67,6 +67,8 @@ type Consignment struct { Returninitiatedat *time.Time `json:"returninitiatedat" gorm:"column:returninitiatedat"` Returndeliveredat *time.Time `json:"returndeliveredat" gorm:"column:returndeliveredat"` Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"` + Condition string `json:"condition" gorm:"column:condition;size:50"` // recorded at hub inbound scan: Good, Damaged, etc. + Shelf string `json:"shelf" gorm:"column:shelf;size:50"` // hub storage location assigned at inbound scan Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` Createdby int `json:"createdby" gorm:"column:createdby"` diff --git a/models/booking.go b/models/booking.go index ca256c6..48c9124 100644 --- a/models/booking.go +++ b/models/booking.go @@ -105,6 +105,7 @@ type BookingAssignment struct { Acceptedat *time.Time `json:"acceptedat" gorm:"column:acceptedat"` Completedat *time.Time `json:"completedat" gorm:"column:completedat"` Remarks string `json:"remarks" gorm:"column:remarks"` + AgentDecisionID *uint64 `json:"agent_decision_id,omitempty" gorm:"column:agent_decision_id"` } func (BookingAssignment) TableName() string { diff --git a/models/transit.go b/models/transit.go index 7c2bae4..5bf3f64 100644 --- a/models/transit.go +++ b/models/transit.go @@ -13,7 +13,10 @@ type Tripsheet struct { Driveruserid *int `json:"driveruserid" gorm:"column:driveruserid"` Dispatchtime *time.Time `json:"dispatchtime" gorm:"column:dispatchtime"` Arrivaltime *time.Time `json:"arrivaltime" gorm:"column:arrivaltime"` - Status string `json:"status" gorm:"column:status;default:Draft"` // Draft, Dispatched, Arrived, Cancelled + Status string `json:"status" gorm:"column:status;default:Draft"` // Draft, Ready, Dispatched, Arrived, Cancelled + Batchlabel string `json:"batchlabel" gorm:"column:batchlabel;size:100"` + Batchkind string `json:"batchkind" gorm:"column:batchkind;size:20"` // local, transfer + Destinationlabel string `json:"destinationlabel" gorm:"column:destinationlabel;size:200"` Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` Createdby int `json:"createdby" gorm:"column:createdby"` diff --git a/models/users.go b/models/users.go index d449064..eb05ad5 100644 --- a/models/users.go +++ b/models/users.go @@ -28,6 +28,7 @@ type Hub struct { Latitude float64 `json:"latitude" gorm:"column:latitude"` Longitude float64 `json:"longitude" gorm:"column:longitude"` Pincode string `json:"pincode" gorm:"column:pincode"` + Capacity int `json:"capacity" gorm:"column:capacity;default:50"` Status string `json:"status" gorm:"column:status;default:Active"` // Active, InActive Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` @@ -90,6 +91,7 @@ type MilerProfile struct { Phone string `json:"phone" gorm:"column:phone;not null"` Profilephotourl string `json:"profilephotourl" gorm:"column:profilephotourl"` Vehicleid *int `json:"vehicleid" gorm:"column:vehicleid"` + Hubid *int `json:"hubid" gorm:"column:hubid"` Defaultvehicletype string `json:"defaultvehicletype" gorm:"column:defaultvehicletype"` Currentlatitude float64 `json:"currentlatitude" gorm:"column:currentlatitude"` Currentlongitude float64 `json:"currentlongitude" gorm:"column:currentlongitude"` @@ -171,3 +173,20 @@ type TenantLocation struct { func (TenantLocation) TableName() string { return "tenantlocations" } + +type HubStaffAccount struct { + Hubstaffaccountid int `json:"hubstaffaccountid" gorm:"primaryKey;column:hubstaffaccountid"` + Hubid int `json:"hubid" gorm:"column:hubid;index;not null"` + Email string `json:"email" gorm:"column:email;unique;not null"` + Passwordhash string `json:"-" gorm:"column:passwordhash;not null"` + Displayname string `json:"displayname" gorm:"column:displayname"` + Roleid int `json:"roleid" gorm:"column:roleid;default:6"` + Isactive bool `json:"isactive" gorm:"column:isactive;default:true"` + Tenantid *int `json:"tenantid" gorm:"column:tenantid;index"` // null = Doormile staff (can manage any hub in their city); set = partner staff scoped to their own hub + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` +} + +func (HubStaffAccount) TableName() string { + return "hubstaffaccounts" +} diff --git a/routes/routes.go b/routes/routes.go index cc109ac..f42cc0e 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -237,6 +237,28 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { adminAuth.Get("/exceptions/:id", controllers.GetExceptionDetails) adminAuth.Put("/exceptions/:id/status", controllers.ResolveException) + // -------------------- + // HUB CONSOLE APIS + // -------------------- + hub := api.Group("/hub") + hub.Post("/login", 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("/inbound/today", controllers.GetHubInboundToday) + hubAuth.Post("/bookings/:id/inbound", controllers.CreateInboundScan) + 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) + // Redis active user caching utilities redisUsers := api.Group("/utils/users/redis") redisUsers.Post("/", controllers.CreateUserRedis) diff --git a/utils/helper.go b/utils/helper.go index 5cb2171..15c9d47 100644 --- a/utils/helper.go +++ b/utils/helper.go @@ -24,6 +24,7 @@ type Claims struct { RoleID int `json:"roleid"` TenantID int `json:"tenantid"` ConfigID int `json:"configid,omitempty"` + HubID int `json:"hubid,omitempty"` jwt.RegisteredClaims } @@ -45,6 +46,26 @@ func GenerateToken(userID int, email string, roleID int, tenantID int, configID return token.SignedString([]byte(secret)) } +// GenerateHubStaffToken issues a JWT for hub console staff (role 6), carrying +// HubID instead of TenantID/ConfigID so hub handlers can scope queries via +// c.Locals("hubid") without colliding with the Miler role (5). +func GenerateHubStaffToken(userID int, email string, hubID int, secret string) (string, error) { + expirationTime := time.Now().Add(24 * time.Hour) + claims := &Claims{ + UserID: userID, + Email: email, + RoleID: 6, + HubID: hubID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} + func ParseToken(tokenString string, secret string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { return []byte(secret), nil