new changes in hub api messages

This commit is contained in:
2026-07-07 12:47:26 +05:30
parent 707323b68c
commit 808ac953a1
4 changed files with 500 additions and 2 deletions

View File

@@ -218,6 +218,12 @@ func GetHubDashboardStats(c *fiber.Ctx) error {
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,
@@ -225,6 +231,8 @@ func GetHubDashboardStats(c *fiber.Ctx) error {
"pending_pickups": pendingPickups,
"batches_sent_today": batchesSentToday,
"exceptions": exceptions,
"parcels_sorted": parcelsReceivedToday,
"sorting_target": hub.Capacity,
})
}
@@ -606,6 +614,14 @@ func GetHubMilers(c *fiber.Ctx) error {
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,
@@ -614,6 +630,7 @@ func GetHubMilers(c *fiber.Ctx) error {
"vehicleid": mp.Vehicleid,
"hubid": mp.Hubid,
"defaultvehicletype": mp.Defaultvehicletype,
"vehicleno": vehicleNo,
"currentlatitude": mp.Currentlatitude,
"currentlongitude": mp.Currentlongitude,
"currentpincode": mp.Currentpincode,
@@ -636,6 +653,174 @@ func GetHubMilers(c *fiber.Ctx) error {
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)
// --------------------
@@ -1317,7 +1502,7 @@ func buildMilerRoute(mp models.MilerProfile) fiber.Map {
coords = append(coords, [2]float64{booking.Pickuplatitude, booking.Pickuplongitude})
stops = append(stops, fiber.Map{
stop := fiber.Map{
"seq": i + 1,
"address": booking.Pickupaddress,
"lat": booking.Pickuplatitude,
@@ -1326,7 +1511,30 @@ func buildMilerRoute(mp models.MilerProfile) fiber.Map {
"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
@@ -1514,3 +1722,251 @@ func HubAutoAssign(c *fiber.Ctx) error {
})
}
}
// --------------------
// 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,
})
}