From 808ac953a1e8e5bac20b895eb1a864b04f764adf Mon Sep 17 00:00:00 2001 From: Suriya Date: Tue, 7 Jul 2026 12:47:26 +0530 Subject: [PATCH] new changes in hub api messages --- controllers/hubController.go | 460 ++++++++++++++++++++++++++++++++++- migrations/migrate.go | 2 + models/audit.go | 33 +++ routes/routes.go | 7 + 4 files changed, 500 insertions(+), 2 deletions(-) diff --git a/controllers/hubController.go b/controllers/hubController.go index c46ae6e..a795a07 100644 --- a/controllers/hubController.go +++ b/controllers/hubController.go @@ -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, + }) +} diff --git a/migrations/migrate.go b/migrations/migrate.go index 3c42775..ca40615 100644 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -42,6 +42,8 @@ func Migrate(db *gorm.DB) error { &models.DoormilePricing{}, &models.AgentDecision{}, &models.HubStaffAccount{}, + &models.HubConversation{}, + &models.HubMessage{}, ) if err != nil { diff --git a/models/audit.go b/models/audit.go index 8721b90..41f8e3e 100644 --- a/models/audit.go +++ b/models/audit.go @@ -116,3 +116,36 @@ type ConsignmentException struct { func (ConsignmentException) TableName() string { return "consignmentexceptions" } + +// HubConversation is a hub-scoped chat channel between hub staff and a miler +// at that hub. One conversation per (hubid, mileruserid) pair. +type HubConversation struct { + Hubconversationid int `json:"hubconversationid" gorm:"primaryKey;column:hubconversationid"` + Hubid int `json:"hubid" gorm:"column:hubid;index;not null"` + Mileruserid *int `json:"mileruserid" gorm:"column:mileruserid;index"` + Participantname string `json:"participantname" gorm:"column:participantname;not null"` + Participantrole string `json:"participantrole" gorm:"column:participantrole"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` +} + +func (HubConversation) TableName() string { + return "hubconversations" +} + +// HubMessage is one message within a HubConversation. Sender is "me" (the +// requesting hub staff) or "them" (the other party), matching the hub +// console frontend's bubble-side convention. +type HubMessage struct { + Hubmessageid int `json:"hubmessageid" gorm:"primaryKey;column:hubmessageid"` + Hubconversationid int `json:"hubconversationid" gorm:"column:hubconversationid;index;not null"` + Sender string `json:"sender" gorm:"column:sender;not null"` // me, them + Senderstaffid *int `json:"senderstaffid" gorm:"column:senderstaffid"` + Messagetext string `json:"messagetext" gorm:"column:messagetext;not null"` + Isread bool `json:"isread" gorm:"column:isread;default:false"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` +} + +func (HubMessage) TableName() string { + return "hubmessages" +} diff --git a/routes/routes.go b/routes/routes.go index fcf8d15..3809d75 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -273,6 +273,13 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { hubAuth.Get("/rider-routes", controllers.GetAllRiderRoutes) hubAuth.Get("/milers/:id/route", controllers.GetMilerRoute) + hubAuth.Get("/messages", controllers.GetHubMessages) + hubAuth.Get("/messages/:id", controllers.GetHubMessageThread) + hubAuth.Post("/messages/:id", controllers.SendHubMessage) + hubAuth.Patch("/messages/:id/read", controllers.MarkHubMessagesRead) + + hubAuth.Get("/report", controllers.GetHubReport) + // Redis active user caching utilities redisUsers := api.Group("/utils/users/redis") redisUsers.Post("/", controllers.CreateUserRedis)