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

16 KiB

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:

{ "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.

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.

{ "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
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:

{
  "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.