Compare commits

...

7 Commits

Author SHA1 Message Date
Suriya
90fa4fbb74 fix: per-site attribution needs its own column, not pickuplocationid
Caught by testing the previous commit against production: creating a booking
with a resolved site failed with

  pickupbookings_pickuplocationid_fkey
  FOREIGN KEY (pickuplocationid) REFERENCES appcustomerlocations(...)

pickuplocationid is the *customer's* saved address, a B2C concept. It never
referred to the client company's own kitchens or branches. The pre-existing
code that validated an incoming pickuplocationid against TenantLocation was
wrong on the same point and would have 500'd for any caller that used it — it
had simply never been called with a value.

Adds tenantlocationid to pickupbookings and consignments (nullable, indexed,
additive via AutoMigrate), carried across at pickup, and points the reporting
filter, the by_location breakdown and the Unattributed bucket at it.

The booking request accepts tenantlocationid, and still accepts
pickuplocationid as an alias so anything written against the earlier docs
starts working instead of failing.

Also gofmt on the two model files touched; booking.go was already failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:08:57 +05:30
Suriya
0c407e5b27 feat: per-site reporting, and actually populate the site on a booking
jupiter's getreportsummary took a locationid — per kitchen, per branch. That
was the one report parameter with no Doormile equivalent, and for a food
client with 23 kitchens it is the difference between one number and a usable
report.

  GET /admin/reports?locationid=      narrows every figure to one site
  GET /admin/reports                  now carries a by_location block
  GET /admin/locations/summary        the standalone per-site table

The filter alone would have been useless: pickuplocationid was null on every
booking in the system, because the console sends a kitchen's address rather
than its id. createExpressBooking now resolves the site itself — nearest
stored location within 150m, falling back to an address match, nil when
nothing matches confidently, since a wrong attribution silently moves orders
between kitchens. An explicit pickuplocationid still wins.

Bookings that named no site are reported as their own "Unattributed" row
rather than dropped, so per-site rows add up to the summary total.

Two fixes found while in here:
- the payments join in the per-site query fanned out, counting a booking once
  per payment row; payments are now pre-aggregated per booking
- by_rider was empty for every client login, which reads as "your riders did
  nothing". Riders are tenant-scoped now, so a client sees its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:56:56 +05:30
Suriya
cf488b3d76 docs: jupiter to Doormile API migration map
Maps every jupiter endpoint we have replaced to its Doormile equivalent, for
the express console and the miler app only. Marks which jupiter paths were
confirmed from live network logs versus taken from the prior codebase
analysis, and states per row whether the Doormile side has been hit with a
real request or only compiles.

Includes the 11-way decomposition of PUT /deliveries/updatedelivery, the
behaviour changes that break a naive repoint, and the gaps jupiter covered
that Doormile does not yet — per-site reporting being the notable one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:49:40 +05:30
Suriya
511d369d7f docs: refresh the express-console API reference
Adds the five rider/tracking endpoints, the ?tenantid= staff filter and the
403-vs-404 refusal rules, and replaces the guesswork coverage note with what
was actually run against production on 2026-08-06.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:46:21 +05:30
Suriya
f44e8fa3b4 fix: access checks that returned utils.Forbidden never blocked anything
utils.Forbidden and utils.NotFound write the response and return c.JSON's
nil. Any helper that signalled refusal by returning one of them handed its
caller a nil error, so every `if err != nil { return err }` guard passed and
the handler carried straight on.

The observable result: GET /admin/milers?tenantid=14 as a DailyGrubs login
returned HTTP 403 with all 30 of the network's riders in the body. Status
line correct, payload leaked.

Three helpers were affected:
  effectiveTenantID    (yesterday, mine) — cross-tenant read returned the
                       unfiltered list under a 403
  canAccessBooking     (was assertBookingAccess, shipped in 6d9232f) — four
                       mutating booking handlers were unguarded
  findMilerForConsole  (was assertMilerAccess) — worse, callers went on to
                       dereference the nil profile

All three now return a bool and the caller writes the refusal itself, so the
control flow is visible at the call site instead of hiding in a helper.

Adds a test that pins utils.Forbidden/NotFound returning nil, so if that ever
changes the assumption breaks loudly rather than silently, plus table tests
for effectiveTenantID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:32:13 +05:30
Suriya
42f2a41ff6 feat: filter console reads by tenant, for clients and for Doormile staff
Two halves of the same thing. A client login was already pinned to its own
tenant on most reads, but the roster, the B2C customer list and the dashboard
counters were not — a DailyGrubs login listed the whole network's riders.

The other half was missing entirely: Doormile's own staff had no way to look
at one client's slice. Reports accepted ?tenantid= but applied it only to the
consignment count, and bookings accepted it while milers, customers,
consignments and the dashboard ignored it.

effectiveTenantID(c) now resolves both cases in one place — the caller's own
tenant for a client login, the requested one for Doormile staff, 0 for the
whole network. A client asking for someone else's tenantid is refused rather
than silently handed their own data back under the wrong label.

Applied to: milers, customers, bookings, consignments, dashboard, reports and
the rider summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:01:13 +05:30
Suriya
d85d5571b8 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>
2026-08-06 11:41:02 +05:30
10 changed files with 2166 additions and 127 deletions

View File

@@ -41,6 +41,85 @@ func isDoormileConsoleStaff(c *fiber.Ctx) bool {
return consoleTenantID(c) == 0
}
// effectiveTenantID is the tenant a request should be read as: the caller's own
// for a client login, or whatever ?tenantid= asks for when Doormile staff want
// one client's slice of the network. Returns 0 for "no restriction", which only
// Doormile staff can reach.
//
// The second return is "allowed", not an error, and deliberately so: the
// utils.* response helpers all return c.JSON's nil, so a helper that signalled
// refusal by returning utils.Forbidden(...) would hand its caller a nil error.
// Every `if err != nil` guard built that way silently passes and the handler
// carries on to write real data into a response already stamped 403. Callers
// must write the refusal themselves.
func effectiveTenantID(c *fiber.Ctx) (tenantID int, allowed bool) {
own := consoleTenantID(c)
requested := c.QueryInt("tenantid", 0)
if own != 0 {
// A client asking for someone else's tenant is refused rather than
// silently given their own data back under the wrong label.
if requested != 0 && requested != own {
return 0, false
}
return own, true
}
return requested, true
}
// scopeToTenant restricts a query to one tenant by its own tenant column,
// no-oping when tenantID is 0.
func scopeToTenant(query *gorm.DB, column string, tenantID int) *gorm.DB {
if tenantID == 0 {
return query
}
return query.Where(column+" = ?", tenantID)
}
// requestedLocationID reads ?locationid= and proves the site belongs to the
// tenant the request is scoped to. Returns 0 for "all sites". The second value
// is "ok", not an error, for the reason on effectiveTenantID — utils.NotFound
// returns nil, so it cannot be used as a refusal signal.
//
// The ownership check matters because a location id is just an integer in a
// query string: without it a client could read another client's per-site
// numbers by guessing ids, which is the whole tenant boundary undone by one
// unvalidated param.
func requestedLocationID(c *fiber.Ctx, tenantID int) (locationID int, ok bool) {
locationID = c.QueryInt("locationid", 0)
if locationID == 0 {
return 0, true
}
var loc models.TenantLocation
if err := db.DB.Select("tenantlocationid", "tenantid").
Where("tenantlocationid = ?", locationID).First(&loc).Error; err != nil {
return 0, false
}
if tenantID != 0 && loc.Tenantid != tenantID {
return 0, false
}
return locationID, true
}
// scopeToLocation narrows a query to one client site, no-oping when locationID
// is 0.
func scopeToLocation(query *gorm.DB, column string, locationID int) *gorm.DB {
if locationID == 0 {
return query
}
return query.Where(column+" = ?", locationID)
}
// milerUserIDsForTenant lists the appusers.userid of a tenant's riders. Riders
// belong to a client through their appusers row, not the miler profile.
func milerUserIDsForTenant(tenantID int) []int {
var ids []int
db.DB.Model(&models.AppUser{}).
Where("tenantid = ? AND roleid = ?", tenantID, 5).Pluck("userid", &ids)
return ids
}
// scopeToOwnTenant restricts a query on a tenant-owned table to the requesting
// console user's own tenant. Doormile staff are unrestricted. This is the
// admin-console counterpart of scopeBookingsToOwnTenant in hubController.go —
@@ -91,25 +170,26 @@ func canAccessTenant(c *fiber.Ctx, tenantID int) bool {
return own == 0 || own == tenantID
}
// assertBookingAccess checks that the caller may act on a booking addressed by
// id. Returns nil for Doormile staff. Mutating handlers take the booking id
// canAccessBooking reports whether the caller may act on a booking addressed by
// id. Always true for Doormile staff. Mutating handlers take the booking id
// straight from the path, so a scoped SELECT elsewhere in the handler does not
// protect them — this has to run before the write.
func assertBookingAccess(c *fiber.Ctx, bookingID int) error {
//
// Returns a bool rather than an error for the reason spelled out on
// effectiveTenantID: utils.NotFound returns nil, so the previous
// error-returning version never actually blocked anything.
func canAccessBooking(c *fiber.Ctx, bookingID int) bool {
own := consoleTenantID(c)
if own == 0 {
return nil
return true
}
var booking models.PickupBooking
if err := db.DB.Select("bookingid", "tenantid").First(&booking, bookingID).Error; err != nil {
return utils.NotFound(c, "booking not found")
return false
}
// A booking with no tenant predates tenant attribution and can't be proven
// to belong to this client, so it stays invisible to them.
if booking.Tenantid == nil || *booking.Tenantid != own {
return utils.NotFound(c, "booking not found")
}
return nil
return booking.Tenantid != nil && *booking.Tenantid == own
}
// Helper to generate tripsheet number
@@ -188,6 +268,13 @@ func LoginAdmin(cfg *config.Config) fiber.Handler {
}
func GetAdminDashboard(c *fiber.Ctx) error {
// Pinned to the caller's own tenant for a client login; Doormile staff pass
// ?tenantid= to see one client's numbers, or omit it for the whole network.
tenantID, allowed := effectiveTenantID(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
var totalTenants int64
var totalCustomers int64
var totalMilers int64
@@ -195,21 +282,34 @@ func GetAdminDashboard(c *fiber.Ctx) error {
var totalConsignments int64
var openExceptions int64
scopeToOwnTenant(c, db.DB.Model(&models.Tenant{}), "tenantid").Count(&totalTenants)
scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid").Count(&totalBookings)
scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid").Count(&totalConsignments)
scopeToTenant(db.DB.Model(&models.Tenant{}), "tenantid", tenantID).Count(&totalTenants)
scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", tenantID).Count(&totalBookings)
scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", 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.
if isDoormileConsoleStaff(c) {
// 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
// for every client login) left a client's dashboard looking like an empty
// account on the day they first signed in.
if tenantID == 0 {
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 {
db.DB.Model(&models.AppCustomer{}).
Where("appcustomerid IN (?)", db.DB.Model(&models.PickupBooking{}).
Select("appcustomerid").Where("tenantid = ?", tenantID)).
Count(&totalCustomers)
db.DB.Model(&models.AppUser{}).
Where("roleid = 5 AND tenantid = ?", tenantID).Count(&totalMilers)
db.DB.Model(&models.ConsignmentException{}).
Where("status = ? AND consignmentid IN (?)", "Open", db.DB.Model(&models.Consignment{}).
Select("consignmentid").Where("tenantid = ?", tenantID)).
Count(&openExceptions)
}
return utils.OK(c, fiber.Map{
"tenantid": tenantID,
"tenants": totalTenants,
"customers": totalCustomers,
"milers": totalMilers,
@@ -235,32 +335,47 @@ func GetAdminReports(c *fiber.Ctx) error {
return utils.BadRequest(c, err.Error())
}
tenantID := c.Query("tenantid")
hubID := c.Query("hubid")
// ownTenant is 0 for Doormile staff (whole-network view) and the client's
// tenant for a client login, which every figure below is restricted to.
ownTenant := consoleTenantID(c)
// ownTenant is 0 for a Doormile-staff whole-network view, the client's own
// tenant for a client login, or the tenant Doormile staff asked for with
// ?tenantid=. Every figure below is restricted to it.
ownTenant, allowed := effectiveTenantID(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
// ?locationid= narrows every figure to one of the client's sites — a single
// kitchen, branch or depot. This was jupiter's getreportsummary locationid
// param; without it a food client with 23 kitchens can only see one number
// for all of them.
locationID, locOK := requestedLocationID(c, ownTenant)
if !locOK {
return utils.NotFound(c, "location not found")
}
bookingScope := func() *gorm.DB {
q := scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", ownTenant)
return scopeToLocation(q, "tenantlocationid", locationID)
}
var totalBookings int64
scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid").
Where("createdat BETWEEN ? AND ?", from, to).Count(&totalBookings)
bookingScope().Where("createdat BETWEEN ? AND ?", from, to).Count(&totalBookings)
var delivered int64
scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid").
bookingScope().
Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingConvertedConsignment, from, to).
Count(&delivered)
var cancelled int64
scopeToOwnTenant(c, db.DB.Model(&models.PickupBooking{}), "tenantid").
bookingScope().
Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingCancelled, from, to).
Count(&cancelled)
consignmentQuery := scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid").
consignmentQuery := scopeToLocation(
scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", ownTenant),
"tenantlocationid", locationID).
Where("createdat BETWEEN ? AND ?", from, to)
if tenantID != "" {
consignmentQuery = consignmentQuery.Where("tenantid = ?", tenantID)
}
if hubID != "" {
consignmentQuery = consignmentQuery.Where("currenthubid = ?", hubID)
}
@@ -349,6 +464,11 @@ func GetAdminReports(c *fiber.Ctx) error {
byTenant = append(byTenant, fiber.Map{"tenantid": r.Tenantid, "tenantname": r.Tenantname, "bookings": r.Bookings})
}
// ---- by location: what went out of each of the client's own sites ----
// For a food client this is "how many orders left which kitchen", which is
// the question a single network-wide total cannot answer.
byLocation := locationBreakdown(ownTenant, locationID, from, to)
// ---- by rider: completed stops/kms/earnings per rider in range,
// optionally scoped to one hub ----
type riderRow struct {
@@ -372,14 +492,23 @@ func GetAdminReports(c *fiber.Ctx) error {
AND ba.assignmentstatus = ? AND ba.completedat BETWEEN ? AND ?
`
args := []interface{}{constants.AssignmentCompleted, from, to}
where := []string{}
if hubID != "" {
riderQuery += " WHERE mp.hubid = ? "
where = append(where, "mp.hubid = ?")
args = append(args, hubID)
}
riderQuery += " GROUP BY mp.userid, mp.displayname ORDER BY completed_stops DESC LIMIT 50"
if isDoormileConsoleStaff(c) {
db.DB.Raw(riderQuery, args...).Scan(&riderRows)
// A client sees its own riders' numbers, not the network's. Previously this
// list was simply empty for every client login, which reads as "your riders
// did nothing" rather than "this view isn't for you".
if ownTenant != 0 {
where = append(where, "mp.userid IN (SELECT userid FROM appusers WHERE tenantid = ? AND roleid = 5)")
args = append(args, ownTenant)
}
if len(where) > 0 {
riderQuery += " WHERE " + strings.Join(where, " AND ")
}
riderQuery += " GROUP BY mp.userid, mp.displayname ORDER BY completed_stops DESC LIMIT 50"
db.DB.Raw(riderQuery, args...).Scan(&riderRows)
byRider := make([]fiber.Map, 0, len(riderRows))
for _, r := range riderRows {
@@ -390,8 +519,10 @@ func GetAdminReports(c *fiber.Ctx) error {
}
return utils.OK(c, fiber.Map{
"from": from.Format("2006-01-02"),
"to": to.Format("2006-01-02"),
"from": from.Format("2006-01-02"),
"to": to.Format("2006-01-02"),
"tenantid": ownTenant,
"locationid": locationID,
"summary": fiber.Map{
"total_bookings": totalBookings,
"delivered": delivered,
@@ -401,12 +532,216 @@ func GetAdminReports(c *fiber.Ctx) error {
"open_exceptions": openExceptions,
"completion_rate": completionRate,
},
"by_hub": byHub,
"by_tenant": byTenant,
"by_rider": byRider,
"by_hub": byHub,
"by_tenant": byTenant,
"by_location": byLocation,
"by_rider": byRider,
})
}
// matchTenantLocation resolves which of a client's sites a pickup came from,
// for callers that send an address instead of a location id. Returns nil when
// nothing matches confidently — a wrong attribution is worse than none, since
// it silently moves orders between kitchens on the report.
//
// Coordinates first: they are unambiguous where an address string is not
// ("Vidhya kitchen, Ritham Tours & Travels, Peelamedu" versus the same site
// stored as "Ritham Tours and Travels, Peelamedu"). Address matching is only a
// fallback for bookings that arrive without coordinates.
func matchTenantLocation(tenantID int, address string, lat, lon float64) *int {
var locations []models.TenantLocation
if err := db.DB.Where("tenantid = ?", tenantID).Find(&locations).Error; err != nil || len(locations) == 0 {
return nil
}
// 150m: tight enough that two kitchens on the same street stay distinct,
// loose enough to absorb the drift between a stored pin and the one the
// console sends.
const matchRadiusKM = 0.15
if lat != 0 || lon != 0 {
best := -1
bestDist := matchRadiusKM
for i, loc := range locations {
if loc.Latitude == 0 && loc.Longitude == 0 {
continue
}
if d := haversineKM(lat, lon, loc.Latitude, loc.Longitude); d < bestDist {
best, bestDist = i, d
}
}
if best >= 0 {
id := locations[best].Tenantlocationid
return &id
}
}
if address == "" {
return nil
}
needle := strings.ToLower(strings.TrimSpace(address))
for _, loc := range locations {
stored := strings.ToLower(strings.TrimSpace(loc.Address))
if stored == "" {
continue
}
// Exact either way round, so "Vidhya kitchen, <address>" still resolves
// when the stored row holds just the address. Substring matching is
// deliberately not loosened past this — a short stored address would
// otherwise swallow unrelated pickups.
if stored == needle || strings.Contains(needle, stored) {
id := loc.Tenantlocationid
return &id
}
}
return nil
}
// GetLocationSummary is the standalone per-site table — jupiter's
// getlocationsummary. Same rows as the report's by_location block, without
// pulling the whole report, so a "Kitchens" screen can load on its own.
//
// GET /admin/locations/summary?tenantid=&locationid=&from=&to=
func GetLocationSummary(c *fiber.Ctx) error {
from, to, err := parseHubDateRange(c)
if err != nil {
return utils.BadRequest(c, err.Error())
}
tenantID, allowed := effectiveTenantID(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
// Doormile staff must name a client: per-site rows across every tenant at
// once are a mix of unrelated sites, not a report.
if tenantID == 0 {
return utils.BadRequest(c, "tenantid is required — per-site figures are reported within one client")
}
locationID, locOK := requestedLocationID(c, tenantID)
if !locOK {
return utils.NotFound(c, "location not found")
}
rows := locationBreakdown(tenantID, locationID, from, to)
return c.JSON(fiber.Map{
"success": true,
"data": rows,
"total": len(rows),
"tenantid": tenantID,
"locationid": locationID,
"from": from.Format("2006-01-02"),
"to": to.Format("2006-01-02"),
})
}
// locationBreakdown returns per-site totals for a tenant: how many bookings
// each of the client's own locations raised in the range, how many reached a
// consignment, and what COD came back.
//
// Bookings with no tenantlocationid are reported as a single "Unattributed"
// row rather than dropped, so the per-site figures still add up to the summary
// total. That row is not noise — it is every booking created without naming
// its site, and it should shrink to zero as the console starts sending
// tenantlocationid.
func locationBreakdown(tenantID, locationID int, from, to time.Time) []fiber.Map {
// Only meaningful within one client. Across the whole network the rows
// would be a mix of every tenant's sites, which is a screen nobody asked
// for; Doormile staff pass ?tenantid= to get this.
if tenantID == 0 {
return []fiber.Map{}
}
type locRow struct {
Tenantlocationid int `gorm:"column:tenantlocationid"`
Locationname string `gorm:"column:locationname"`
Address string `gorm:"column:address"`
Pincode string `gorm:"column:pincode"`
Bookings int64 `gorm:"column:bookings"`
Delivered int64 `gorm:"column:delivered"`
Cancelled int64 `gorm:"column:cancelled"`
CodCollected float64 `gorm:"column:cod_collected"`
}
// The date range sits in the JOIN, not a WHERE, so a site with no orders in
// the window still appears with zeros instead of vanishing from the report.
//
// Payments are pre-aggregated per booking before joining. Joining
// bookingpayments directly would fan out — a booking with two payment rows
// would be counted as two bookings.
sql := `
SELECT tl.tenantlocationid AS tenantlocationid,
tl.locationname AS locationname,
tl.address AS address,
tl.pincode AS pincode,
COUNT(b.bookingid) AS bookings,
COUNT(b.bookingid) FILTER (WHERE b.status = ?) AS delivered,
COUNT(b.bookingid) FILTER (WHERE b.status = ?) AS cancelled,
COALESCE(SUM(p.paid), 0) AS cod_collected
FROM tenantlocations tl
LEFT JOIN pickupbookings b
ON b.tenantlocationid = tl.tenantlocationid
AND b.createdat BETWEEN ? AND ?
LEFT JOIN (
SELECT bookingid, SUM(amount) AS paid
FROM bookingpayments
WHERE paymentstatus = ?
GROUP BY bookingid
) p ON p.bookingid = b.bookingid
WHERE tl.tenantid = ?`
args := []interface{}{
constants.BookingConvertedConsignment, constants.BookingCancelled,
from, to, constants.PaymentStatusPaid, tenantID,
}
if locationID != 0 {
sql += ` AND tl.tenantlocationid = ?`
args = append(args, locationID)
}
sql += `
GROUP BY tl.tenantlocationid, tl.locationname, tl.address, tl.pincode
ORDER BY bookings DESC, tl.locationname`
var rows []locRow
db.DB.Raw(sql, args...).Scan(&rows)
out := make([]fiber.Map, 0, len(rows)+1)
for _, r := range rows {
out = append(out, fiber.Map{
"tenantlocationid": r.Tenantlocationid,
"locationname": r.Locationname,
"address": r.Address,
"pincode": r.Pincode,
"bookings": r.Bookings,
"delivered": r.Delivered,
"cancelled": r.Cancelled,
"cod_collected": r.CodCollected,
})
}
// Anything the client raised without naming a site. Suppressed when the
// caller asked for one specific location.
if locationID == 0 {
var unattributed int64
db.DB.Model(&models.PickupBooking{}).
Where("tenantid = ? AND tenantlocationid IS NULL AND createdat BETWEEN ? AND ?",
tenantID, from, to).Count(&unattributed)
if unattributed > 0 {
out = append(out, fiber.Map{
"tenantlocationid": nil,
"locationname": "Unattributed",
"address": "",
"pincode": "",
"bookings": unattributed,
"delivered": 0,
"cancelled": 0,
"cod_collected": 0,
})
}
}
return out
}
// --------------------
// APP USERS MANAGEMENT
// --------------------
@@ -801,8 +1136,17 @@ func GetAdminCustomers(c *fiber.Ctx) error {
query = query.Where("firstname ILIKE ? OR lastname ILIKE ? OR phone ILIKE ?", like, like, like)
}
// A client sees only the customers they have actually delivered to, not
// Doormile's whole B2C address book.
query = scopeViaBookings(c, query, "appcustomerid")
// Doormile's whole B2C address book. Doormile staff can ask for one
// client's customers with ?tenantid=.
tenantID, allowed := effectiveTenantID(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
if tenantID != 0 {
query = query.Where("appcustomerid IN (?)",
db.DB.Model(&models.PickupBooking{}).Select("appcustomerid").
Where("tenantid = ?", tenantID))
}
var total int64
if err := query.Count(&total).Error; err != nil {
@@ -1338,19 +1682,31 @@ func DeleteVehicle(c *fiber.Ctx) error {
// --------------------
func GetMilers(c *fiber.Ctx) error {
tenantID, allowed := effectiveTenantID(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
var profiles []models.MilerProfile
query := db.DB
query := db.DB.Model(&models.MilerProfile{})
if appLocStr := c.Query("applocationid"); appLocStr != "" {
query = query.Where("applocationid = ?", appLocStr)
}
// Riders belong to a client through their appusers row; a client sees their
// own fleet, not Doormile's whole roster.
if tenantID := consoleTenantID(c); tenantID != 0 {
query = query.Where("userid IN (?)",
db.DB.Model(&models.AppUser{}).Select("userid").
Where("tenantid = ? AND roleid = ?", tenantID, 5))
if hubID := c.Query("hubid"); hubID != "" {
query = query.Where("hubid = ?", hubID)
}
if err := query.Find(&profiles).Error; err != nil {
// Riders belong to a client through their appusers row, so the fleet filter
// is a subquery on that rather than a column here. A client login is pinned
// to its own tenant; Doormile staff pass ?tenantid= to see one client's
// fleet, or omit it for the whole roster.
if tenantID != 0 {
ids := milerUserIDsForTenant(tenantID)
if len(ids) == 0 {
return utils.List(c, []models.MilerProfile{}, 0)
}
query = query.Where("userid IN ?", ids)
}
if err := query.Order("displayname").Find(&profiles).Error; err != nil {
return utils.Internal(c, "failed to fetch milers")
}
return utils.List(c, profiles, int64(len(profiles)))
@@ -1428,8 +1784,11 @@ 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 {
// 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, ok := findMilerForConsole(c, id)
if !ok {
return utils.NotFound(c, "miler not found")
}
return utils.OK(c, profile)
@@ -1457,8 +1816,8 @@ 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 {
profile, ok := findMilerForConsole(c, id)
if !ok {
return utils.NotFound(c, "miler not found")
}
@@ -1475,8 +1834,8 @@ 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 {
profile, ok := findMilerForConsole(c, id)
if !ok {
return utils.NotFound(c, "miler not found")
}
@@ -1502,7 +1861,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 +1869,16 @@ func UpdateMiler(c *fiber.Ctx) error {
func BlockMiler(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
tx := db.DB.Begin()
var profile models.MilerProfile
if err := tx.Where("milerprofileid = ?", id).First(&profile).Error; err != nil {
tx.Rollback()
profile, ok := findMilerForConsole(c, id)
if !ok {
return utils.NotFound(c, "miler not found")
}
tx := db.DB.Begin()
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 +1906,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 {
profile, ok := findMilerForConsole(c, id)
if !ok {
return utils.NotFound(c, "miler not found")
}
// 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)
}
@@ -1569,13 +1937,14 @@ func GetAdminBookings(c *fiber.Ctx) error {
pagesize := min(100, max(1, c.QueryInt("pagesize", 20)))
offset := (pageno - 1) * pagesize
query := db.DB.Model(&models.PickupBooking{})
if tenantID := c.Query("tenantid"); tenantID != "" {
query = query.Where("tenantid = ?", tenantID)
tenantID, allowed := effectiveTenantID(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
query := scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", tenantID)
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
}
// Applied after the caller's own ?tenantid= filter so a client login can
// narrow within their tenant but never widen past it.
query = scopeToOwnTenant(c, query, "tenantid")
var total int64
if err := query.Count(&total).Error; err != nil {
@@ -1608,10 +1977,16 @@ type AdminBookingRequest struct {
Appcustomerid int `json:"appcustomerid"`
CustomerPhone string `json:"customer_phone"`
CustomerName string `json:"customer_name"`
// Pickuplocationid names the client site the parcel is collected from — a
// Tenantlocationid names the client site the parcel is collected from — a
// DailyGrubs kitchen, for instance. Optional, but supplying it lets the
// address/pincode/coordinates be filled from the stored location instead of
// retyped, and is the only thing that makes per-site reporting possible.
// address/pincode/coordinates be filled from the stored site instead of
// retyped, and is what per-site reporting groups by. When it is omitted the
// site is inferred from the pickup coordinates or address.
Tenantlocationid *int `json:"tenantlocationid"`
// Pickuplocationid is accepted only as an alias for the field above, for
// callers written against the earlier docs. It is never stored as-is: the
// column of that name foreign-keys to appcustomerlocations, not to a
// client's sites.
Pickuplocationid *int `json:"pickuplocationid"`
Pickupaddress string `json:"pickupaddress"`
Pickuppincode string `json:"pickuppincode"`
@@ -1658,18 +2033,30 @@ func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error
return nil, &expressBookingValidationError{"tenantid does not match a known tenant"}
}
// A named pickup location fills in whatever the caller left blank, so the
// console can send a kitchen id instead of restating its address every time.
// It must belong to the booking's tenant — otherwise one client could book
// against another client's site.
if req.Pickuplocationid != nil {
// A named pickup site fills in whatever the caller left blank, so the console
// can send a kitchen id instead of restating its address every time. It must
// belong to the booking's tenant — otherwise one client could book against
// another client's site.
//
// `pickuplocationid` is accepted as an alias here purely for callers written
// against the earlier documentation. It is the wrong column: it foreign-keys
// to appcustomerlocations, so a tenantlocations id in it fails the insert.
// Both names resolve to Tenantlocationid.
siteID := req.Tenantlocationid
if siteID == nil {
siteID = req.Pickuplocationid
}
req.Pickuplocationid = nil
if siteID != nil {
var loc models.TenantLocation
if err := db.DB.Where("tenantlocationid = ?", *req.Pickuplocationid).First(&loc).Error; err != nil {
return nil, &expressBookingValidationError{"pickuplocationid does not match a known location"}
if err := db.DB.Where("tenantlocationid = ?", *siteID).First(&loc).Error; err != nil {
return nil, &expressBookingValidationError{"tenantlocationid does not match a known location"}
}
if loc.Tenantid != req.Tenantid {
return nil, &expressBookingValidationError{"pickuplocationid does not belong to this tenant"}
return nil, &expressBookingValidationError{"tenantlocationid does not belong to this tenant"}
}
req.Tenantlocationid = siteID
if req.Pickupaddress == "" {
req.Pickupaddress = loc.Address
}
@@ -1687,6 +2074,14 @@ func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error
return nil, &expressBookingValidationError{"pickup address and pincode are required"}
}
// If the caller did not name a site, try to recognise it from where the
// pickup actually is. Without this the field stays null — as it did on every
// booking in the system — and per-site reporting has nothing to group by,
// because the console sends a kitchen's address rather than its id.
if req.Tenantlocationid == nil {
req.Tenantlocationid = matchTenantLocation(req.Tenantid, req.Pickupaddress, req.Pickuplatitude, req.Pickuplongitude)
}
tx := db.DB.Begin()
customerID := req.Appcustomerid
@@ -1720,6 +2115,7 @@ func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error
Tenantid: &tenantID,
Appcustomerid: customerID,
Pickuplocationid: req.Pickuplocationid,
Tenantlocationid: req.Tenantlocationid,
Pickupaddress: req.Pickupaddress,
Pickuppincode: req.Pickuppincode,
Pickuplatitude: req.Pickuplatitude,
@@ -1965,8 +2361,8 @@ func GetAdminBookingDetails(c *fiber.Ctx) error {
func AdminAssignMiler(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
if err := assertBookingAccess(c, id); err != nil {
return err
if !canAccessBooking(c, id) {
return utils.NotFound(c, "booking not found")
}
type MilerAssign struct {
@@ -1989,8 +2385,8 @@ func AdminAssignMiler(c *fiber.Ctx) error {
func AdminAssignVehicle(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
if err := assertBookingAccess(c, id); err != nil {
return err
if !canAccessBooking(c, id) {
return utils.NotFound(c, "booking not found")
}
type VehicleAssign struct {
@@ -2018,8 +2414,8 @@ func AdminAssignVehicle(c *fiber.Ctx) error {
func AdminUpdateBookingStatus(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
if err := assertBookingAccess(c, id); err != nil {
return err
if !canAccessBooking(c, id) {
return utils.NotFound(c, "booking not found")
}
type StatusUpdate struct {
@@ -2055,8 +2451,8 @@ func AdminCancelBooking(c *fiber.Ctx) error {
if err != nil {
return utils.BadRequest(c, "invalid booking ID")
}
if err := assertBookingAccess(c, id); err != nil {
return err
if !canAccessBooking(c, id) {
return utils.NotFound(c, "booking not found")
}
var booking models.PickupBooking
@@ -2188,14 +2584,20 @@ func AdminBulkCancelBookings(c *fiber.Ctx) error {
func GetAdminConsignments(c *fiber.Ctx) error {
page := utils.ParsePage(c)
tenantID, allowed := effectiveTenantID(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
var total int64
if err := scopeToOwnTenant(c, db.DB.Model(&models.Consignment{}), "tenantid").
if err := scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", tenantID).
Count(&total).Error; err != nil {
return utils.Internal(c, "failed to count consignments")
}
var list []models.Consignment
if err := page.Apply(scopeToOwnTenant(c, db.DB, "tenantid")).Find(&list).Error; err != nil {
if err := page.Apply(scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", tenantID)).
Find(&list).Error; err != nil {
return utils.Internal(c, "failed to fetch consignments")
}
return utils.Paginated(c, list, total, page)

View File

@@ -0,0 +1,597 @@
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"
)
// findMilerForConsole resolves a miler profile by its profile id, but only if
// 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.
//
// The second return is "found", not an error, for the reason on
// effectiveTenantID: a version that returned utils.NotFound(...) as its error
// hands the caller a nil, so the guard passes and the handler goes on to
// dereference a nil profile.
//
// Callers report a miss as "not found" rather than "forbidden" — a client
// should not be able to probe which rider ids exist outside their own fleet.
func findMilerForConsole(c *fiber.Ctx, milerProfileID int) (*models.MilerProfile, bool) {
var profile models.MilerProfile
if err := db.DB.Where("milerprofileid = ?", milerProfileID).First(&profile).Error; err != nil {
return nil, false
}
own := consoleTenantID(c)
if own == 0 {
return &profile, true
}
var user models.AppUser
if err := db.DB.Select("userid", "tenantid").Where("userid = ?", profile.Userid).First(&user).Error; err != nil {
return nil, false
}
if user.Tenantid != own {
return nil, false
}
return &profile, true
}
// visibleMilerUserIDs lists the appusers.userid of every rider the request
// should cover — the caller's own fleet for a client login, or the tenant
// Doormile staff asked for with ?tenantid=. Returns nil for "no restriction".
func visibleMilerUserIDs(c *fiber.Ctx) (ids []int, allowed bool) {
tenantID, allowed := effectiveTenantID(c)
if !allowed {
return nil, false
}
if tenantID == 0 {
return nil, true
}
return milerUserIDsForTenant(tenantID), true
}
// 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)
}
visible, allowed := visibleMilerUserIDs(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
if 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, ok := findMilerForConsole(c, id)
if !ok {
return utils.NotFound(c, "miler not found")
}
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, ok := findMilerForConsole(c, id)
if !ok {
return utils.NotFound(c, "miler not found")
}
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}
}

View File

@@ -804,6 +804,7 @@ func BookingPickupComplete(c *fiber.Ctx) error {
Trackingno: trackingNo,
Tenantid: consignmentTenantID,
Pickuplocationid: booking.Pickuplocationid,
Tenantlocationid: booking.Tenantlocationid,
Pickuplatitude: booking.Pickuplatitude,
Pickuplongitude: booking.Pickuplongitude,
Deliverylatitude: booking.Deliverylatitude,

View File

@@ -0,0 +1,103 @@
package controllers
import (
"testing"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp"
)
// newCtx builds a throwaway request context with the given console identity.
func newCtx(t *testing.T, tenantID int, query string) (*fiber.Ctx, func()) {
t.Helper()
app := fiber.New()
fctx := &fasthttp.RequestCtx{}
fctx.Request.SetRequestURI("/admin/milers?" + query)
c := app.AcquireCtx(fctx)
c.Locals("tenantid", tenantID)
return c, func() { app.ReleaseCtx(c) }
}
// TestResponseHelpersReturnNil pins the trap that broke both console access
// checks: utils.Forbidden and utils.NotFound write the response and return
// c.JSON's nil. A helper that signals refusal by returning one of them hands
// its caller a nil error, every `if err != nil` guard passes, and the handler
// carries on to write real data into a response already stamped 403 or 404.
//
// If this test ever fails because the helpers started returning a real error,
// the bool-returning access checks can go back to returning errors.
func TestResponseHelpersReturnNil(t *testing.T) {
app := fiber.New()
c := app.AcquireCtx(&fasthttp.RequestCtx{})
defer app.ReleaseCtx(c)
if err := utils.Forbidden(c, "denied"); err != nil {
t.Errorf("utils.Forbidden returned %v; the access checks assume nil — see effectiveTenantID", err)
}
if err := utils.NotFound(c, "missing"); err != nil {
t.Errorf("utils.NotFound returned %v; the access checks assume nil — see findMilerForConsole", err)
}
}
func TestEffectiveTenantID(t *testing.T) {
cases := []struct {
name string
own int
query string
wantTenant int
wantAllowed bool
}{
{
name: "doormile staff with no filter see the whole network",
own: 0,
query: "",
wantTenant: 0,
wantAllowed: true,
},
{
name: "doormile staff can ask for one client's slice",
own: 0,
query: "tenantid=13",
wantTenant: 13,
wantAllowed: true,
},
{
name: "a client login is pinned to its own tenant",
own: 13,
query: "",
wantTenant: 13,
wantAllowed: true,
},
{
name: "a client asking for its own tenant is fine",
own: 13,
query: "tenantid=13",
wantTenant: 13,
wantAllowed: true,
},
{
name: "a client asking for another tenant is refused",
own: 13,
query: "tenantid=14",
wantTenant: 0,
wantAllowed: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, release := newCtx(t, tc.own, tc.query)
defer release()
gotTenant, gotAllowed := effectiveTenantID(c)
if gotAllowed != tc.wantAllowed {
t.Errorf("allowed = %v, want %v", gotAllowed, tc.wantAllowed)
}
if gotAllowed && gotTenant != tc.wantTenant {
t.Errorf("tenant = %d, want %d", gotTenant, tc.wantTenant)
}
})
}
}

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

@@ -0,0 +1,422 @@
# Doormile Express Console — API reference
The console surface only (`/admin/*`). 95 routes: 1 login + 94 authenticated.
Everything is under `https://api.doormile.com/api/v1`.
Verified against the build deployed 2026-08-06 12:41 IST.
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. Build the UI as if the API returns exactly what
the account is allowed to see, because it does.
**`?tenantid=` — the staff-only filter.** Doormile staff pass it to narrow any
of the list/summary endpoints to one client:
```
GET /admin/milers?tenantid=13 → DailyGrubs' 5 riders
GET /admin/dashboard?tenantid=13 → DailyGrubs' counters
GET /admin/reports?tenantid=13&from=…&to=…
```
Supported on `dashboard`, `reports`, `milers`, `milers/summary`, `customers`,
`bookings`, `consignments`. Omit it for the whole network.
A **client** login passing another tenant's id gets **403** — not their own data
silently relabelled. Passing their own id, or omitting it, works normally.
Reading one resource by id that belongs to another tenant returns **404**, not
403, so ids outside your own fleet aren't probeable. This covers bookings,
consignments and riders, on reads *and* writes — `POST /admin/bookings/:id/
assign-miler` on someone else's booking is refused before the write, not after.
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=&to=&tenantid=&locationid=&hubid=`, 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) |
| GET | `/admin/locations/summary` | **per-site performance**`?tenantid=&locationid=&from=&to=` |
| 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.
### Per-site reporting
`GET /admin/locations/summary?tenantid=13&from=&to=` returns one row per site:
```jsonc
{ "tenantlocationid": 13, "locationname": "Vidhya kitchen",
"address": "…", "pincode": "641015",
"bookings": 12, "delivered": 11, "cancelled": 1, "cod_collected": 840 }
```
Sites with no orders in the range still appear, with zeros. A trailing
`"Unattributed"` row (`tenantlocationid: null`) carries bookings that never
named a site, so the rows always add up to the report's summary total.
Doormile staff **must** pass `?tenantid=` here — per-site rows across all
tenants at once aren't a meaningful report, so it 400s without one.
The same rows appear as `by_location` inside `GET /admin/reports`.
**Send `tenantlocationid` on bookings.** Attribution depends on it. The server
will try to recognise the site from the pickup coordinates (within 150m) or a
matching address, but an explicit id is exact and always wins.
## 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 / notes |
|---|---|---|
| GET | `/admin/milers` | tenant-scoped; `?applocationid=&hubid=&tenantid=` |
| GET | `/admin/milers/summary` | **the roster table** — see below |
| POST | `/admin/milers` | see below |
| GET | `/admin/milers/:id` | 404 outside your fleet |
| GET | `/admin/milers/:id/logs` | GPS trail — see below |
| GET | `/admin/milers/:id/activity` | one rider's detail — see below |
| PUT | `/admin/milers/:id` | |
| PUT | `/admin/milers/:id/block` | |
| PUT | `/admin/milers/:id/assign-vehicle` | `{ vehicleid }` |
| POST | `/admin/milers/:id/notify` | `{ title, message }``:id` is the **milerprofileid** |
Every `:id` here is the **milerprofileid**, not the userid.
```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.
}
```
### The rider screens
These are the express-console equivalents of jupiter's `getridersummary` and
its rider/delivery logs.
**`GET /admin/milers/summary?from=&to=&applocationid=&hubid=&tenantid=`**
One row per rider — live state on the left, range totals on the right. Defaults
to today. This is the rider list screen.
```jsonc
{ "milerprofileid": 78, "userid": 41, "displayname": "Murali P",
"phone": "…", "availabilitystatus": "Offline", "defaultvehicletype": "Bike",
"hubid": null, "hubname": "", "rating": 5,
"onduty": false, "dutystartedat": null,
"currentlatitude": 11.0163, "currentlongitude": 77.0147,
"lastlocationupdatedat": "…", "lastpingat": "2026-08-05T18:10:00Z",
"assigned": 0, "accepted": 0, "rejected": 0,
"completed": 1, "cancelled": 0, "delivered": 1,
"riderkms": 0, "ridercharges": 0 }
```
`lastpingat` comes from Redis telemetry, `lastlocationupdatedat` from the
profile — they differ, and the first is the better staleness signal.
**`GET /admin/milers/:id/logs?from=&to=&limit=`**
The GPS trail. `limit` defaults to 500, caps at 5000. Returns
`{ data: [MilerLog…], total, distancekm, miler: {…}, from, to }` where
`distancekm` is the haversine sum over consecutive points. Telemetry coords are
**strings**, since that's what the rider app sends.
**`GET /admin/milers/:id/activity?from=&to=`**
One rider's detail page: `assignments[]`, the `bookings[]` behind them,
`dutylogs[]`, `breaklogs[]`, `lastpingat`, and `totals: { assignments,
delivered, riderkms, ridercharges, dutyminutes }`.
## 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` | 404 if outside your tenant |
| GET | `/admin/bookings/:id/track` | **the tracking screen** — see below |
| 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
"tenantlocationid": 13, // a stored kitchen/branch — fills address, pincode and
// coords for you, and is what per-site reporting groups by
"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** `tenantlocationid` supplies them.
- A `tenantlocationid` belonging to another tenant is rejected.
- `pickuplocationid` is accepted as an alias for `tenantlocationid`, for anything
written against the earlier version of this doc. Prefer the new name: the
database column called `pickuplocationid` means something else entirely (the
B2C customer's saved address) and is not what per-site reporting uses.
- **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.
### Tracking one booking end to end
**`GET /admin/bookings/:id/track`** — one call for the whole lifecycle, so the
tracking screen doesn't have to stitch five requests together:
```jsonc
{
"booking": { with parcels, serviceoptions, payments },
"assignments": [ { mileruserid, assignmentstatus, assignedat, acceptedat,
completedat, riderkms, ridercharges } ], // every attempt
"riders": [ { userid, displayname, phone, } ], // one per attempt
"livelocation": { "latitude": , "longitude": }, // null if no ping in 30 min
"lastpingat": "…",
"consignment": { }, // present once picked up
"history": [ { eventstatus, remarks, createdat } ],
"telemetry": [ ConsignmentLog ],
"deliveryproof": { deliveredtoname, photourl, geolatitude, }
}
```
`assignments` holds **every** attempt, not just the current one — a rejected
first assignment is exactly what ops needs when asking why a pickup was slow.
## Consignments
| Method | Path | Notes |
|---|---|---|
| GET | `/admin/consignments` | `?tenantid=` for staff |
| GET | `/admin/consignments/:id` | |
| GET | `/admin/consignments/:id/logs` | event history + telemetry + proof |
| GET | `/admin/consignments/track/:trackingno` | |
| PUT | `/admin/consignments/:id/status` | |
`/:id/logs` returns `{ consignment, history[], telemetry[], deliveryproof? }`
the durable Postgres event log and the rider's Redis trail in one response,
rather than making the console reconcile two stores.
## 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.
## Verified against production, 2026-08-06
Run as both a client login (`info@dailygrubs.com`, tenant 13) and Doormile
staff (`developer@doormile.com`, tenant 0):
- dashboard, reports, milers, milers/summary, milers/:id, milers/:id/logs,
milers/:id/activity, customers, bookings, bookings/:id, bookings/:id/track,
consignments, consignments/:id/logs, tenants, tenants/:id/locations
- `?tenantid=` narrowing on all of the above, for staff
- 403 on a client requesting another tenant; 404 on cross-tenant reads *and*
on `assign-miler` / `status` / `cancel` writes, with the target row confirmed
unmodified afterwards
Still unproven: tripsheets, exceptions, vehicles, competitor-branches,
carrier-pricing, doormile-pricing, app-users CRUD, partner CRUD, bulk booking
create/cancel, `assign-vehicle`, `block`. Written, compiled, never called with
a real request.

254
docs/jupiter2doormile.md Normal file
View File

@@ -0,0 +1,254 @@
# jupiter → Doormile
What the old Nearle/jupiter API did, and what replaces it in Doormile. Two
surfaces only — the **express console** and the **miler app**. Hub console, CRM
and the B2C customer app are out of scope here.
Base URLs:
| | jupiter | Doormile |
|---|---|---|
| API | `jupiter.nearle.app/live/api/v1` | `api.doormile.com/api/v1` |
| Write path | `queue.workolik.com` (TLS verify off, hardcoded IP pin) | same host, no side channel |
**Confidence marking.** Paths marked ✅ were read off real network logs from the
live jupiter console. Paths marked ~ come from the prior-session analysis of the
jupiter codebase and have not been re-confirmed against a live request — check
the exact spelling before wiring anything to them.
Status: **Done** = built and hit with a real request · **Built** = written and
compiled, never called · **Gap** = nothing replaces it yet · **Dropped** =
deliberately not migrated.
---
## 1. Auth
| jupiter | Doormile | Status |
|---|---|---|
| ~ console login (undocumented in jupiter's own API docs — found only by reading the console source) | `POST /admin/login``{email, password}` | **Done** |
| ~ rider login | `POST /miler/login` then `POST /miler/verify-pin` | **Done** |
Two real differences:
- Doormile splits rider login into **phone → PIN**, two calls. jupiter did it in
one.
- The Doormile console token carries **`tenantid`**. jupiter had no tenant
concept on the login at all; every console user saw everything. This is the
single biggest behavioural change for a client account.
- `configid` must be **1001** on both miler calls. There is no jupiter
equivalent — it's a Doormile login partition.
---
## 2. Express console
### 2.1 Rider screens
| jupiter | Doormile | Status |
|---|---|---|
| ✅ `GET /deliveries/getridersummary/?applocationid=&fromdate=&todate=` | `GET /admin/milers/summary?applocationid=&from=&to=&tenantid=&hubid=` | **Done** |
| ~ rider list | `GET /admin/milers?applocationid=&hubid=&tenantid=` | **Done** |
| ~ rider detail | `GET /admin/milers/:id` | **Done** |
| ~ `riderlogs` (the 1.17M-row, zero-index table) | `GET /admin/milers/:id/logs?from=&to=&limit=` | **Done** |
| ✅ `getriderlocationsummary` *(name confirmed, path inferred)* | covered by `milers/summary` (`currentlatitude/longitude`, `lastpingat`) and `milers/:id/logs` | **Done** |
| — *(no jupiter equivalent)* | `GET /admin/milers/:id/activity?from=&to=` | **Done** |
| ~ rider create/edit | `POST /admin/milers`, `PUT /admin/milers/:id` | **Done** |
| ~ block rider | `PUT /admin/milers/:id/block` | **Built** |
| ~ assign vehicle | `PUT /admin/milers/:id/assign-vehicle` | **Built** |
| — | `POST /admin/milers/:id/notify` | **Done** |
Parameter translation: jupiter used `fromdate`/`todate`, Doormile uses
`from`/`to`. Both `YYYY-MM-DD`. jupiter's `applocationid=0` meant "all cities";
Doormile means the same by **omitting** the param.
### 2.2 Orders / deliveries
| jupiter | Doormile | Status |
|---|---|---|
| ✅ `GET /deliveries/getdeliveries/` | `GET /admin/bookings` + `GET /admin/consignments` | **Done** |
| ~ `getdelivery` / `getorders` | `GET /admin/bookings/:id`, `GET /admin/consignments/:id` | **Done** |
| ~ `POST /deliveries/createdeliveries` | `POST /admin/expressbooking` | **Done** |
| ~ `createdeliveries` in bulk | `POST /admin/expressbooking/bulk` (max 200, per-row results) | **Built** |
| ~ `PUT /deliveries/updatedelivery` | **split into 11 endpoints** — see §4 | **Done / partial** |
| — | `GET /admin/bookings/:id/track` | **Done** |
| — | `GET /admin/consignments/:id/logs` | **Done** |
| — | `GET /admin/consignments/track/:trackingno` | **Built** |
Two jupiter bugs that do not carry over, by construction:
- `getdeliveries` returned **every row 21×** (unconstrained `LEFT JOIN
tenantpricing`, `DISTINCT` over 87 columns that deduped nothing). Doormile's
list endpoints are paginated (`pageno`/`pagesize`, default 500, cap 1000) and
return one row per booking.
- `createdeliveries` had a quadratic insert bug — a slice declared outside the
loop kept accumulating, producing ~2× duplicate `deliveryqueues` rows
(66,446 deliveries → 132,826 rows, confirmed live). `createExpressBooking` is
a single transaction per booking; `/bulk` loops it and reports per-row.
### 2.3 Reporting
| jupiter | Doormile | Status |
|---|---|---|
| ✅ `GET /deliveries/getreportsummary/?applocationid=&tenantid=&locationid=&fromdate=&todate=` | `GET /admin/reports?from=&to=&tenantid=&locationid=&hubid=` | **Done** |
| ~ `getlocationsummary` | `GET /admin/locations/summary?tenantid=&locationid=&from=&to=` | **Done** |
| — | `GET /admin/dashboard?tenantid=` | **Done** |
`locationid` is supported: it narrows every figure to one client site, and
`/admin/reports` now carries a `by_location` block alongside `by_hub`,
`by_tenant` and `by_rider`.
**Attribution caveat.** Per-site figures group by `tenantlocationid` on the
booking — a column added 2026-08-06. The pre-existing `pickuplocationid` column
is *not* it: that one foreign-keys to `appcustomerlocations`, the B2C customer's
saved address, so writing a client-site id into it fails the insert. Every
booking created before 2026-08-06 has no site at all.
Since the console sends a kitchen's *address* rather than its id,
`createExpressBooking` resolves the site itself — nearest stored location within
150m, falling back to an address match. Bookings with no site are reported as
their own `"Unattributed"` row rather than dropped, so per-site rows still add
up to the summary total. Sending `tenantlocationid` explicitly is exact and
always wins.
**`applocationid` (city) is still not a report parameter.** jupiter had it;
Doormile filters by `hubid` instead. Only matters once one client runs in more
than one city.
### 2.4 Tenants and their sites
| jupiter | Doormile | Status |
|---|---|---|
| ✅ `GET /tenants/gettenants/` | `GET /admin/tenants` | **Done** |
| ✅ `GET /tenants/gettenantlocations/` | `GET /admin/tenants/:id/locations` | **Done** |
| ~ `getlocations` / `getlocation` / `getlocationdetails` | same as above | **Done** |
| ~ tenant create/edit | `POST /admin/tenants`, `PUT /admin/tenants/:id` | **Done** |
| ~ location create/edit | `POST /admin/tenants/:id/locations`, `PUT /admin/tenantlocations/:id` | **Done** |
| ~ `getbranches` | `GET /admin/hubs` — *jupiter "branches" ≈ Doormile hubs; verify this is the same concept before relying on it* | **Built** |
| ~ `getlocationsummary` | `GET /admin/locations/summary` — see §2.3 | **Done** |
Doormile adds `locationname` on a tenant location. jupiter identified a site by
its address alone, which does not distinguish two branches on one street.
---
## 3. Miler app
jupiter's rider app drove almost everything through one overloaded endpoint.
Doormile gives each action its own route.
| jupiter action | Doormile | Status |
|---|---|---|
| ~ rider login | `POST /miler/login` + `POST /miler/verify-pin` | **Done** |
| ~ PIN reset | `POST /miler/reset-pin` — **now admin-only**, see §5 | **Done** |
| ~ location ping | `PUT /miler/location` | **Done** |
| ~ availability toggle | `PUT /miler/availability` | **Done** |
| ~ assignment list | `GET /miler/assignments`, `GET /miler/assignments/:id` | **Done** |
| ~ accept | `POST /miler/assignments/:id/accept` | **Done** |
| ~ reject | `POST /miler/assignments/:id/reject` | **Built** |
| ~ rider logs write | `POST /miler/logs`, `POST /miler/status` | **Done** |
| ~ per-delivery logs | `POST /miler/consignments/logs` | **Done** |
| — | `POST /miler/duty/start`, `PUT /miler/duty/end`, `GET /miler/duty/current` | **Done** |
| — | `POST /miler/breaks/start`, `PUT /miler/breaks/end` | **Done** |
| — | `GET /miler/earnings` | **Done** |
| — | `POST /miler/support`, `GET /miler/support` | **Built** |
| — | `GET /miler/notifications` | **Done** |
| — | `PATCH /miler/notifications/:id/read` | **Gap** — stub, persists nothing |
---
## 4. `PUT /deliveries/updatedelivery` — the 11-way split
This is the centre of the migration. jupiter overloaded one endpoint for **11
distinct real actions**, distinguished only by which JSON fields happened to be
non-empty. Each is now its own route with its own validation and its own status
transition.
**Eight rider actions:**
| Action | Doormile |
|---|---|
| reached pickup | `POST /miler/bookings/:bookingid/reached` |
| confirm parcel + dimensions | `POST /miler/bookings/:bookingid/parcel` |
| collect payment | `POST /miler/bookings/:bookingid/payment` |
| pickup complete | `POST /miler/bookings/:bookingid/pickup-complete` |
| needs a bigger vehicle | `POST /miler/bookings/:bookingid/vehicle-required` |
| cancel before pickup | `POST /miler/bookings/:bookingid/cancel` |
| deliver | `POST /miler/consignments/:id/deliver` |
| skip / failed attempt | `POST /miler/consignments/:id/skip` |
**Three console actions** that were bundled into the same rider endpoint:
| Action | Doormile |
|---|---|
| assign a rider | `POST /admin/bookings/:id/assign-miler` |
| change status | `PUT /admin/bookings/:id/status` · `PUT /admin/consignments/:id/status` |
| cancel | `POST /admin/bookings/:id/cancel` · `POST /admin/bookings/bulk-cancel` |
`pickup-complete` is the pivot the old system had no concept of: it converts the
booking into a **consignment**, recomputes chargeable weight from the dimensions
the rider entered, and decides routing — matching 3-digit pincode prefixes go
straight to `Out_for_Delivery` (hyperlocal), everything else routes via a hub.
---
## 5. Behaviour changes that break a naive repoint
Response shapes are completely different — flat 87- and 92-column jupiter rows
versus nested Doormile JSON. Every screen that parses a response needs
rewriting, not repointing. Beyond that:
1. **Tenant scoping is real now.** A client console login sees only its own
tenant. `?tenantid=` narrows for Doormile staff; a client passing another
tenant's id gets **403**. Cross-tenant reads of a single resource return
**404**, not 403, so ids aren't probeable. jupiter had none of this.
2. **`configid` 1001** on every miler auth call. No jupiter equivalent.
3. **PIN reset is admin-only.** jupiter let anyone reset a rider PIN with just a
phone number, which is the login identifier, not a secret. Two calls took over
any account. The rider app must not call `/miler/reset-pin` — route resets
through ops.
4. **Identity comes from the token, never the body.** jupiter's telemetry
endpoints took `userid` from the request body. Doormile ignores it.
5. **Telemetry lat/long/speed/battery are strings**, and
`POST /miler/consignments/logs` takes a **bare JSON array**.
6. **Delivery OTP is opt-in per tenant** (`Tenant.Requiredeliveryotp`), default
off. Off for DailyGrubs. When on, it's verified server-side.
7. **Dates**: `from`/`to`, not `fromdate`/`todate`. IST wall-clock throughout.
8. **CityGate**: a booking's pickup pincode prefix must be an open city — `641`
Coimbatore, `600` Chennai, `560` Bengaluru, `500` Hyderabad, `629` Nagercoil.
jupiter had no such gate.
---
## 6. Gaps — jupiter did this, Doormile does not yet
| What | Detail |
|---|---|
| `applocationid` on reports | jupiter could filter a report by city. Doormile filters by `hubid`. Only bites when one client operates in several cities. |
| **Route optimisation** | jupiter used external paid services (`routes.workolik.com`) for multi-stop sequencing. Nothing in Doormile replaces true stop-ordering. `HubBatchAssign` decides *who* gets a booking, not *what order* to run stops in. |
| Notifications read-state | `PATCH /miler/notifications/:id/read` is a stub; no table exists. |
| `riderkms` / `ridercharges` backfill | Populated on new deliveries only. Rows completed before 2026-08-06 read 0 and will not backfill themselves. |
---
## 7. Dropped on purpose
| What | Why |
|---|---|
| `/v1/substitutions` CRUD | Rider substitutions. Low traffic in the old system; Suriya's call. Revisit if it turns out to matter. |
| jupiter's v2 endpoints | They wrote **only to Redis**, invisible to the v1/v3 Postgres reads — genuine split-brain, with a Redis `INCR` id space that could collide with the Postgres sequence. Doormile keeps Redis for ephemeral telemetry only; durable state is always Postgres. |
| `queue.workolik.com` write path | Separate host with TLS verification disabled and a hardcoded IP pin. Not reproduced. |
| ~20 never-populated columns on `orders`, 6 lat/lng pairs for 3 real points on `deliveries`, status spread across 6 text+timestamp column pairs | Replaced by a normalised schema with a real event-log table (`consignmenthistory`). |
---
## 8. What is not migrated at all
Nothing on the client side has moved. The rider Flutter app and
`doormile_express_console` still call `jupiter.nearle.app`. Doormile having the
endpoint does not mean traffic uses it.
Suggested order: pick one net-new console screen (the rider summary, or
reports) and wire it to Doormile first — it replaces nothing live, so it is the
cheapest real proof the cutover works. Then the higher-traffic screens
(deliveries list, rider status updates), then the rider app.

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

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

View File

@@ -32,13 +32,17 @@ func (Pricing) TableName() string {
}
type Consignment struct {
Consignmentid int `json:"consignmentid" gorm:"primaryKey;column:consignmentid"`
Trackingno string `json:"trackingno" gorm:"column:trackingno;unique;not null"`
Orderheaderid *int `json:"orderheaderid" gorm:"column:orderheaderid"`
Tenantid int `json:"tenantid" gorm:"column:tenantid"`
Senderid *int `json:"senderid" gorm:"column:senderid"`
Receiverid *int `json:"receiverid" gorm:"column:receiverid"`
Consignmentid int `json:"consignmentid" gorm:"primaryKey;column:consignmentid"`
Trackingno string `json:"trackingno" gorm:"column:trackingno;unique;not null"`
Orderheaderid *int `json:"orderheaderid" gorm:"column:orderheaderid"`
Tenantid int `json:"tenantid" gorm:"column:tenantid"`
Senderid *int `json:"senderid" gorm:"column:senderid"`
Receiverid *int `json:"receiverid" gorm:"column:receiverid"`
// See PickupBooking: pickuplocationid is the customer's saved address,
// tenantlocationid is the client's own site. Carried over at pickup so a
// parcel stays traceable to the kitchen or branch it left.
Pickuplocationid *int `json:"pickuplocationid" gorm:"column:pickuplocationid"`
Tenantlocationid *int `json:"tenantlocationid" gorm:"column:tenantlocationid;index"`
Deliverylocationid *int `json:"deliverylocationid" gorm:"column:deliverylocationid"`
Originhubid *int `json:"originhubid" gorm:"column:originhubid"`
Currenthubid *int `json:"currenthubid" gorm:"column:currenthubid"`

View File

@@ -5,8 +5,8 @@ import (
)
type PickupBooking struct {
Bookingid int `json:"bookingid" gorm:"primaryKey;column:bookingid"`
Bookingno string `json:"bookingno" gorm:"column:bookingno;unique;not null"`
Bookingid int `json:"bookingid" gorm:"primaryKey;column:bookingid"`
Bookingno string `json:"bookingno" gorm:"column:bookingno;unique;not null"`
// Tenantid identifies which client company this booking is for. Nil for
// direct B2C bookings (Bookingsource "Customer_App") that aren't attributed
// to a tenant yet — see CreateCustomerBooking. Required for CRM bookings
@@ -14,31 +14,39 @@ type PickupBooking struct {
// a specific tenant. Propagated onto the resulting Consignment at pickup
// time in BookingPickupComplete, instead of inferring it from whichever
// miler happens to complete the pickup.
Tenantid *int `json:"tenantid" gorm:"column:tenantid;index"`
Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid"`
Pickuplocationid *int `json:"pickuplocationid" gorm:"column:pickuplocationid"`
Pickupaddress string `json:"pickupaddress" gorm:"column:pickupaddress;not null"`
Pickuppincode string `json:"pickuppincode" gorm:"column:pickuppincode;not null"`
Pickuplatitude float64 `json:"pickuplatitude" gorm:"column:pickuplatitude;not null"`
Pickuplongitude float64 `json:"pickuplongitude" gorm:"column:pickuplongitude;not null"`
Deliveryaddress string `json:"deliveryaddress" gorm:"column:deliveryaddress;not null"`
Deliverypincode string `json:"deliverypincode" gorm:"column:deliverypincode;not null"`
Deliverylatitude float64 `json:"deliverylatitude" gorm:"column:deliverylatitude;not null"`
Deliverylongitude float64 `json:"deliverylongitude" gorm:"column:deliverylongitude;not null"`
Deliverycity string `json:"deliverycity" gorm:"column:deliverycity"`
Nearesthubid *int `json:"nearesthubid" gorm:"column:nearesthubid"`
Bookingsource string `json:"bookingsource" gorm:"column:bookingsource;default:Customer_App"`
Providercompany string `json:"providercompany" gorm:"column:providercompany"`
Providerlocation string `json:"providerlocation" gorm:"column:providerlocation"`
Notes string `json:"notes" gorm:"column:notes"`
Status string `json:"status" gorm:"column:status;default:Created"` // Created, Miler_Assigned, Pickup_Scheduled, Picked_Up, Converted_To_Consignment, Cancelled
Preferredpickupfrom *time.Time `json:"preferredpickupfrom" gorm:"column:preferredpickupfrom"`
Preferredpickupto *time.Time `json:"preferredpickupto" gorm:"column:preferredpickupto"`
Assignedmileruserid *int `json:"assignedmileruserid" gorm:"column:assignedmileruserid"`
Consignmentid *int `json:"consignmentid" gorm:"column:consignmentid"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
Tenantid *int `json:"tenantid" gorm:"column:tenantid;index"`
Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid"`
// Pickuplocationid is the *customer's* saved address the parcel was collected
// from — it carries a foreign key to appcustomerlocations. It is a B2C
// concept and has nothing to do with the client company's own sites.
Pickuplocationid *int `json:"pickuplocationid" gorm:"column:pickuplocationid"`
// Tenantlocationid is the *client's* site: the kitchen, branch or depot the
// parcel came out of. Separate column because pickuplocationid points at a
// different table entirely; writing a tenantlocations id into it violates
// that foreign key. This is what per-site reporting groups by.
Tenantlocationid *int `json:"tenantlocationid" gorm:"column:tenantlocationid;index"`
Pickupaddress string `json:"pickupaddress" gorm:"column:pickupaddress;not null"`
Pickuppincode string `json:"pickuppincode" gorm:"column:pickuppincode;not null"`
Pickuplatitude float64 `json:"pickuplatitude" gorm:"column:pickuplatitude;not null"`
Pickuplongitude float64 `json:"pickuplongitude" gorm:"column:pickuplongitude;not null"`
Deliveryaddress string `json:"deliveryaddress" gorm:"column:deliveryaddress;not null"`
Deliverypincode string `json:"deliverypincode" gorm:"column:deliverypincode;not null"`
Deliverylatitude float64 `json:"deliverylatitude" gorm:"column:deliverylatitude;not null"`
Deliverylongitude float64 `json:"deliverylongitude" gorm:"column:deliverylongitude;not null"`
Deliverycity string `json:"deliverycity" gorm:"column:deliverycity"`
Nearesthubid *int `json:"nearesthubid" gorm:"column:nearesthubid"`
Bookingsource string `json:"bookingsource" gorm:"column:bookingsource;default:Customer_App"`
Providercompany string `json:"providercompany" gorm:"column:providercompany"`
Providerlocation string `json:"providerlocation" gorm:"column:providerlocation"`
Notes string `json:"notes" gorm:"column:notes"`
Status string `json:"status" gorm:"column:status;default:Created"` // Created, Miler_Assigned, Pickup_Scheduled, Picked_Up, Converted_To_Consignment, Cancelled
Preferredpickupfrom *time.Time `json:"preferredpickupfrom" gorm:"column:preferredpickupfrom"`
Preferredpickupto *time.Time `json:"preferredpickupto" gorm:"column:preferredpickupto"`
Assignedmileruserid *int `json:"assignedmileruserid" gorm:"column:assignedmileruserid"`
Consignmentid *int `json:"consignmentid" gorm:"column:consignmentid"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
// Relations
Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"`
ServiceOptions []BookingServiceOption `json:"serviceoptions" gorm:"foreignKey:Bookingid"`
@@ -91,7 +99,7 @@ type BookingPayment struct {
Bookingpaymentid int `json:"bookingpaymentid" gorm:"primaryKey;column:bookingpaymentid"`
Bookingid int `json:"bookingid" gorm:"column:bookingid"`
Amount float64 `json:"amount" gorm:"column:amount;not null"`
Paymentmode string `json:"paymentmode" gorm:"column:paymentmode"` // Cash, UPI, Card, Wallet
Paymentmode string `json:"paymentmode" gorm:"column:paymentmode"` // Cash, UPI, Card, Wallet
Paymentstatus string `json:"paymentstatus" gorm:"column:paymentstatus;default:Pending"` // Pending, Paid, Failed, Refunded
Collectedbyuserid *int `json:"collectedbyuserid" gorm:"column:collectedbyuserid"`
Transactionref string `json:"transactionref" gorm:"column:transactionref"`
@@ -129,8 +137,8 @@ type BookingVehicleRequirement struct {
Requiredvehicletype string `json:"requiredvehicletype" gorm:"column:requiredvehicletype;not null"`
Reason string `json:"reason" gorm:"column:reason"`
Nearesthubid *int `json:"nearesthubid" gorm:"column:nearesthubid"`
Scheduledpickupfrom *time.Time `json:"scheduledpickupfrom" gorm:"column:scheduledpickupfrom"`
Scheduledpickupto *time.Time `json:"scheduledpickupto" gorm:"column:scheduledpickupto"`
Scheduledpickupfrom *time.Time `json:"scheduledpickupfrom" gorm:"column:scheduledpickupfrom"`
Scheduledpickupto *time.Time `json:"scheduledpickupto" gorm:"column:scheduledpickupto"`
Assignedvehicleid *int `json:"assignedvehicleid" gorm:"column:assignedvehicleid"`
Assigneddriveruserid *int `json:"assigneddriveruserid" gorm:"column:assigneddriveruserid"`
Status string `json:"status" gorm:"column:status;default:Required"` // Required, Scheduled, Assigned, Arrived, Picked_Up, Cancelled

View File

@@ -222,6 +222,9 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
adminAuth.Get("/tenants/:id/locations", controllers.GetTenantLocations)
adminAuth.Post("/tenants/:id/locations", controllers.CreateTenantLocation)
adminAuth.Put("/tenantlocations/:id", controllers.UpdateTenantLocation)
// Per-site performance — jupiter's getlocationsummary. Needs ?tenantid= for
// Doormile staff; a client login is already pinned to its own sites.
adminAuth.Get("/locations/summary", controllers.GetLocationSummary)
// Tenant customers
adminAuth.Get("/tenantcustomers", controllers.GetTenantCustomers)
@@ -250,8 +253,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 +270,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 +280,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)