package controllers import ( "fmt" "math" "sort" "strconv" "strings" "time" "doormile/config" "doormile/constants" "doormile/db" "doormile/dto" "doormile/internal/assignment" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" ) // hubAutoAssignTimeout bounds how long HubAutoAssign waits synchronously for // TryAssignOnce (GEOSEARCH + AI decision + commit) before returning 202 and // letting the assignment finish in the background. const hubAutoAssignTimeout = 8 * time.Second // assignableBookingStatuses gates both HubAssignMiler and HubAutoAssign. // Pending_Assignment / Assignment_Failed aren't statuses this codebase // currently sets anywhere (only Pending_Pickup is real today) but are kept // here so the gate doesn't need touching if those states get introduced later. var assignableBookingStatuses = map[string]bool{ constants.BookingPendingPickup: true, "Pending_Assignment": true, "Assignment_Failed": true, } // defaultJourneyMinutes is the assumed total truck transit time until real // GPS telemetry (EMQX) is wired up; used to interpolate in-transit position. const defaultJourneyMinutes = 480 // 8 hours // zoneNames maps a handful of known pincodes in our 4 operating cities to // human-readable locality names. Unmapped pincodes fall back to "Zone ". var zoneNames = map[string]string{ "641001": "Gandhipuram", "641002": "RS Puram", "641004": "Peelamedu", "641012": "Jupiter Nagar", "641035": "Saravanampatti", "500003": "Secunderabad", "500032": "Financial District", "500034": "Banjara Hills", "500081": "Gachibowli", "560034": "Koramangala", "560038": "Indiranagar", "560066": "Whitefield", "560100": "Electronic City", "600017": "T Nagar", "600032": "Guindy", "600040": "Anna Nagar", "600042": "Velachery", } func zoneName(pincode string) string { if name, ok := zoneNames[pincode]; ok { return name } return "Zone " + pincode } // haversineKM returns the great-circle distance between two lat/lon points in km. func haversineKM(lat1, lon1, lat2, lon2 float64) float64 { const earthRadiusKM = 6371.0 toRad := func(deg float64) float64 { return deg * math.Pi / 180 } dLat := toRad(lat2 - lat1) dLon := toRad(lon2 - lon1) a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2) return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) } // humanizeRelativeTime renders a timestamp as "5 min ago" / "2 hrs ago" / "3 days ago". func humanizeRelativeTime(t time.Time) string { d := time.Since(t) switch { case d < time.Minute: return "just now" case d < time.Hour: return fmt.Sprintf("%d min ago", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("%d hrs ago", int(d.Hours())) default: return fmt.Sprintf("%d days ago", int(d.Hours()/24)) } } // 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) // Sorting happens at inbound scan (CreateInboundScan assigns a shelf), so // today's inwarded count doubles as "parcels sorted today"; the target is // the hub's own declared daily capacity rather than a made-up constant. var hub models.Hub db.DB.Where("hubid = ?", hubID).First(&hub) 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, "parcels_sorted": parcelsReceivedToday, "sorting_target": hub.Capacity, }) } 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") } response := make([]fiber.Map, 0, len(consignments)) for _, cs := range consignments { originName := fmt.Sprintf("Direct pickup (%s)", cs.Pickuppincode) if cs.Originhubid != nil { var oh models.Hub if db.DB.Where("hubid = ?", *cs.Originhubid).First(&oh).Error == nil { originName = oh.Hubname } } response = append(response, fiber.Map{ "consignmentid": cs.Consignmentid, "trackingno": cs.Trackingno, // sendername is empty: consignments carry no FK back to any // customer/booking record (Senderid/Receiverid are never // populated anywhere in this codebase). "sendername": "", "originname": originName, // destinationname: consignments store no free-text delivery // address, only deliverypincode — used as the best available label. "destinationname": cs.Deliverypincode, "condition": cs.Condition, "shelf": cs.Shelf, // temperature: no column exists anywhere yet for this. "temperature": "N/A", "status": cs.Status, "updatedat": cs.Updatedat, }) } return utils.List(c, response, int64(len(response))) } 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, enriched // with computed operational stats per miler. func GetHubMilers(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) midnight := todayMidnight() weekAgo := time.Now().AddDate(0, 0, -7) var profiles []models.MilerProfile if err := db.DB.Where("hubid = ?", hubID).Find(&profiles).Error; err != nil { return utils.Internal(c, "failed to fetch milers") } response := make([]fiber.Map, 0, len(profiles)) for _, mp := range profiles { var zones []string db.DB.Model(&models.BookingAssignment{}). Joins("JOIN pickupbookings pb ON pb.bookingid = bookingassignments.bookingid"). Where("bookingassignments.mileruserid = ? AND bookingassignments.assignedat >= ?", mp.Userid, weekAgo). Distinct("pb.pickuppincode"). Pluck("pb.pickuppincode", &zones) if zones == nil { zones = []string{} } // assignmentstatus (BookingAssignment's real status column) only has // Assigned/Accepted/Rejected/Reassigned/Completed/Cancelled — treated // Assigned+Accepted as "still active load". var assignedLoad int64 db.DB.Model(&models.BookingAssignment{}). Where("mileruserid = ? AND assignmentstatus IN ?", mp.Userid, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). Count(&assignedLoad) // Pending_Pickup lives on pickupbookings.status, not on the // assignment itself, so this counts via a join. var pickupsPending int64 db.DB.Model(&models.BookingAssignment{}). Joins("JOIN pickupbookings pb ON pb.bookingid = bookingassignments.bookingid"). Where("bookingassignments.mileruserid = ? AND pb.status = ?", mp.Userid, constants.BookingPendingPickup). Count(&pickupsPending) // bookingpayments has no mileruserid column; collectedbyuserid is the // app user id of whoever collected it, which for miler collections is // the miler's own userid. var codCollected float64 db.DB.Model(&models.BookingPayment{}). Where("collectedbyuserid = ? AND paymentstatus = ? AND createdat >= ?", mp.Userid, constants.PaymentStatusPaid, midnight). Select("COALESCE(SUM(amount), 0)").Scan(&codCollected) var codPending float64 db.DB.Model(&models.BookingPayment{}). Where("collectedbyuserid = ? AND paymentstatus = ?", mp.Userid, constants.PaymentStatusPending). Select("COALESCE(SUM(amount), 0)").Scan(&codPending) var firstAssignment models.BookingAssignment var checkinAt *time.Time if db.DB.Where("mileruserid = ? AND assignedat >= ?", mp.Userid, midnight). Order("assignedat ASC").First(&firstAssignment).Error == nil { checkinAt = &firstAssignment.Assignedat } hoursActive := 0.0 if checkinAt != nil { hoursActive = time.Since(*checkinAt).Hours() } var vehicleNo string if mp.Vehicleid != nil { var v models.Vehicle if db.DB.Where("vehicleid = ?", *mp.Vehicleid).First(&v).Error == nil { vehicleNo = v.Vehicleno } } response = append(response, fiber.Map{ "milerprofileid": mp.Milerprofileid, "userid": mp.Userid, "displayname": mp.Displayname, "phone": mp.Phone, "vehicleid": mp.Vehicleid, "hubid": mp.Hubid, "defaultvehicletype": mp.Defaultvehicletype, "vehicleno": vehicleNo, "currentlatitude": mp.Currentlatitude, "currentlongitude": mp.Currentlongitude, "currentpincode": mp.Currentpincode, "availabilitystatus": mp.Availabilitystatus, "rating": mp.Rating, "totalcompletedpickups": mp.Totalcompletedpickups, "totalcancelledpickups": mp.Totalcancelledpickups, "zones": zones, "assignedload": assignedLoad, "capacity": 30, // hardcoded until vehicle capacity is modelled "pickupspending": pickupsPending, "codcollected": codCollected, "codpending": codPending, "checkinat": checkinAt, "hoursactive": hoursActive, "isverified": mp.Devicetoken != "", }) } return utils.List(c, response, int64(len(response))) } // -------------------- // HUB MESSAGES / CHAT // -------------------- // ensureHubConversations lazily creates a HubConversation for every miler // currently assigned to this hub that doesn't already have one, so the // message list always reflects the hub's real roster without needing a // separate provisioning endpoint. func ensureHubConversations(hubID int) { var profiles []models.MilerProfile if err := db.DB.Where("hubid = ?", hubID).Find(&profiles).Error; err != nil { return } for _, mp := range profiles { var existing models.HubConversation if db.DB.Where("hubid = ? AND mileruserid = ?", hubID, mp.Userid).First(&existing).Error == nil { continue } userid := mp.Userid conv := models.HubConversation{ Hubid: hubID, Mileruserid: &userid, Participantname: mp.Displayname, Participantrole: "Miler", } db.DB.Create(&conv) } } // GetHubMessages returns the hub's conversation list, ordered by most recent // activity, with each conversation's last message and unread count. func GetHubMessages(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) ensureHubConversations(hubID) var conversations []models.HubConversation if err := db.DB.Where("hubid = ?", hubID).Order("updatedat DESC").Find(&conversations).Error; err != nil { return utils.Internal(c, "failed to fetch conversations") } response := make([]fiber.Map, 0, len(conversations)) for _, conv := range conversations { var lastMsg models.HubMessage lastMessageText := "" lastTime := conv.Createdat if db.DB.Where("hubconversationid = ?", conv.Hubconversationid). Order("createdat DESC").First(&lastMsg).Error == nil { lastMessageText = lastMsg.Messagetext lastTime = lastMsg.Createdat } var unread int64 db.DB.Model(&models.HubMessage{}). Where("hubconversationid = ? AND sender = 'them' AND isread = false", conv.Hubconversationid). Count(&unread) response = append(response, fiber.Map{ "id": conv.Hubconversationid, "name": conv.Participantname, "lastmessage": lastMessageText, "time": humanizeRelativeTime(lastTime), "unread": unread, }) } return utils.List(c, response, int64(len(response))) } // GetHubMessageThread returns one conversation's full message history. func GetHubMessageThread(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) id, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid conversation id") } var conv models.HubConversation if err := db.DB.Where("hubconversationid = ? AND hubid = ?", id, hubID).First(&conv).Error; err != nil { return utils.NotFound(c, "conversation not found") } var messages []models.HubMessage db.DB.Where("hubconversationid = ?", id).Order("createdat ASC").Find(&messages) msgResponse := make([]fiber.Map, 0, len(messages)) for _, m := range messages { msgResponse = append(msgResponse, fiber.Map{ "sender": m.Sender, "text": m.Messagetext, "time": m.Createdat.Local().Format("3:04 PM"), }) } return utils.OK(c, fiber.Map{ "id": conv.Hubconversationid, "name": conv.Participantname, "messages": msgResponse, }) } // SendHubMessage posts a reply from the logged-in hub staff into a conversation. func SendHubMessage(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) staffID := c.Locals("userid").(int) id, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid conversation id") } type SendMessageRequest struct { Text string `json:"text"` } req := new(SendMessageRequest) if err := c.BodyParser(req); err != nil { return utils.BadRequest(c, "invalid request body") } if strings.TrimSpace(req.Text) == "" { return utils.BadRequest(c, "text is required") } var conv models.HubConversation if err := db.DB.Where("hubconversationid = ? AND hubid = ?", id, hubID).First(&conv).Error; err != nil { return utils.NotFound(c, "conversation not found") } message := models.HubMessage{ Hubconversationid: id, Sender: "me", Senderstaffid: &staffID, Messagetext: req.Text, Isread: true, } if err := db.DB.Create(&message).Error; err != nil { return utils.Internal(c, "failed to send message") } conv.Updatedat = time.Now() db.DB.Save(&conv) return utils.OK(c, fiber.Map{ "sender": "me", "text": message.Messagetext, "time": message.Createdat.Local().Format("3:04 PM"), }) } // MarkHubMessagesRead clears the unread flag on every inbound ("them") // message in a conversation. func MarkHubMessagesRead(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) id, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid conversation id") } var conv models.HubConversation if err := db.DB.Where("hubconversationid = ? AND hubid = ?", id, hubID).First(&conv).Error; err != nil { return utils.NotFound(c, "conversation not found") } db.DB.Model(&models.HubMessage{}). Where("hubconversationid = ? AND sender = 'them' AND isread = false", id). Update("isread", true) return utils.Message(c, "messages marked as read") } // -------------------- // 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, "lat": h.Latitude, "lon": h.Longitude, "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) } // -------------------- // IN-TRANSIT TRACKING & INBOUND VEHICLES // -------------------- // GetInTransitTripsheets returns tripsheets currently moving to/from this hub, // with a linearly-interpolated position along the source→dest line since we // don't have real truck GPS yet. Swap in real coordinates later — shape stays. func GetInTransitTripsheets(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) var tripsheets []models.Tripsheet if err := db.DB.Where("(sourcehubid = ? OR destinationhubid = ?) AND status IN ? AND deletedat IS NULL", hubID, hubID, []string{constants.TripsheetDispatched, "In_Transit"}). Order("dispatchtime DESC").Find(&tripsheets).Error; err != nil { return utils.Internal(c, "failed to fetch in-transit tripsheets") } response := make([]fiber.Map, 0, len(tripsheets)) for _, ts := range tripsheets { var source, dest models.Hub db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source) db.DB.Where("hubid = ?", ts.Destinationhubid).First(&dest) var vehicleNo string if ts.Vehicleid != nil { var v models.Vehicle if db.DB.Where("vehicleid = ?", *ts.Vehicleid).First(&v).Error == nil { vehicleNo = v.Vehicleno } } var itemCount int64 db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&itemCount) elapsedMinutes := 0.0 if ts.Dispatchtime != nil { elapsedMinutes = time.Since(*ts.Dispatchtime).Minutes() } progressPct := int(math.Min(95, (elapsedMinutes/defaultJourneyMinutes)*100)) if progressPct < 0 { progressPct = 0 } etaMinutes := int(defaultJourneyMinutes - elapsedMinutes) if etaMinutes < 0 { etaMinutes = 0 } frac := float64(progressPct) / 100.0 currentLat := source.Latitude + (dest.Latitude-source.Latitude)*frac currentLon := source.Longitude + (dest.Longitude-source.Longitude)*frac response = append(response, fiber.Map{ "tripsheetid": ts.Tripsheetid, "tripsheetno": ts.Tripsheetno, "label": fmt.Sprintf("%s → %s", source.Hubname, dest.Hubname), "originlat": source.Latitude, "originlon": source.Longitude, "destlat": dest.Latitude, "destlon": dest.Longitude, "currentlat": currentLat, "currentlon": currentLon, "status": ts.Status, "progresspct": progressPct, "vehicleno": vehicleNo, "itemcount": itemCount, "dispatchtime": ts.Dispatchtime, "eta_minutes": etaMinutes, }) } return utils.List(c, response, int64(len(response))) } // GetInboundVehicles returns vehicles arriving at or expected at this hub. func GetInboundVehicles(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) var tripsheets []models.Tripsheet if err := db.DB.Where("destinationhubid = ? AND status IN ? AND deletedat IS NULL", hubID, []string{constants.TripsheetDispatched, "In_Transit", constants.TripsheetArrived}). Order("dispatchtime DESC").Limit(20).Find(&tripsheets).Error; err != nil { return utils.Internal(c, "failed to fetch inbound vehicles") } response := make([]fiber.Map, 0, len(tripsheets)) for _, ts := range tripsheets { var source models.Hub db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source) var vehicleNo, vehicleType string if ts.Vehicleid != nil { var v models.Vehicle if db.DB.Where("vehicleid = ?", *ts.Vehicleid).First(&v).Error == nil { vehicleNo = v.Vehicleno vehicleType = v.Vehicletype } } var totalItems, unloadedItems int64 db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&totalItems) status := "On the way" unloadedPct := 0 if ts.Status == constants.TripsheetArrived { status = "Unloading" db.DB.Model(&models.TripsheetItem{}). Where("tripsheetid = ? AND scanstatus = ?", ts.Tripsheetid, constants.ScanUnloaded). Count(&unloadedItems) if totalItems > 0 { unloadedPct = int(float64(unloadedItems) / float64(totalItems) * 100) } } eta := "Arrived" if ts.Status != constants.TripsheetArrived && ts.Dispatchtime != nil { remaining := defaultJourneyMinutes - int(time.Since(*ts.Dispatchtime).Minutes()) if remaining < 0 { remaining = 0 } eta = fmt.Sprintf("%d hrs %d min", remaining/60, remaining%60) } response = append(response, fiber.Map{ "vehicleno": vehicleNo, "vehicletype": vehicleType, "origin": source.Hubname, "tripsheetid": ts.Tripsheetid, "status": status, "eta": eta, "unloadedpct": unloadedPct, "itemcount": totalItems, }) } return utils.List(c, response, int64(len(response))) } // -------------------- // HUB ACTIVITY FEED // -------------------- type activityEntry struct { Time time.Time `gorm:"column:time"` Type string `gorm:"column:type"` Text string `gorm:"column:text"` } // GetHubActivity aggregates real events from 4 sources (inbound scans, // dispatches, exceptions, miler assignments) into one timeline. func GetHubActivity(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) limit, err := strconv.Atoi(c.Query("limit", "10")) if err != nil || limit <= 0 { limit = 10 } midnight := todayMidnight() // Source 4 (miler assignments) is scoped by mp.hubid via a join — the // ticket's version had no hub filter at all, which would leak every // hub's assignment activity into every other hub's feed. query := ` (SELECT c.updatedat AS time, 'inbound' AS type, ('Received parcel ' || c.trackingno || ' from ' || COALESCE(oh.hubname, 'Direct Pickup')) AS text FROM consignments c LEFT JOIN hubs oh ON c.originhubid = oh.hubid WHERE c.currenthubid = ? AND c.status = ? AND c.updatedat > ?) UNION ALL (SELECT t.dispatchtime AS time, 'dispatch' AS type, ('Dispatched batch ' || t.tripsheetno || ' to ' || COALESCE(t.destinationlabel, '')) AS text FROM tripsheets t WHERE t.sourcehubid = ? AND t.dispatchtime > ?) UNION ALL (SELECT ce.createdat AS time, 'exception' AS type, ('Exception raised: ' || ce.exceptiontype || ' on ' || c.trackingno) AS text FROM consignmentexceptions ce JOIN consignments c ON ce.consignmentid = c.consignmentid WHERE ce.hubid = ? AND ce.createdat > ?) UNION ALL (SELECT ba.assignedat AS time, 'sorting' AS type, ('Assigned booking #' || ba.bookingid || ' to miler ' || mp.displayname) AS text FROM bookingassignments ba JOIN milerprofiles mp ON ba.mileruserid = mp.userid WHERE mp.hubid = ? AND ba.assignedat > ?) ORDER BY time DESC LIMIT ? ` var entries []activityEntry if err := db.DB.Raw(query, hubID, constants.ConsignmentInwardedAtHub, midnight, hubID, midnight, hubID, midnight, hubID, midnight, limit, ).Scan(&entries).Error; err != nil { return utils.Internal(c, "failed to fetch hub activity") } response := make([]fiber.Map, 0, len(entries)) for _, e := range entries { response = append(response, fiber.Map{ "time": e.Time, "type": e.Type, "text": e.Text, }) } return utils.List(c, response, int64(len(response))) } // -------------------- // DELIVERY ZONES // -------------------- // GetHubZones returns delivery zone breakdown for this hub's milers, falling // back to static known zones for the hub's city when there's too little // assignment data yet (new hub, few bookings). func GetHubZones(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) type zoneRow struct { Zone string `gorm:"column:zone"` Milers int64 `gorm:"column:milers"` Parcels int64 `gorm:"column:parcels"` Status string `gorm:"column:status"` } // ba.pickuppincode / ba.status don't exist on bookingassignments — // pincode lives on pickupbookings (joined here) and the real status // column is assignmentstatus. query := ` SELECT pb.pickuppincode AS zone, COUNT(DISTINCT ba.mileruserid) AS milers, COUNT(ba.bookingassignmentid) AS parcels, CASE WHEN COUNT(DISTINCT ba.mileruserid) = 0 THEN 'Need Milers' ELSE 'Active' END AS status FROM bookingassignments ba JOIN milerprofiles mp ON ba.mileruserid = mp.userid JOIN pickupbookings pb ON pb.bookingid = ba.bookingid WHERE mp.hubid = ? AND ba.assignmentstatus IN ? GROUP BY pb.pickuppincode ORDER BY parcels DESC LIMIT 10 ` var rows []zoneRow db.DB.Raw(query, hubID, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}).Scan(&rows) seen := make(map[string]bool) response := make([]fiber.Map, 0, len(rows)) for _, r := range rows { seen[r.Zone] = true response = append(response, fiber.Map{ "zone": r.Zone, "zonename": zoneName(r.Zone), "parcels": r.Parcels, "milers": r.Milers, "status": r.Status, }) } if len(response) < 3 { prefix := hubPincodePrefix(hubID) for pincode := range zoneNames { if strings.HasPrefix(pincode, prefix) && !seen[pincode] { response = append(response, fiber.Map{ "zone": pincode, "zonename": zoneName(pincode), "parcels": 0, "milers": 0, "status": "Need Milers", }) } } } return utils.List(c, response, int64(len(response))) } // -------------------- // NOTIFICATIONS (synthetic — no dedicated table yet) // -------------------- type notificationEntry struct { Title string Type string Time time.Time } // GetHubNotifications generates notifications from real events across 4 // sources since there's no dedicated notifications table yet. func GetHubNotifications(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) midnight := todayMidnight() last24h := time.Now().Add(-24 * time.Hour) var notifications []notificationEntry var exceptions []models.ConsignmentException db.DB.Where("hubid = ? AND createdat > ?", hubID, last24h).Find(&exceptions) for _, e := range exceptions { notifications = append(notifications, notificationEntry{ Title: "Exception: " + e.Exceptiontype, Type: "exception", Time: e.Createdat, }) } var inbound []models.Tripsheet db.DB.Where("destinationhubid = ? AND status IN ?", hubID, []string{constants.TripsheetDispatched, "In_Transit"}).Find(&inbound) for _, ts := range inbound { var source models.Hub db.DB.Where("hubid = ?", ts.Sourcehubid).First(&source) t := ts.Updatedat if ts.Dispatchtime != nil { t = *ts.Dispatchtime } notifications = append(notifications, notificationEntry{ Title: "Truck arriving from " + source.Hubname, Type: "inbound", Time: t, }) } var offlineMilers []models.MilerProfile db.DB.Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerOffline).Find(&offlineMilers) for _, mp := range offlineMilers { var hadActivityToday int64 db.DB.Model(&models.BookingAssignment{}).Where("mileruserid = ? AND assignedat >= ?", mp.Userid, midnight).Count(&hadActivityToday) if hadActivityToday > 0 { notifications = append(notifications, notificationEntry{ Title: "Miler " + mp.Displayname + " went offline", Type: "warning", Time: mp.Updatedat, }) } } prefix := hubPincodePrefix(hubID) thirtyMinAgo := time.Now().Add(-30 * time.Minute) var stalePending []models.PickupBooking staleQuery := db.DB.Where("assignedmileruserid IS NULL AND status = ? AND createdat < ?", constants.BookingPendingPickup, thirtyMinAgo) if prefix != "" { staleQuery = staleQuery.Where("pickuppincode LIKE ?", prefix+"%") } staleQuery.Find(&stalePending) for _, b := range stalePending { notifications = append(notifications, notificationEntry{ Title: "Pickup waiting 30+ min", Type: "alert", Time: b.Createdat, }) } sort.Slice(notifications, func(i, j int) bool { return notifications[i].Time.After(notifications[j].Time) }) if len(notifications) > 20 { notifications = notifications[:20] } response := make([]fiber.Map, 0, len(notifications)) for i, n := range notifications { response = append(response, fiber.Map{ "id": i + 1, "title": n.Title, "type": n.Type, "time": humanizeRelativeTime(n.Time), "read": false, }) } return utils.List(c, response, int64(len(response))) } // MarkNotificationRead is a stub until a notifications table with read-state // exists — always succeeds without persisting anything. func MarkNotificationRead(c *fiber.Ctx) error { return c.JSON(fiber.Map{"success": true}) } // -------------------- // PARCEL ROUTING (SCAN) // -------------------- // resolveNextHop compares the delivery pincode's city prefix against the // requesting hub's own city prefix to decide local delivery vs. transfer. func resolveNextHop(destPrefix, myPrefix string) string { if destPrefix == "" || destPrefix == myPrefix { return "Local delivery" } var hub models.Hub if db.DB.Where("pincode LIKE ?", destPrefix+"%").First(&hub).Error == nil { return "Transfer to " + hub.Hubname } return "Transfer to Regional Hub" } func recommendShelfForRouting(condition string, isColdChain bool, nextHop string) string { switch { case strings.Contains(condition, "Damaged"): return "Exception Area" case isColdChain: return "Zone C (Cold Room)" case nextHop == "Local delivery": return "Zone B" default: return "Zone A" } } // GetRoutingInfo scans a parcel and returns its sort destination, checking // both consignments.trackingno and pickupbookings.bookingno (a booking not // yet converted to a consignment has no trackingno of its own). func GetRoutingInfo(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) trackingNo := c.Params("trackingno") myPrefix := hubPincodePrefix(hubID) var consignment models.Consignment if db.DB.Where("trackingno = ?", trackingNo).First(&consignment).Error == nil { destPrefix := "" if len(consignment.Deliverypincode) >= 3 { destPrefix = consignment.Deliverypincode[:3] } nextHop := resolveNextHop(destPrefix, myPrefix) shelf := consignment.Shelf if shelf == "" { shelf = recommendShelfForRouting(consignment.Condition, false, nextHop) } return utils.OK(c, fiber.Map{ "trackingno": consignment.Trackingno, "destination": consignment.Deliverypincode, "recommendedshelf": shelf, "nexthop": nextHop, "condition": consignment.Condition, "iscoldchain": false, "weight": fmt.Sprintf("%.1f kg", consignment.Chargeableweight), // no FK from consignments back to a customer/booking record "customername": "", "bookingid": nil, }) } var booking models.PickupBooking found := db.DB.Where("bookingno = ?", trackingNo).First(&booking).Error == nil if !found { if id, err := strconv.Atoi(trackingNo); err == nil { found = db.DB.Where("bookingid = ?", id).First(&booking).Error == nil } } if !found { return utils.NotFound(c, "tracking number not found") } destPrefix := "" if len(booking.Deliverypincode) >= 3 { destPrefix = booking.Deliverypincode[:3] } nextHop := resolveNextHop(destPrefix, myPrefix) shelf := recommendShelfForRouting("", false, nextHop) var customer models.AppCustomer db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer) customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname) weight := "N/A" var parcel models.BookingParcel if db.DB.Where("bookingid = ?", booking.Bookingid).First(&parcel).Error == nil { weight = fmt.Sprintf("%.1f kg", parcel.Weight) } return utils.OK(c, fiber.Map{ "trackingno": booking.Bookingno, "destination": fmt.Sprintf("%s, %s", booking.Deliveryaddress, booking.Deliverypincode), "recommendedshelf": shelf, "nexthop": nextHop, "condition": "Pending Scan", "iscoldchain": false, "weight": weight, "customername": customerName, "bookingid": booking.Bookingid, }) } // -------------------- // RIDER ROUTES // -------------------- // buildMilerRoute computes today's planned stops for one miler. eta_minutes // for pending stops is a simple 15-min-per-stop placeholder until a real // routing engine is wired up — same spirit as the in-transit interpolation. func buildMilerRoute(mp models.MilerProfile) fiber.Map { var assignments []models.BookingAssignment db.DB.Where("mileruserid = ? AND assignedat >= ?", mp.Userid, todayMidnight()). Order("assignedat ASC").Find(&assignments) stops := make([]fiber.Map, 0, len(assignments)) var coords [][2]float64 completedCount := 0 pendingEtaMinutes := 0 for i, a := range assignments { var booking models.PickupBooking if db.DB.Where("bookingid = ?", a.Bookingid).First(&booking).Error != nil { continue } itemCount := 0 var parcel models.BookingParcel if db.DB.Where("bookingid = ?", a.Bookingid).First(&parcel).Error == nil { itemCount = 1 } status := "pending" switch booking.Status { case constants.BookingPickedUp, constants.BookingConvertedConsignment: status = "completed" case constants.BookingPickupScheduled: status = "in_progress" } etaMinutes := 0 if status == "completed" { completedCount++ } else { pendingEtaMinutes += 15 etaMinutes = pendingEtaMinutes } coords = append(coords, [2]float64{booking.Pickuplatitude, booking.Pickuplongitude}) stop := fiber.Map{ "seq": i + 1, "address": booking.Pickupaddress, "lat": booking.Pickuplatitude, "lon": booking.Pickuplongitude, "items": itemCount, "bookingid": booking.Bookingid, "status": status, "eta_minutes": etaMinutes, } // Optional enrichment: only set when real data backs it, so the // frontend's "hide when absent" handling never sees a fake zero. if itemCount == 1 { stop["weight"] = fmt.Sprintf("%.1f kg", parcel.Weight) } var payment models.BookingPayment if db.DB.Where("bookingid = ?", a.Bookingid).First(&payment).Error == nil && payment.Amount > 0 { stop["cod"] = payment.Amount } if booking.Preferredpickupfrom != nil && booking.Preferredpickupto != nil { stop["timeslot"] = fmt.Sprintf("%s–%s", booking.Preferredpickupfrom.Format("15"), booking.Preferredpickupto.Format("15")) } if booking.Notes != "" { stop["instructions"] = booking.Notes } if n := len(coords); n > 1 { leg := haversineKM(coords[n-2][0], coords[n-2][1], coords[n-1][0], coords[n-1][1]) stop["legdistance_km"] = math.Round(leg*10) / 10 } stops = append(stops, stop) } totalDistance := 0.0 for i := 1; i < len(coords); i++ { totalDistance += haversineKM(coords[i-1][0], coords[i-1][1], coords[i][0], coords[i][1]) } return fiber.Map{ "mileruserid": mp.Userid, "milername": mp.Displayname, "mode": "pickup", "totalstops": len(stops), "completedstops": completedCount, "totaldistance_km": math.Round(totalDistance*10) / 10, "stops": stops, } } // GetMilerRoute returns today's planned stops for a specific miler (:id is // the mileruserid, matching bookingassignments.mileruserid). func GetMilerRoute(c *fiber.Ctx) error { id, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid miler id") } var mp models.MilerProfile if err := db.DB.Where("userid = ?", id).First(&mp).Error; err != nil { return utils.NotFound(c, "miler not found") } return utils.OK(c, buildMilerRoute(mp)) } // GetAllRiderRoutes returns the same route structure for every miler at this // hub today, for the Rider Routes overview page. func GetAllRiderRoutes(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") } routes := make([]fiber.Map, 0, len(profiles)) for _, mp := range profiles { routes = append(routes, buildMilerRoute(mp)) } return utils.List(c, routes, int64(len(routes))) } // -------------------- // HUB-SCOPED ASSIGNMENT (manual override + auto-assign) // -------------------- // HubAssignMiler is the hub console's manual assignment override — hub staff // (role 6) can't call POST /admin/bookings/:id/assign-miler (admin-only) or // rely on POST /internal/bookings/:id/reassign (requires an already-assigned // booking), so this exists for a hub-scoped path onto the same shared // AssignMilerToBooking logic AdminAssignMiler uses. func HubAssignMiler(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) bookingID, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid booking id") } type AssignRequest struct { Mileruserid int `json:"mileruserid"` } req := new(AssignRequest) if err := c.BodyParser(req); err != nil { return utils.BadRequest(c, "invalid request body") } if req.Mileruserid == 0 { return utils.BadRequest(c, "mileruserid is required") } var booking models.PickupBooking if err := db.DB.First(&booking, bookingID).Error; err != nil { return utils.NotFound(c, "booking not found") } if !assignableBookingStatuses[booking.Status] { return utils.BadRequest(c, "booking is not in an assignable state") } var miler models.MilerProfile if err := db.DB.Where("userid = ? AND hubid = ?", req.Mileruserid, hubID).First(&miler).Error; err != nil { return utils.Forbidden(c, "miler does not belong to this hub") } // assignedbyuserid carries a real FK to appusers(userid); hub staff // accounts live in a separate hubstaffaccounts id space, so passing that // id here would violate the constraint (or worse, silently misattribute // to whatever appuser happens to share that id). Pass nil instead — the // field is nullable and this is the honest option until hub staff get a // mirrored appusers row. updatedBooking, err := AssignMilerToBooking(bookingID, req.Mileruserid, nil) if err != nil { return utils.Internal(c, "failed to assign miler") } return c.JSON(fiber.Map{ "success": true, "message": "miler assigned", "data": fiber.Map{ "bookingid": updatedBooking.Bookingid, "mileruserid": req.Mileruserid, "milername": miler.Displayname, "status": updatedBooking.Status, }, }) } // HubAutoAssign triggers the AI assignment engine for one booking that wasn't // automatically assigned at creation time. It runs TryAssignOnce (a single // GEOSEARCH + AI-decision + commit attempt, no retry loop) in a goroutine and // waits up to hubAutoAssignTimeout — long enough for the typical sub-second // Redis GEO query plus the AI layer's own 5s HTTP timeout, but bounded so the // HTTP handler never hangs indefinitely. If it doesn't finish in time, the // goroutine keeps running in the background and the assignment still lands. func HubAutoAssign(c *fiber.Ctx) error { bookingID, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid booking id") } var booking models.PickupBooking if err := db.DB.First(&booking, bookingID).Error; err != nil { return utils.NotFound(c, "booking not found") } if !assignableBookingStatuses[booking.Status] { return utils.BadRequest(c, "booking is not in an assignable state") } type outcome struct { res assignment.AutoAssignResult err error } resultCh := make(chan outcome, 1) go func() { res, err := assignment.TryAssignOnce(bookingID) resultCh <- outcome{res, err} }() select { case out := <-resultCh: if out.err != nil { return utils.Internal(c, "assignment failed: "+out.err.Error()) } if !out.res.Assigned { return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{ "success": false, "message": "No eligible miler found in range", "data": fiber.Map{ "searched_radius_km": out.res.SearchedRadiusKm, "candidates_found": out.res.CandidatesFound, "reasoning": out.res.Reasoning, }, }) } return c.JSON(fiber.Map{ "success": true, "data": fiber.Map{ "mileruserid": out.res.MilerUserID, "milername": out.res.MilerName, "distance_km": out.res.DistanceKm, // no confidence field: the AI decision engine's response has // no such value anywhere in this pipeline to report honestly. "reasoning": out.res.Reasoning, }, }) case <-time.After(hubAutoAssignTimeout): return c.Status(fiber.StatusAccepted).JSON(fiber.Map{ "success": true, "message": "assignment in progress", }) } } // -------------------- // HUB REPORT EXPORT // -------------------- // reportActivityLimit caps the activity timeline included in a report so a // wide date range can't return an unbounded result set. const reportActivityLimit = 500 // GetHubReport builds the data backing the dashboard's Export button: a // summary plus per-section breakdowns, each filtered to [from, to] (inclusive // day range) and scoped to the requesting hub. func GetHubReport(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) fromStr := c.Query("from") toStr := c.Query("to") if fromStr == "" || toStr == "" { return utils.BadRequest(c, "from and to query params are required (YYYY-MM-DD)") } from, err := time.ParseInLocation("2006-01-02", fromStr, time.Local) if err != nil { return utils.BadRequest(c, "invalid from date, expected YYYY-MM-DD") } toDate, err := time.ParseInLocation("2006-01-02", toStr, time.Local) if err != nil { return utils.BadRequest(c, "invalid to date, expected YYYY-MM-DD") } to := toDate.Add(24*time.Hour - time.Nanosecond) if to.Before(from) { return utils.BadRequest(c, "to date must not be before from date") } // ---- summary ---- var parcelsReceived int64 db.DB.Model(&models.ConsignmentHistory{}). Where("hubid = ? AND eventstatus = ? AND createdat BETWEEN ? AND ?", hubID, constants.ConsignmentInwardedAtHub, from, to). Count(&parcelsReceived) var batchesDispatched int64 db.DB.Model(&models.Tripsheet{}). Where("sourcehubid = ? AND dispatchtime BETWEEN ? AND ? AND deletedat IS NULL", hubID, from, to). Count(&batchesDispatched) var ordersAssigned int64 db.DB.Model(&models.BookingAssignment{}). Joins("JOIN milerprofiles mp ON mp.userid = bookingassignments.mileruserid"). Where("mp.hubid = ? AND bookingassignments.assignedat BETWEEN ? AND ?", hubID, from, to). Count(&ordersAssigned) var exceptionsCount int64 db.DB.Model(&models.ConsignmentException{}). Where("hubid = ? AND createdat BETWEEN ? AND ?", hubID, from, to). Count(&exceptionsCount) var codCollected float64 db.DB.Model(&models.BookingPayment{}). Joins("JOIN milerprofiles mp ON mp.userid = bookingpayments.collectedbyuserid"). Where("mp.hubid = ? AND bookingpayments.paymentstatus = ? AND bookingpayments.createdat BETWEEN ? AND ?", hubID, constants.PaymentStatusPaid, from, to). Select("COALESCE(SUM(bookingpayments.amount), 0)").Scan(&codCollected) // ---- inbound: sourced from consignmenthistory, not consignments.status, // so a parcel that has since moved past "Inwarded_at_Hub" still shows up // for the day it actually arrived. ---- var inboundHistory []models.ConsignmentHistory db.DB.Where("hubid = ? AND eventstatus = ? AND createdat BETWEEN ? AND ?", hubID, constants.ConsignmentInwardedAtHub, from, to). Order("createdat ASC").Find(&inboundHistory) inbound := make([]fiber.Map, 0, len(inboundHistory)) for _, h := range inboundHistory { var cs models.Consignment if db.DB.Where("consignmentid = ?", h.Consignmentid).First(&cs).Error != nil { continue } originName := fmt.Sprintf("Direct pickup (%s)", cs.Pickuppincode) if cs.Originhubid != nil { var oh models.Hub if db.DB.Where("hubid = ?", *cs.Originhubid).First(&oh).Error == nil { originName = oh.Hubname } } inbound = append(inbound, fiber.Map{ "date": h.Createdat.Format("2006-01-02"), // no FK from consignments back to a customer/booking record "bookingid": nil, "trackingnumber": cs.Trackingno, "sendername": "", "origin": originName, "destination": cs.Deliverypincode, "weight": fmt.Sprintf("%.1f kg", cs.Chargeableweight), "condition": cs.Condition, "shelf": cs.Shelf, "inboundedat": h.Createdat, }) } // ---- dispatch ---- var tripsheets []models.Tripsheet db.DB.Where("sourcehubid = ? AND dispatchtime BETWEEN ? AND ? AND deletedat IS NULL", hubID, from, to). Order("dispatchtime ASC").Find(&tripsheets) dispatch := make([]fiber.Map, 0, len(tripsheets)) for _, ts := range tripsheets { var vehicleNo string if ts.Vehicleid != nil { var v models.Vehicle if db.DB.Where("vehicleid = ?", *ts.Vehicleid).First(&v).Error == nil { vehicleNo = v.Vehicleno } } var itemCount int64 db.DB.Model(&models.TripsheetItem{}).Where("tripsheetid = ?", ts.Tripsheetid).Count(&itemCount) date := "" if ts.Dispatchtime != nil { date = ts.Dispatchtime.Format("2006-01-02") } dispatch = append(dispatch, fiber.Map{ "date": date, "batchlabel": ts.Batchlabel, "kind": ts.Batchkind, "destination": ts.Destinationlabel, "vehicle": vehicleNo, "itemcount": itemCount, "status": ts.Status, "dispatchtime": ts.Dispatchtime, }) } // ---- assignments ---- var assignments []models.BookingAssignment db.DB.Joins("JOIN milerprofiles mp ON mp.userid = bookingassignments.mileruserid"). Where("mp.hubid = ? AND bookingassignments.assignedat BETWEEN ? AND ?", hubID, from, to). Order("bookingassignments.assignedat ASC").Find(&assignments) assignmentRows := make([]fiber.Map, 0, len(assignments)) for _, a := range assignments { var booking models.PickupBooking if db.DB.Where("bookingid = ?", a.Bookingid).First(&booking).Error != nil { continue } var customer models.AppCustomer db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer) var miler models.MilerProfile db.DB.Where("userid = ?", a.Mileruserid).First(&miler) assignmentRows = append(assignmentRows, fiber.Map{ "date": a.Assignedat.Format("2006-01-02"), "bookingid": a.Bookingid, "customer": strings.TrimSpace(customer.Firstname + " " + customer.Lastname), "milername": miler.Displayname, "pickup": zoneName(booking.Pickuppincode), "delivery": booking.Deliverycity, "status": booking.Status, }) } // ---- exceptions ---- var exceptions []models.ConsignmentException db.DB.Where("hubid = ? AND createdat BETWEEN ? AND ?", hubID, from, to). Order("createdat ASC").Find(&exceptions) exceptionRows := make([]fiber.Map, 0, len(exceptions)) for _, ex := range exceptions { var cs models.Consignment db.DB.Where("consignmentid = ?", ex.Consignmentid).First(&cs) reason := ex.Description if reason == "" { reason = ex.Exceptiontype } exceptionRows = append(exceptionRows, fiber.Map{ "date": ex.Createdat.Format("2006-01-02"), "trackingnumber": cs.Trackingno, "reason": reason, "shelf": cs.Shelf, }) } // ---- activity: same 4-source union as GetHubActivity, windowed to the // report's date range instead of "today". ---- activityQuery := ` (SELECT c.updatedat AS time, 'inbound' AS type, ('Received parcel ' || c.trackingno || ' from ' || COALESCE(oh.hubname, 'Direct Pickup')) AS text FROM consignments c LEFT JOIN hubs oh ON c.originhubid = oh.hubid WHERE c.currenthubid = ? AND c.status = ? AND c.updatedat BETWEEN ? AND ?) UNION ALL (SELECT t.dispatchtime AS time, 'dispatch' AS type, ('Dispatched batch ' || t.tripsheetno || ' to ' || COALESCE(t.destinationlabel, '')) AS text FROM tripsheets t WHERE t.sourcehubid = ? AND t.dispatchtime BETWEEN ? AND ?) UNION ALL (SELECT ce.createdat AS time, 'exception' AS type, ('Exception raised: ' || ce.exceptiontype || ' on ' || c.trackingno) AS text FROM consignmentexceptions ce JOIN consignments c ON ce.consignmentid = c.consignmentid WHERE ce.hubid = ? AND ce.createdat BETWEEN ? AND ?) UNION ALL (SELECT ba.assignedat AS time, 'sorting' AS type, ('Assigned booking #' || ba.bookingid || ' to miler ' || mp.displayname) AS text FROM bookingassignments ba JOIN milerprofiles mp ON ba.mileruserid = mp.userid WHERE mp.hubid = ? AND ba.assignedat BETWEEN ? AND ?) ORDER BY time ASC LIMIT ? ` var activityEntries []activityEntry db.DB.Raw(activityQuery, hubID, constants.ConsignmentInwardedAtHub, from, to, hubID, from, to, hubID, from, to, hubID, from, to, reportActivityLimit, ).Scan(&activityEntries) activity := make([]fiber.Map, 0, len(activityEntries)) for _, e := range activityEntries { activity = append(activity, fiber.Map{ "time": e.Time, "type": e.Type, "text": e.Text, }) } return utils.OK(c, fiber.Map{ "range": fiber.Map{ "from": fromStr, "to": toStr, }, "summary": fiber.Map{ "parcels_received": parcelsReceived, "batches_dispatched": batchesDispatched, "orders_assigned": ordersAssigned, "exceptions": exceptionsCount, "cod_collected": codCollected, }, "inbound": inbound, "dispatch": dispatch, "assignments": assignmentRows, "exceptions": exceptionRows, "activity": activity, }) }