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>
This commit is contained in:
Suriya
2026-08-06 11:41:02 +05:30
parent 6d9232f3b7
commit d85d5571b8
5 changed files with 1164 additions and 24 deletions

View File

@@ -199,14 +199,20 @@ func GetAdminDashboard(c *fiber.Ctx) error {
scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid").Count(&totalBookings)
scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid").Count(&totalConsignments)
// Customers, milers and exceptions have no tenant column, so there is no
// way to attribute them to one client here. Rather than show a client
// Doormile-wide totals, these are reported as zero for client logins; the
// per-client versions need a join through bookings and are not built yet.
// Customers and exceptions carry no tenant column of their own, so they are
// counted through the bookings and consignments that do. Riders link to a
// client through appusers.tenantid. Reporting these as zero (which this did)
// left a client's dashboard looking like an empty account.
if isDoormileConsoleStaff(c) {
db.DB.Model(&models.AppCustomer{}).Count(&totalCustomers)
db.DB.Model(&models.AppUser{}).Where("roleid = 5").Count(&totalMilers)
db.DB.Model(&models.ConsignmentException{}).Where("status = ?", "Open").Count(&openExceptions)
} else {
own := consoleTenantID(c)
scopeViaBookings(c, db.DB.Model(&models.AppCustomer{}), "appcustomerid").Count(&totalCustomers)
db.DB.Model(&models.AppUser{}).Where("roleid = 5 AND tenantid = ?", own).Count(&totalMilers)
scopeViaConsignments(c, db.DB.Model(&models.ConsignmentException{}), "consignmentid").
Where("status = ?", "Open").Count(&openExceptions)
}
return utils.OK(c, fiber.Map{
@@ -1428,9 +1434,12 @@ func CreateMiler(c *fiber.Ctx) error {
func GetMilerDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var profile models.MilerProfile
if err := db.DB.Where("milerprofileid = ?", id).First(&profile).Error; err != nil {
return utils.NotFound(c, "miler not found")
// GetMilers scopes the roster to the caller's own fleet, but reading one
// rider by id did not — a client login could walk the whole network's riders
// by incrementing the id.
profile, err := assertMilerAccess(c, id)
if err != nil {
return err
}
return utils.OK(c, profile)
}
@@ -1457,9 +1466,9 @@ func AdminNotifyMiler(c *fiber.Ctx) error {
return utils.BadRequest(c, "title and message are required")
}
var profile models.MilerProfile
if err := db.DB.Where("milerprofileid = ?", id).First(&profile).Error; err != nil {
return utils.NotFound(c, "miler not found")
profile, aerr := assertMilerAccess(c, id)
if aerr != nil {
return aerr
}
if profile.Devicetoken == "" {
@@ -1475,9 +1484,9 @@ func AdminNotifyMiler(c *fiber.Ctx) error {
func UpdateMiler(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var profile models.MilerProfile
if err := db.DB.Where("milerprofileid = ?", id).First(&profile).Error; err != nil {
return utils.NotFound(c, "miler not found")
profile, aerr := assertMilerAccess(c, id)
if aerr != nil {
return aerr
}
type MilerUpdate struct {
@@ -1502,7 +1511,7 @@ func UpdateMiler(c *fiber.Ctx) error {
}
profile.Updatedat = time.Now()
if err := db.DB.Save(&profile).Error; err != nil {
if err := db.DB.Save(profile).Error; err != nil {
return utils.Internal(c, "failed to update miler")
}
return utils.OK(c, profile)
@@ -1510,17 +1519,16 @@ func UpdateMiler(c *fiber.Ctx) error {
func BlockMiler(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
profile, aerr := assertMilerAccess(c, id)
if aerr != nil {
return aerr
}
tx := db.DB.Begin()
var profile models.MilerProfile
if err := tx.Where("milerprofileid = ?", id).First(&profile).Error; err != nil {
tx.Rollback()
return utils.NotFound(c, "miler not found")
}
profile.Availabilitystatus = constants.MilerBlocked
profile.Updatedat = time.Now()
if err := tx.Save(&profile).Error; err != nil {
if err := tx.Save(profile).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to block miler profile")
}
@@ -1548,14 +1556,24 @@ func AssignMilerVehicle(c *fiber.Ctx) error {
return utils.BadRequest(c, "invalid request body")
}
var profile models.MilerProfile
if err := db.DB.Where("milerprofileid = ?", id).First(&profile).Error; err != nil {
return utils.NotFound(c, "miler not found")
profile, aerr := assertMilerAccess(c, id)
if aerr != nil {
return aerr
}
// A vehicle can only be handed to a rider the caller owns, and only from
// their own fleet — otherwise a client could park another client's van
// against their rider.
var vehicle models.Vehicle
if err := db.DB.Where("vehicleid = ?", req.Vehicleid).First(&vehicle).Error; err != nil {
return utils.NotFound(c, "vehicle not found")
}
profile.Vehicleid = &req.Vehicleid
profile.Updatedat = time.Now()
db.DB.Save(&profile)
if err := db.DB.Save(profile).Error; err != nil {
return utils.Internal(c, "failed to assign vehicle")
}
return utils.OK(c, profile)
}