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