This commit is contained in:
2026-07-04 11:10:52 +05:30
parent bd6427f5db
commit c8adaf7815
18 changed files with 1140 additions and 92 deletions

3
.env
View File

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

View File

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

View File

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

View File

@@ -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"),
}
}

View File

@@ -67,6 +67,7 @@ const (
// Tripsheet Statuses
const (
TripsheetDraft = "Draft"
TripsheetReady = "Ready"
TripsheetDispatched = "Dispatched"
TripsheetArrived = "Arrived"
TripsheetCancelled = "Cancelled"

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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)

View File

@@ -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 35 — Commit to DB and publish to NATS.
if err := commitCustomerAssignment(&booking, miler, provider, etaMinutes); err != nil {
// Steps 46 — 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)

View File

@@ -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 <token>",
})
}
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)

View File

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

View File

@@ -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"`

View File

@@ -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 {

View File

@@ -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"`

View File

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

View File

@@ -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)

View File

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