Files
doormile_backend/docs/express-console-api.md
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

419 lines
16 KiB
Markdown

# 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 `pickuplocationid` 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
"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.
### 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.