Files
doormile_backend/controllers/adminMilerOpsController.go
Suriya d85d5571b8 feat: rider visibility and tracking for the express console
A client login could list its own bookings but had no way to see what its
riders were actually doing. jupiter's console gave them getridersummary and
the rider/delivery logs; Doormile records all of it and exposed none of it.

New console endpoints, all tenant-scoped:
  GET /admin/milers/summary        roster with live state + range totals
  GET /admin/milers/:id/logs       GPS trail from the Redis telemetry index
  GET /admin/milers/:id/activity   one rider's assignments, duty and breaks
  GET /admin/consignments/:id/logs event history + telemetry + proof
  GET /admin/bookings/:id/track    booking -> assignments -> parcel -> proof

Also closes a rider IDOR: GetMilers scoped the roster to the caller's own
fleet, but reading, editing, blocking, notifying or assigning a vehicle to a
single rider by id did not, so a client login could walk the whole network's
riders by incrementing the id. All five now go through assertMilerAccess.

And the client dashboard no longer reports milers/customers/exceptions as
zero — those have no tenant column, so they are counted through appusers,
bookings and consignments respectively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:41:02 +05:30

586 lines
18 KiB
Go

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 caller may
// see. Returns nil for Doormile staff, meaning "no restriction".
func visibleMilerUserIDs(c *fiber.Ctx) []int {
own := consoleTenantID(c)
if own == 0 {
return nil
}
var ids []int
db.DB.Model(&models.AppUser{}).Where("tenantid = ? AND roleid = ?", own, 5).Pluck("userid", &ids)
return ids
}
// 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)
}
if visible := visibleMilerUserIDs(c); 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}
}