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)
}

View File

@@ -0,0 +1,585 @@
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}
}

292
docs/express-console-api.md Normal file
View File

@@ -0,0 +1,292 @@
# Doormile Express Console — API reference
The console surface only (`/admin/*`). 89 routes: 1 login + 88 authenticated.
Everything is under `https://api.doormile.com/api/v1`.
Miler-app, hub-console, customer-app and CRM routes are not in this document.
---
## Auth
```
POST /api/v1/admin/login
{ "email": "developer@doormile.com", "password": "admin@123" }
```
Returns `{ success, token, user: { id, name, email, role, tenantid } }`.
Send it on every other call as `Authorization: Bearer <token>`.
The token is a JWT carrying `userid`, `email`, `roleid`, `tenantid`, `configid`.
Roles allowed on this group: **1 admin, 3 manager, 4 executive**. Anything else
gets 401.
### Tenant scoping — read this before wiring any list screen
`tenantid` in the token decides what the account can see:
| Token `tenantid` | Who | Sees |
|---|---|---|
| `0` / null | Doormile's own staff | everything, all tenants |
| set (e.g. `13`) | a client's console login | only that tenant's rows |
Scoping is applied server-side. A client login **cannot** widen it by sending
`?tenantid=` or a body `tenantid` — on writes the server overwrites the field
with the token's tenant. Build the UI as if the API returns exactly what the
account is allowed to see, because it does.
Note the group is registered under a stale `// all open, no token required`
comment in `routes.go`; the comment is wrong, `AuthMiddleware` is applied.
---
## Dashboard, profile, reports
| Method | Path | Notes |
|---|---|---|
| GET | `/admin/dashboard` | counts + today's numbers |
| GET | `/admin/reports` | `?from=YYYY-MM-DD&to=YYYY-MM-DD`, defaults to today (IST) |
| GET | `/admin/profile` | current account |
| GET | `/admin/me` | alias of the above |
| PUT | `/admin/profile/password` | `{ "current_password": "...", "new_password": "..." }` — snake_case |
## App users (staff logins)
| Method | Path |
|---|---|
| GET | `/admin/users` |
| POST | `/admin/users` |
| PUT | `/admin/users/:id` |
| DELETE | `/admin/users/:id` |
## Partners (fleet / rider suppliers)
Not the same thing as a tenant. A partner supplies vehicles and riders; a tenant
is a client Doormile delivers for.
| Method | Path | Body |
|---|---|---|
| GET | `/admin/partners` | |
| POST | `/admin/partners` | `{ partnername, partnertypeid, contactno, status }` |
| GET | `/admin/partners/:id` | |
| PUT | `/admin/partners/:id` | |
| DELETE | `/admin/partners/:id` | hard delete — no soft-delete column |
## Tenants (client companies)
| Method | Path | Body |
|---|---|---|
| GET | `/admin/tenants` | |
| POST | `/admin/tenants` | `{ tenantname, primaryemail, primarycontact, status, requiredeliveryotp }` |
| GET | `/admin/tenants/:id` | |
| PUT | `/admin/tenants/:id` | `requiredeliveryotp` is a pointer — omit it to leave the setting alone |
| DELETE | `/admin/tenants/:id` | hard delete |
| GET | `/admin/tenants/:id/locations` | the client's sites (kitchens, branches, depots) |
| POST | `/admin/tenants/:id/locations` | `{ locationname, address, city, state, pincode, latitude, longitude, isprimary, status }` |
| PUT | `/admin/tenantlocations/:id` | note: **not** nested under the tenant |
`requiredeliveryotp` is opt-in per tenant and **off by default**. DailyGrubs runs
without delivery OTP by decision.
## Tenant customers (a client's own end customers)
| Method | Path | Body |
|---|---|---|
| GET | `/admin/tenantcustomers` | |
| POST | `/admin/tenantcustomers` | `{ firstname, lastname, phone, email }` |
| GET | `/admin/tenantcustomers/:id` | |
| PUT | `/admin/tenantcustomers/:id` | |
| DELETE | `/admin/tenantcustomers/:id` | |
## B2C app customers
| Method | Path |
|---|---|
| GET | `/admin/customers` |
| PATCH | `/admin/customers/:id` |
Tenant-scoped through their bookings — a client login sees only customers who
have ordered through them.
## Hubs
| Method | Path | Body |
|---|---|---|
| GET | `/admin/hubs` | |
| POST | `/admin/hubs` | `{ hubname, hubtype, applocationid, contactno, address, latitude, longitude, pincode, status }` |
| GET | `/admin/hubs/:id` | |
| PUT | `/admin/hubs/:id` | |
| DELETE | `/admin/hubs/:id` | soft delete |
`hubtype`: `sorting_center` \| `delivery_hub`. `applocationid` is the city —
Nagercoil is 5; read the rest from `GET /admin/hubs` rather than hardcoding.
## Vehicles
| Method | Path | Body |
|---|---|---|
| GET | `/admin/vehicles` | |
| POST | `/admin/vehicles` | `{ vehicleno, vehicletype, maxweight, maxvolume, partnerid, batterypercentage, status }` |
| GET | `/admin/vehicles/:id` | |
| PUT | `/admin/vehicles/:id` | |
| DELETE | `/admin/vehicles/:id` | soft delete |
## Milers (riders)
| Method | Path | Body |
|---|---|---|
| GET | `/admin/milers` | tenant-scoped |
| POST | `/admin/milers` | see below |
| GET | `/admin/milers/:id` | |
| PUT | `/admin/milers/:id` | |
| PUT | `/admin/milers/:id/block` | |
| PUT | `/admin/milers/:id/assign-vehicle` | |
| POST | `/admin/milers/:id/notify` | `{ title, message }``:id` is the **milerprofileid** |
```jsonc
POST /admin/milers
{
"authname": "Murali",
"email": "murali@dailygrubs.com",
"contactno": "9876543210",
"password": "1234",
"displayname": "Murali S",
"tenantid": 13,
"defaultvehicletype": "Bike",
"applocationid": 1,
"hubid": 4 // optional; without it the rider is invisible to the hub console
// configid defaults to 1001 — the partition the miler app logs in against.
// Do not override it. Riders created without it could never log in.
}
```
## Bookings
| Method | Path | Notes |
|---|---|---|
| GET | `/admin/bookings` | tenant-scoped list |
| POST | `/admin/expressbooking` | create one — passes CityGate |
| POST | `/admin/expressbooking/bulk` | `{ "bookings": [ ... ] }`, max 200, per-row results |
| GET | `/admin/bookings/:id` | 403 if outside your tenant |
| POST | `/admin/bookings/:id/assign-miler` | |
| POST | `/admin/bookings/:id/assign-vehicle` | |
| PUT | `/admin/bookings/:id/status` | |
| POST | `/admin/bookings/:id/cancel` | |
| POST | `/admin/bookings/bulk-cancel` | |
```jsonc
POST /admin/expressbooking
{
"tenantid": 13, // required; forced to your own tenant on client logins
"pickuplocationid": 15, // a stored kitchen/branch — fills address, pincode and
// coords for you, and is what makes per-site reporting work
"customer_phone": "9876543210", // creates a Guest customer if unknown
"customer_name": "Ramesh",
"deliveryaddress": "12 Cross Cut Road, Gandhipuram",
"deliverypincode": "641012",
"deliverycity": "Coimbatore",
"deliverylatitude": 11.0168,
"deliverylongitude": 76.9558,
"service_option": "Fast", // Normal | Fast | Superfast
"finalprice": 120, // the order amount the tenant pays — passed through
"notes": "Ring the bell",
"parcels": [
{ "itemcategory": "Food", "itemdescription": "2 meal boxes", "declaredvalue": 350 }
]
}
```
Rules worth knowing:
- `parcels` must be non-empty and `tenantid` must exist.
- Pickup address + pincode are required **unless** `pickuplocationid` supplies them.
- A `pickuplocationid` belonging to another tenant is rejected.
- **CityGate**: the pickup pincode prefix must be an open city — `641`
Coimbatore, `600` Chennai, `560` Bengaluru, `500` Hyderabad, `629` Nagercoil.
Any other prefix is refused at the middleware, before the handler runs.
- Matching 3-digit pickup and delivery prefixes = hyperlocal, and the parcel goes
straight to `Out_for_Delivery` at pickup instead of routing via a hub.
- Auto-assignment fires after commit as a background retry loop (5 attempts,
2 min apart). The response returns before a rider is attached.
## Consignments
| Method | Path |
|---|---|
| GET | `/admin/consignments` |
| GET | `/admin/consignments/:id` |
| GET | `/admin/consignments/track/:trackingno` |
| PUT | `/admin/consignments/:id/status` |
## Tripsheets (hub-to-hub transport)
| Method | Path | Body |
|---|---|---|
| GET | `/admin/tripsheets` | |
| POST | `/admin/tripsheets` | `{ sourcehubid, destinationhubid, vehicleid, driveruserid }` |
| GET | `/admin/tripsheets/:id` | |
| POST | `/admin/tripsheets/:id/items` | `{ consignmentid }` |
| DELETE | `/admin/tripsheets/:id/items/:itemid` | |
| PUT | `/admin/tripsheets/:id/dispatch` | |
| PUT | `/admin/tripsheets/:id/arrive` | |
## Pricing
| Method | Path | Notes |
|---|---|---|
| GET | `/admin/pricing` | tenant pricing rules |
| POST | `/admin/pricing` | `{ tenantid, applocationid, vehicletype, baseprice, baseweight, priceperkg, basedistance, priceperkm, handlingcharges, effectivefrom, effectiveto, currency, priority, status }` |
| PUT | `/admin/pricing/:id` | |
| DELETE | `/admin/pricing/:id` | |
| POST | `/admin/pricing/simulate` | quote without creating anything |
| POST | `/admin/pricing/quote` | same handler as simulate |
| GET | `/admin/doormile-pricing` | Doormile's own bands |
| POST | `/admin/doormile-pricing` | |
| PUT | `/admin/doormile-pricing/:id` | |
| DELETE | `/admin/doormile-pricing/:id` | soft delete |
## Exceptions
| Method | Path | Body |
|---|---|---|
| GET | `/admin/exceptions` | |
| POST | `/admin/exceptions` | `{ consignmentid, tripsheetid, hubid, exceptiontype, severity, description }` |
| GET | `/admin/exceptions/:id` | |
| PUT | `/admin/exceptions/:id/status` | `{ resolution, status }``Resolved` \| `Closed` |
`exceptiontype`: `Lost`, `Damaged`, `Misrouted`, `Receiver_Refused`,
`Missing_Contents`, `Undeliverable`. `severity`: `Low`, `Medium`, `High`,
`Critical`.
## Competitive intel
| Method | Path |
|---|---|
| GET/POST | `/admin/competitor-branches` |
| PUT/DELETE | `/admin/competitor-branches/:id` |
| GET/POST | `/admin/carrier-pricing` |
| PUT/DELETE | `/admin/carrier-pricing/:id` |
---
## Conventions across every endpoint
- **Envelope**: `{ "success": true, "data": ... }` on success,
`{ "success": false, "message": "..." }` on failure. Lists add `total`,
paginated lists add `page`.
- **Pagination**: `?pageno=1&pagesize=100`. Default 500, cap 1000.
- **Rate limits**: 300/min per IP globally, 10/min shared across all credential
endpoints. Behind the ingress this keys on the proxy IP unless
`TRUSTED_PROXIES` is set.
- **Timestamps** are IST (`Asia/Kolkata`) wall-clock in `timestamp without time
zone` columns. Send dates as `YYYY-MM-DD`, not epochs.
- **Soft delete** exists on Hub, Vehicle, Consignment, Tripsheet, TripsheetItem,
ConsignmentException, DoormilePricing, CarrierPricing. Partner and Tenant are
hard-deleted.
## Not exercised yet
Roughly half these routes have never had a real request against them. Exercised
end-to-end so far: login, dashboard, reports, tenants + locations, milers
(create/list/notify), expressbooking (single), bookings list/detail,
assign-miler, consignments, profile password. Treat the rest as written but
unproven.

238
docs/miler-app-api.md Normal file
View File

@@ -0,0 +1,238 @@
# Doormile Miler App — API reference
The rider-app surface only (`/miler/*`). 38 routes: 3 auth + 35 authenticated.
Base URL `https://api.doormile.com/api/v1`.
This supersedes "Miler App API Contract v1.0" where the two disagree — several
shapes in that doc never matched the code. The known mismatches are called out
inline below.
---
## Auth
Two-step: phone → PIN. **`configid` is `1001`** — that's the partition riders
live in. It defaults to 1001 if omitted, but a miler *row* created without it
can never log in, so the console must set it at creation time.
```
POST /miler/login
{ "phone": "9876543210", "configid": 1001 }
→ { success, message: "PIN verification required", phone }
404 if no account, 403 if not role 5 or not Active
POST /miler/verify-pin
{ "phone": "9876543210", "pin": "1234", "configid": 1001, "device_token": "fcm..." }
→ { success, token, user: { userid, authname, email, contactno, profile: {…MilerProfile…} } }
```
The verify-pin response has **no `data` key** — the fields the old contract doc
listed as flat (`displayname`, `hubid`, `availabilitystatus`, `rating`) live
under `user.profile`.
Send the token as `Authorization: Bearer <token>` on everything else. Role 5 is
enforced; an admin token gets 401 here.
### PIN reset is not self-service
```
POST /miler/reset-pin ← requires an ADMIN token (roles 1/3/4)
{ "phone": "9876543210", "new_pin": "1234", "configid": 1001 }
```
It sits under `/miler` but it is a console/ops operation. It was previously
open, and reset-pin → verify-pin took over any rider account with nothing but a
phone number. The app must not call this; route rider PIN resets through ops.
Credential endpoints share a **10/min** rate limit.
---
## Profile & device
| Method | Path | Body |
|---|---|---|
| GET | `/miler/profile` | |
| PUT | `/miler/profile` | `{ displayname, profilephotourl, defaultvehicletype, phone }` |
| PUT | `/miler/device-token` | `{ "device_token": "..." }` — snake_case |
## Location & availability
| Method | Path | Body |
|---|---|---|
| PUT | `/miler/location` | `{ latitude, longitude, pincode, speed, heading }` |
| PUT | `/miler/availability` | `{ "status": "Available" }` |
`PUT /location` writes Redis only — a SET plus a `GEOADD` into
`milers:locations`, which is the index dispatch searches (10km radius,
nearest 10). No NATS publish, despite what the old doc claimed. `speed` and
`heading` are accepted and reach the telemetry log; they used to be silently
dropped.
`PUT /availability` accepts **either** `status` or `availabilitystatus`
the doc told the Flutter side to send the second, the code only read the
first, so both are honoured now rather than picking a winner.
Valid statuses: `Offline`, `Available`, `Assigned`, `On_Pickup`, `At_Customer`,
`Picked_Up`, `On_Delivery`, `Break`, `Blocked`. Note it's **`Break`**, not
`On_Break`.
## Duty & breaks
| Method | Path | Body |
|---|---|---|
| POST | `/miler/duty/start` | `{ lat, lon }` |
| PUT | `/miler/duty/end` | |
| GET | `/miler/duty/current` | |
| POST | `/miler/breaks/start` | `{ "breaktype": "Lunch" }` |
| PUT | `/miler/breaks/end` | |
Ordering is enforced: starting duty twice returns "already on duty, end current
duty first"; a break without duty returns "not on duty".
## Assignments
| Method | Path | Body |
|---|---|---|
| GET | `/miler/assignments` | the rider's own queue |
| GET | `/miler/assignments/:id` | |
| POST | `/miler/assignments/:id/accept` | |
| POST | `/miler/assignments/:id/reject` | `{ "reason": "too far" }` |
## The pickup flow
In order, all keyed on `:bookingid`:
| Step | Method | Path | Body |
|---|---|---|---|
| 1 | POST | `/miler/bookings/:bookingid/reached` | — |
| 2 | POST | `/miler/bookings/:bookingid/parcel` | `{ "parcels": [{ parcel_id, weight, length, width, height }] }` |
| 3 | POST | `/miler/bookings/:bookingid/payment` | `{ amount, paymentmode, transactionref }` |
| 4 | POST | `/miler/bookings/:bookingid/pickup-complete` | — |
Escape hatches:
| Method | Path | Notes |
|---|---|---|
| POST | `/miler/bookings/:bookingid/vehicle-required` | params are **query strings**: `?type=truck&reason=...` |
| POST | `/miler/bookings/:bookingid/cancel` | `{ "reason": "..." }` — refused once picked up |
`paymentmode`: `Cash`, `UPI`, `Card`, `Wallet`. Amount must be > 0.
**`pickup-complete` is the pivot.** It converts the booking into a consignment,
recomputes chargeable weight from the parcel dimensions the rider entered in
step 2, and decides routing: if the pickup and delivery pincodes share a 3-digit
prefix it's hyperlocal and the consignment goes straight to `Out_for_Delivery`
in the rider's hands. Otherwise it routes via the hub. The consignment inherits
the **booking's** tenant, not the rider's.
## Delivery
| Method | Path | Body |
|---|---|---|
| POST | `/miler/consignments/:id/deliver` | `{ deliveredtoname, otp, photourl, receiversignatureurl, lat, lon }` |
| POST | `/miler/consignments/:id/skip` | `{ reason, lat, lon }` |
- `deliveredtoname` is required. `reason` is required on skip.
- The consignment must be `Out_for_Delivery` or both return 400.
- **`otp` is only required when the tenant has `requiredeliveryotp` on.**
It's off by default, and off for DailyGrubs. When it is on, the OTP is checked
server-side — a non-empty string is no longer enough.
- `lat`/`lon` should be the actual delivery point: `deliver` computes
`riderkms` from the pickup coords by haversine and writes it with
`ridercharges` (the tenant's order amount, passed through from the booking's
`finalprice`) onto the earnings record.
- `skip` bumps `attemptcount` rather than failing the consignment.
## Bookings & earnings
| Method | Path | Query |
|---|---|---|
| GET | `/miler/bookings` | `?status=&date=YYYY-MM-DD` |
| GET | `/miler/earnings` | `?period=daily\|weekly\|monthly&date=YYYY-MM-DD` |
`bonuspoints` stays zero — nothing writes it yet. That's known and deliberate.
## Telemetry (Redis-backed, high frequency)
| Method | Path | Body |
|---|---|---|
| POST | `/miler/logs` | one `MilerLog` |
| GET | `/miler/logs` | |
| POST | `/miler/status` | `{ "status": "Available" }` |
| GET | `/miler/status` | |
| POST | `/miler/consignments/logs` | a **JSON array** of `ConsignmentLog` |
| GET | `/miler/consignments/logs/:consignmentid` | |
| GET | `/miler/consignments/userlogs/:userid` | must be your own userid |
**Lat/long/speed/heading/battery on these are strings, not numbers.** Sending
numbers fails to parse.
```jsonc
POST /miler/logs
{
"logdate": "2026-08-06 14:32:10", // YYYY-MM-DD HH:MM:SS, IST
"latitude": "11.0168", "longitude": "76.9558",
"speed": "24.5", "heading": "180", "accuracy": "8",
"status": "On_Delivery", "orderid": "25",
"battery": "72", "is_charging": false,
"connection": "4G", "location_service": "enabled", "is_background": true
}
```
```jsonc
POST /miler/consignments/logs
[ { "consignmentid": 25, "logdate": "2026-08-06 14:32:10",
"latitude": "11.0168", "longitude": "76.9558",
"speed": "24.5", "heading": "180",
"status": "Out_for_Delivery", "remarks": "", "battery": "72",
"is_background": true } ]
```
**Do not send `userid` in these bodies.** All three used to read the rider
identity from the request body, which let any logged-in rider write another
rider's GPS into the dispatch index. Identity now comes from the token and a
body `userid` is ignored; `/userlogs/:userid` rejects anyone else's id.
Redis is never the system of record here — a flush loses telemetry, not
business state.
## Notifications & support
| Method | Path | Body |
|---|---|---|
| GET | `/miler/notifications` | |
| PATCH | `/miler/notifications/:id/read` | **stub** — see below |
| POST | `/miler/support` | `{ subject, description }` |
| GET | `/miler/support` | |
Notifications are synthesized fresh from `BookingAssignment` rows on every GET,
and `id` is just the array index. `PATCH .../read` returns `{success: true}`
without persisting anything, because there's no notifications table with read
state. Read state cannot stick between calls until that table exists — don't
build a UI that depends on it.
---
## Conventions
- **Envelope**: `{ "success": true, "data": ... }`; failures are
`{ "success": false, "message": "..." }`.
- **Rate limits**: 300/min per IP globally, 10/min across login/verify-pin/
reset-pin.
- **Timestamps** are IST wall-clock. Send `YYYY-MM-DD HH:MM:SS` on telemetry,
`YYYY-MM-DD` on date filters.
- **Status enums** — booking: `Pending_Pickup`, `Created`, `Miler_Assigned`,
`Pickup_Scheduled`, `Picked_Up`, `Converted_To_Consignment`, `Cancelled`.
Consignment: `Created`, `Inwarded_at_Hub`, `Tripsheet_Loaded`, `In_Transit`,
`Out_for_Delivery`, `Delivered`, `RTO_Initiated`, `Returned_to_Sender`,
`Missing`, `Damaged`. Assignment: `Assigned`, `Accepted`, `Rejected`,
`Reassigned`, `Completed`, `Cancelled`.
## Known gaps
1. `PATCH /notifications/:id/read` is a stub — needs a real table, schema not
decided.
2. `bonuspoints` is never written.
3. `assignments/:id/reject` and `bookings/:id/vehicle-required` have never had a
real request against them.

View File

@@ -250,8 +250,13 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
// Milers
adminAuth.Get("/milers", controllers.GetMilers)
// Registered before /milers/:id — Fiber matches in registration order, so
// the literal path has to come first or ":id" swallows "summary".
adminAuth.Get("/milers/summary", controllers.GetMilerSummary)
adminAuth.Post("/milers", controllers.CreateMiler)
adminAuth.Get("/milers/:id", controllers.GetMilerDetails)
adminAuth.Get("/milers/:id/logs", controllers.GetMilerLogs)
adminAuth.Get("/milers/:id/activity", controllers.GetMilerActivity)
adminAuth.Put("/milers/:id", controllers.UpdateMiler)
adminAuth.Put("/milers/:id/block", controllers.BlockMiler)
adminAuth.Put("/milers/:id/assign-vehicle", controllers.AssignMilerVehicle)
@@ -262,6 +267,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
adminAuth.Post("/expressbooking", middlewares.CityGateMiddleware, controllers.CreateExpressBooking)
adminAuth.Post("/expressbooking/bulk", middlewares.CityGateMiddleware, controllers.AdminBulkCreateBookings)
adminAuth.Get("/bookings/:id", controllers.GetAdminBookingDetails)
adminAuth.Get("/bookings/:id/track", controllers.GetAdminBookingTrack)
adminAuth.Post("/bookings/:id/assign-miler", controllers.AdminAssignMiler)
adminAuth.Post("/bookings/:id/assign-vehicle", controllers.AdminAssignVehicle)
adminAuth.Put("/bookings/:id/status", controllers.AdminUpdateBookingStatus)
@@ -271,6 +277,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
// Consignments
adminAuth.Get("/consignments", controllers.GetAdminConsignments)
adminAuth.Get("/consignments/:id", controllers.GetAdminConsignmentDetails)
adminAuth.Get("/consignments/:id/logs", controllers.GetAdminConsignmentLogs)
adminAuth.Get("/consignments/track/:trackingno", controllers.GetAdminConsignmentTracking)
adminAuth.Put("/consignments/:id/status", controllers.AdminUpdateConsignmentStatus)