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:
@@ -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)
|
||||
}
|
||||
|
||||
585
controllers/adminMilerOpsController.go
Normal file
585
controllers/adminMilerOpsController.go
Normal 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}
|
||||
}
|
||||
Reference in New Issue
Block a user