From d85d5571b8cc1d0d60237683967824f9a98203d8 Mon Sep 17 00:00:00 2001 From: Suriya Date: Thu, 6 Aug 2026 11:41:02 +0530 Subject: [PATCH] feat: rider visibility and tracking for the express console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- controllers/adminController.go | 66 ++- controllers/adminMilerOpsController.go | 585 +++++++++++++++++++++++++ docs/express-console-api.md | 292 ++++++++++++ docs/miler-app-api.md | 238 ++++++++++ routes/routes.go | 7 + 5 files changed, 1164 insertions(+), 24 deletions(-) create mode 100644 controllers/adminMilerOpsController.go create mode 100644 docs/express-console-api.md create mode 100644 docs/miler-app-api.md diff --git a/controllers/adminController.go b/controllers/adminController.go index 5097450..0e22920 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -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) } diff --git a/controllers/adminMilerOpsController.go b/controllers/adminMilerOpsController.go new file mode 100644 index 0000000..95f954b --- /dev/null +++ b/controllers/adminMilerOpsController.go @@ -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} +} diff --git a/docs/express-console-api.md b/docs/express-console-api.md new file mode 100644 index 0000000..37dbe95 --- /dev/null +++ b/docs/express-console-api.md @@ -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 `. + +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. diff --git a/docs/miler-app-api.md b/docs/miler-app-api.md new file mode 100644 index 0000000..ce3d058 --- /dev/null +++ b/docs/miler-app-api.md @@ -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 ` 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. diff --git a/routes/routes.go b/routes/routes.go index 8be393b..31461cd 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -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)