package controllers // Rider-facing console reporting: the roster summary, per-rider GPS trails, // per-consignment delivery logs and the end-to-end booking track view. // // These exist because jupiter's console gave a client a live picture of their // riders — getridersummary, riderlogs, delivery logs — and the express console // had no equivalent. Doormile records all of it already; none of it was // readable from /admin. // // Every handler here is tenant-scoped: a client login sees its own fleet and // nothing else. import ( "encoding/json" "fmt" "strconv" "time" "doormile/constants" "doormile/db" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" "github.com/redis/go-redis/v9" ) // assertMilerAccess resolves a miler profile by its profile id and proves the // caller is allowed to see that rider. A rider belongs to a client through the // tenantid on their appusers row — the same link GetMilers filters on. Doormile // staff (tenant 0) skip the check. func assertMilerAccess(c *fiber.Ctx, milerProfileID int) (*models.MilerProfile, error) { var profile models.MilerProfile if err := db.DB.Where("milerprofileid = ?", milerProfileID).First(&profile).Error; err != nil { return nil, utils.NotFound(c, "miler not found") } own := consoleTenantID(c) if own == 0 { return &profile, nil } var user models.AppUser if err := db.DB.Select("userid", "tenantid").Where("userid = ?", profile.Userid).First(&user).Error; err != nil { return nil, utils.NotFound(c, "miler not found") } // Deliberately "not found" rather than "forbidden": a client should not be // able to probe which rider ids exist outside their own fleet. if user.Tenantid != own { return nil, utils.NotFound(c, "miler not found") } return &profile, nil } // visibleMilerUserIDs lists the appusers.userid of every rider the request // should cover — the caller's own fleet for a client login, or the tenant // Doormile staff asked for with ?tenantid=. Returns nil for "no restriction". func visibleMilerUserIDs(c *fiber.Ctx) ([]int, error) { tenantID, err := effectiveTenantID(c) if err != nil { return nil, err } if tenantID == 0 { return nil, nil } return milerUserIDsForTenant(tenantID), nil } // milerSummaryRow is one line of the roster table — the shape the console's // rider list renders directly. type milerSummaryRow struct { Milerprofileid int `json:"milerprofileid"` Userid int `json:"userid"` Displayname string `json:"displayname"` Phone string `json:"phone"` Availabilitystatus string `json:"availabilitystatus"` Defaultvehicletype string `json:"defaultvehicletype"` Hubid *int `json:"hubid"` Hubname string `json:"hubname"` Rating float64 `json:"rating"` Onduty bool `json:"onduty"` Dutystartedat *time.Time `json:"dutystartedat"` Currentlatitude float64 `json:"currentlatitude"` Currentlongitude float64 `json:"currentlongitude"` Lastlocationupdatedat *time.Time `json:"lastlocationupdatedat"` Lastpingat *time.Time `json:"lastpingat"` Assigned int64 `json:"assigned"` Accepted int64 `json:"accepted"` Rejected int64 `json:"rejected"` Completed int64 `json:"completed"` Cancelled int64 `json:"cancelled"` Delivered int64 `json:"delivered"` Riderkms float64 `json:"riderkms"` Ridercharges float64 `json:"ridercharges"` } // GetMilerSummary is the console's rider roster: one row per rider with their // live state and their numbers for the date range. This is the express-console // equivalent of jupiter's getridersummary. // // GET /admin/milers/summary?from=&to=&applocationid=&hubid= func GetMilerSummary(c *fiber.Ctx) error { from, to, err := parseHubDateRange(c) if err != nil { return utils.BadRequest(c, err.Error()) } query := db.DB.Model(&models.MilerProfile{}) if appLoc := c.Query("applocationid"); appLoc != "" { query = query.Where("applocationid = ?", appLoc) } if hubID := c.Query("hubid"); hubID != "" { query = query.Where("hubid = ?", hubID) } visible, verr := visibleMilerUserIDs(c) if verr != nil { return verr } if visible != nil { if len(visible) == 0 { return utils.List(c, []milerSummaryRow{}, 0) } query = query.Where("userid IN ?", visible) } var profiles []models.MilerProfile if err := query.Order("displayname").Find(&profiles).Error; err != nil { return utils.Internal(c, "failed to fetch milers") } if len(profiles) == 0 { return utils.List(c, []milerSummaryRow{}, 0) } userIDs := make([]int, 0, len(profiles)) for _, p := range profiles { userIDs = append(userIDs, p.Userid) } // One grouped query per fact rather than per rider, so the roster costs a // fixed handful of queries whatever the fleet size. type assignAgg struct { Mileruserid int `gorm:"column:mileruserid"` Status string `gorm:"column:assignmentstatus"` Cnt int64 `gorm:"column:cnt"` Riderkms float64 `gorm:"column:riderkms"` Ridercharges float64 `gorm:"column:ridercharges"` } var aggs []assignAgg db.DB.Model(&models.BookingAssignment{}). Select("mileruserid, assignmentstatus, COUNT(*) AS cnt, COALESCE(SUM(riderkms),0) AS riderkms, COALESCE(SUM(ridercharges),0) AS ridercharges"). Where("mileruserid IN ? AND assignedat BETWEEN ? AND ?", userIDs, from, to). Group("mileruserid, assignmentstatus"). Scan(&aggs) type deliveredAgg struct { Userid int `gorm:"column:userid"` Cnt int64 `gorm:"column:cnt"` } var delivered []deliveredAgg db.DB.Model(&models.ConsignmentHistory{}). Select("userid, COUNT(*) AS cnt"). Where("userid IN ? AND eventstatus = ? AND createdat BETWEEN ? AND ?", userIDs, constants.ConsignmentDelivered, from, to). Group("userid"). Scan(&delivered) var dutyLogs []models.MilerDutyLog db.DB.Where("userid IN ? AND onduty = ?", userIDs, true).Find(&dutyLogs) hubNames := map[int]string{} var hubs []models.Hub db.DB.Select("hubid", "hubname").Find(&hubs) for _, h := range hubs { hubNames[h.Hubid] = h.Hubname } rows := make([]milerSummaryRow, 0, len(profiles)) for _, p := range profiles { row := milerSummaryRow{ Milerprofileid: p.Milerprofileid, Userid: p.Userid, Displayname: p.Displayname, Phone: p.Phone, Availabilitystatus: p.Availabilitystatus, Defaultvehicletype: p.Defaultvehicletype, Hubid: p.Hubid, Rating: p.Rating, Currentlatitude: p.Currentlatitude, Currentlongitude: p.Currentlongitude, Lastlocationupdatedat: p.Lastlocationupdatedat, Lastpingat: lastTelemetryPing(p.Userid), } if p.Hubid != nil { row.Hubname = hubNames[*p.Hubid] } for _, d := range dutyLogs { if d.Userid == p.Userid { row.Onduty = true start := d.Loginat row.Dutystartedat = &start break } } for _, a := range aggs { if a.Mileruserid != p.Userid { continue } row.Riderkms += a.Riderkms row.Ridercharges += a.Ridercharges switch a.Status { case constants.AssignmentAssigned: row.Assigned += a.Cnt case constants.AssignmentAccepted: row.Accepted += a.Cnt case constants.AssignmentRejected: row.Rejected += a.Cnt case constants.AssignmentCompleted: row.Completed += a.Cnt case constants.AssignmentCancelled: row.Cancelled += a.Cnt } } for _, d := range delivered { if d.Userid == p.Userid { row.Delivered = d.Cnt break } } rows = append(rows, row) } return c.JSON(fiber.Map{ "success": true, "data": rows, "total": len(rows), "from": from.Format("2006-01-02"), "to": to.Format("2006-01-02"), }) } // lastTelemetryPing reads the timestamp of a rider's most recent periodic log // straight off the Redis index. Nil when the rider has never pinged or Redis is // unavailable — telemetry is best-effort and must never fail the roster. func lastTelemetryPing(userID int) *time.Time { if db.Rdb == nil { return nil } res, err := db.Rdb.ZRevRangeWithScores(db.Ctx, fmt.Sprintf("miler_periodic_logs:%d", userID), 0, 0).Result() if err != nil || len(res) == 0 { return nil } t := time.Unix(int64(res[0].Score), 0).UTC() return &t } // GetMilerLogs returns a rider's GPS trail for a date range — jupiter's // riderlogs, but read from the Redis telemetry index rather than a 1.17M-row // unindexed table. // // GET /admin/milers/:id/logs?from=&to=&limit= func GetMilerLogs(c *fiber.Ctx) error { id, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid miler ID") } profile, aerr := assertMilerAccess(c, id) if aerr != nil { return aerr } if db.Rdb == nil { return utils.Internal(c, "cache service unavailable") } from, to, err := parseHubDateRange(c) if err != nil { return utils.BadRequest(c, err.Error()) } limit := 500 if l := c.QueryInt("limit"); l > 0 { limit = l } if limit > 5000 { limit = 5000 } // The zset is scored by the log's own unix timestamp, so the range query is // the date filter — no scanning every key for the rider. keys, err := db.Rdb.ZRangeByScore(db.Ctx, fmt.Sprintf("miler_periodic_logs:%d", profile.Userid), &redis.ZRangeBy{ Min: strconv.FormatInt(from.Unix(), 10), Max: strconv.FormatInt(to.Unix(), 10), Count: int64(limit), }).Result() if err != nil { return utils.Internal(c, "failed to fetch rider logs") } if len(keys) == 0 { return c.JSON(fiber.Map{ "success": true, "data": []models.MilerLog{}, "total": 0, "distancekm": 0.0, "miler": milerLogHeader(profile), }) } vals, err := db.Rdb.MGet(db.Ctx, keys...).Result() if err != nil { return utils.Internal(c, "failed to read rider logs") } logs := make([]models.MilerLog, 0, len(vals)) for _, v := range vals { s, ok := v.(string) if !ok { continue } var l models.MilerLog if json.Unmarshal([]byte(s), &l) == nil { logs = append(logs, l) } } // Trail distance, so the console can show kms actually ridden over the // window rather than only the per-booking figure. var distance float64 for i := 1; i < len(logs); i++ { lat1, ok1 := parseCoord(logs[i-1].Latitude) lon1, ok2 := parseCoord(logs[i-1].Longitude) lat2, ok3 := parseCoord(logs[i].Latitude) lon2, ok4 := parseCoord(logs[i].Longitude) if ok1 && ok2 && ok3 && ok4 { distance += haversineKM(lat1, lon1, lat2, lon2) } } return c.JSON(fiber.Map{ "success": true, "data": logs, "total": len(logs), "distancekm": distance, "miler": milerLogHeader(profile), "from": from.Format("2006-01-02"), "to": to.Format("2006-01-02"), }) } func milerLogHeader(p *models.MilerProfile) fiber.Map { return fiber.Map{ "milerprofileid": p.Milerprofileid, "userid": p.Userid, "displayname": p.Displayname, "phone": p.Phone, } } // parseCoord reads a telemetry coordinate. The Redis log models store lat/long // as strings because that is what the rider app sends. func parseCoord(s string) (float64, bool) { if s == "" { return 0, false } f, err := strconv.ParseFloat(s, 64) if err != nil || f == 0 { return 0, false } return f, true } // GetMilerActivity is one rider's detail page: their roster row, plus the duty // and break sessions and the individual assignments behind the counts. // // GET /admin/milers/:id/activity?from=&to= func GetMilerActivity(c *fiber.Ctx) error { id, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid miler ID") } profile, aerr := assertMilerAccess(c, id) if aerr != nil { return aerr } from, to, err := parseHubDateRange(c) if err != nil { return utils.BadRequest(c, err.Error()) } var assignments []models.BookingAssignment db.DB.Where("mileruserid = ? AND assignedat BETWEEN ? AND ?", profile.Userid, from, to). Order("assignedat DESC").Find(&assignments) // The booking behind each assignment, so the console can show the pickup and // drop without a request per row. bookingIDs := make([]int, 0, len(assignments)) for _, a := range assignments { bookingIDs = append(bookingIDs, a.Bookingid) } var bookings []models.PickupBooking if len(bookingIDs) > 0 { db.DB.Where("bookingid IN ?", bookingIDs).Find(&bookings) } var dutyLogs []models.MilerDutyLog db.DB.Where("userid = ? AND loginat BETWEEN ? AND ?", profile.Userid, from, to). Order("loginat DESC").Find(&dutyLogs) var breakLogs []models.MilerBreakLog db.DB.Where("userid = ? AND startat BETWEEN ? AND ?", profile.Userid, from, to). Order("startat DESC").Find(&breakLogs) var delivered int64 db.DB.Model(&models.ConsignmentHistory{}). Where("userid = ? AND eventstatus = ? AND createdat BETWEEN ? AND ?", profile.Userid, constants.ConsignmentDelivered, from, to).Count(&delivered) var totalKms, totalCharges float64 var dutyMinutes float64 for _, a := range assignments { totalKms += a.Riderkms totalCharges += a.Ridercharges } for _, d := range dutyLogs { end := utils.DBNow() if d.Logoutat != nil { end = *d.Logoutat } dutyMinutes += end.Sub(d.Loginat).Minutes() } return utils.OK(c, fiber.Map{ "miler": profile, "from": from.Format("2006-01-02"), "to": to.Format("2006-01-02"), "assignments": assignments, "bookings": bookings, "dutylogs": dutyLogs, "breaklogs": breakLogs, "lastpingat": lastTelemetryPing(profile.Userid), "totals": fiber.Map{ "assignments": len(assignments), "delivered": delivered, "riderkms": totalKms, "ridercharges": totalCharges, "dutyminutes": dutyMinutes, }, }) } // GetAdminConsignmentLogs returns everything recorded against one parcel: the // durable event history from Postgres and the rider's telemetry trail from // Redis. jupiter kept these in one flat table; here they are two stores, so the // console gets both in one call rather than reconciling them itself. // // GET /admin/consignments/:id/logs func GetAdminConsignmentLogs(c *fiber.Ctx) error { id, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid consignment ID") } var consignment models.Consignment q := scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid") if err := q.Where("consignmentid = ?", id).First(&consignment).Error; err != nil { return utils.NotFound(c, "consignment not found") } var history []models.ConsignmentHistory db.DB.Where("consignmentid = ?", id).Order("createdat").Find(&history) var proof models.DeliveryProof hasProof := db.DB.Where("consignmentid = ?", id).First(&proof).Error == nil telemetry := readConsignmentTelemetry(id) resp := fiber.Map{ "consignment": consignment, "history": history, "telemetry": telemetry, } if hasProof { resp["deliveryproof"] = proof } return utils.OK(c, resp) } // readConsignmentTelemetry pulls the rider's per-parcel log list out of Redis. // Always returns a usable slice — telemetry missing is not an error worth // failing a tracking screen over. func readConsignmentTelemetry(consignmentID int) []models.ConsignmentLog { logs := []models.ConsignmentLog{} if db.Rdb == nil { return logs } raw, err := db.Rdb.LRange(db.Ctx, "Consignmentlogs:"+strconv.Itoa(consignmentID), 0, -1).Result() if err != nil { return logs } for _, item := range raw { var l models.ConsignmentLog if json.Unmarshal([]byte(item), &l) == nil { logs = append(logs, l) } } return logs } // GetAdminBookingTrack is the one call the console's tracking screen needs: the // booking, every assignment attempt against it with the rider behind each, the // consignment it became, that parcel's event history and proof of delivery, and // the rider's live position. // // GET /admin/bookings/:id/track func GetAdminBookingTrack(c *fiber.Ctx) error { id, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid booking ID") } var booking models.PickupBooking q := scopeToOwnTenant(c, db.DB.Preload("Parcels").Preload("ServiceOptions").Preload("Payments"), "tenantid") if err := q.Where("bookingid = ?", id).First(&booking).Error; err != nil { return utils.NotFound(c, "booking not found") } var assignments []models.BookingAssignment db.DB.Where("bookingid = ?", id).Order("assignedat").Find(&assignments) // Every rider who has touched this booking, not just the current one — a // rejected first attempt is exactly what ops needs to see when asking why a // pickup was slow. riderIDs := make([]int, 0, len(assignments)) for _, a := range assignments { riderIDs = append(riderIDs, a.Mileruserid) } riders := []models.MilerProfile{} if len(riderIDs) > 0 { db.DB.Where("userid IN ?", riderIDs).Find(&riders) } resp := fiber.Map{ "booking": booking, "assignments": assignments, "riders": riders, } // The live position of whoever currently holds it. if booking.Assignedmileruserid != nil { resp["livelocation"] = readMilerLiveLocation(*booking.Assignedmileruserid) resp["lastpingat"] = lastTelemetryPing(*booking.Assignedmileruserid) } // The consignment, once the booking has been picked up and converted. // BookingPickupComplete writes the link back onto the booking. var consignment models.Consignment if booking.Consignmentid != nil && db.DB.Where("consignmentid = ?", *booking.Consignmentid).First(&consignment).Error == nil { var history []models.ConsignmentHistory db.DB.Where("consignmentid = ?", consignment.Consignmentid).Order("createdat").Find(&history) resp["consignment"] = consignment resp["history"] = history resp["telemetry"] = readConsignmentTelemetry(consignment.Consignmentid) var proof models.DeliveryProof if db.DB.Where("consignmentid = ?", consignment.Consignmentid).First(&proof).Error == nil { resp["deliveryproof"] = proof } } return utils.OK(c, resp) } // readMilerLiveLocation reads the rider's current position out of the Redis key // UpdateMilerLocation writes on every ping. That key holds a bare "lat,lon" // string with a 30-minute TTL, so a nil here means the rider has not pinged in // the last half hour — normal for someone off duty, and worth showing as // "no live position" rather than a stale one. func readMilerLiveLocation(milerUserID int) interface{} { if db.Rdb == nil { return nil } val, err := db.Rdb.Get(db.Ctx, fmt.Sprintf("miler:gps:%d", milerUserID)).Result() if err != nil { return nil } var lat, lon float64 if _, err := fmt.Sscanf(val, "%f,%f", &lat, &lon); err != nil { return nil } return fiber.Map{"latitude": lat, "longitude": lon} }