Files
doormile_backend/controllers/adminMilerOpsController.go
Suriya f44e8fa3b4 fix: access checks that returned utils.Forbidden never blocked anything
utils.Forbidden and utils.NotFound write the response and return c.JSON's
nil. Any helper that signalled refusal by returning one of them handed its
caller a nil error, so every `if err != nil { return err }` guard passed and
the handler carried straight on.

The observable result: GET /admin/milers?tenantid=14 as a DailyGrubs login
returned HTTP 403 with all 30 of the network's riders in the body. Status
line correct, payload leaked.

Three helpers were affected:
  effectiveTenantID    (yesterday, mine) — cross-tenant read returned the
                       unfiltered list under a 403
  canAccessBooking     (was assertBookingAccess, shipped in 6d9232f) — four
                       mutating booking handlers were unguarded
  findMilerForConsole  (was assertMilerAccess) — worse, callers went on to
                       dereference the nil profile

All three now return a bool and the caller writes the refusal itself, so the
control flow is visible at the call site instead of hiding in a helper.

Adds a test that pins utils.Forbidden/NotFound returning nil, so if that ever
changes the assumption breaks loudly rather than silently, plus table tests
for effectiveTenantID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:32:13 +05:30

598 lines
19 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"
)
// findMilerForConsole resolves a miler profile by its profile id, but only if
// 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.
//
// The second return is "found", not an error, for the reason on
// effectiveTenantID: a version that returned utils.NotFound(...) as its error
// hands the caller a nil, so the guard passes and the handler goes on to
// dereference a nil profile.
//
// Callers report a miss as "not found" rather than "forbidden" — a client
// should not be able to probe which rider ids exist outside their own fleet.
func findMilerForConsole(c *fiber.Ctx, milerProfileID int) (*models.MilerProfile, bool) {
var profile models.MilerProfile
if err := db.DB.Where("milerprofileid = ?", milerProfileID).First(&profile).Error; err != nil {
return nil, false
}
own := consoleTenantID(c)
if own == 0 {
return &profile, true
}
var user models.AppUser
if err := db.DB.Select("userid", "tenantid").Where("userid = ?", profile.Userid).First(&user).Error; err != nil {
return nil, false
}
if user.Tenantid != own {
return nil, false
}
return &profile, true
}
// 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) (ids []int, allowed bool) {
tenantID, allowed := effectiveTenantID(c)
if !allowed {
return nil, false
}
if tenantID == 0 {
return nil, true
}
return milerUserIDsForTenant(tenantID), true
}
// 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, allowed := visibleMilerUserIDs(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
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, ok := findMilerForConsole(c, id)
if !ok {
return utils.NotFound(c, "miler not found")
}
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, ok := findMilerForConsole(c, id)
if !ok {
return utils.NotFound(c, "miler not found")
}
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}
}