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>
423 lines
16 KiB
Markdown
423 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 `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.
|