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}
|
||||
}
|
||||
292
docs/express-console-api.md
Normal file
292
docs/express-console-api.md
Normal 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
238
docs/miler-app-api.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user