feat: rider visibility and tracking for the express console

A client login could list its own bookings but had no way to see what its
riders were actually doing. jupiter's console gave them getridersummary and
the rider/delivery logs; Doormile records all of it and exposed none of it.

New console endpoints, all tenant-scoped:
  GET /admin/milers/summary        roster with live state + range totals
  GET /admin/milers/:id/logs       GPS trail from the Redis telemetry index
  GET /admin/milers/:id/activity   one rider's assignments, duty and breaks
  GET /admin/consignments/:id/logs event history + telemetry + proof
  GET /admin/bookings/:id/track    booking -> assignments -> parcel -> proof

Also closes a rider IDOR: GetMilers scoped the roster to the caller's own
fleet, but reading, editing, blocking, notifying or assigning a vehicle to a
single rider by id did not, so a client login could walk the whole network's
riders by incrementing the id. All five now go through assertMilerAccess.

And the client dashboard no longer reports milers/customers/exceptions as
zero — those have no tenant column, so they are counted through appusers,
bookings and consignments respectively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-06 11:41:02 +05:30
parent 6d9232f3b7
commit d85d5571b8
5 changed files with 1164 additions and 24 deletions

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

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