Files
doormile_backend/controllers/hubController.go

1973 lines
62 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <code>".
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,
})
}