Files
doormile_milderapp/MILER_API_REQUIREMENTS.md
2026-08-28 11:13:15 +05:30

1054 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Miler App — API requirements for the backend team
**From:** Miler rider-app engineering
**Date:** 21 Aug 2026 · **revised 24 Aug 2026**
**Against:** *Doormile Miler App — API reference* (38 `/miler/*` routes)
**Base URL:** `https://api.doormile.com/api/v1`
Everything below was traced in the shipped app and, where it says *verified*,
confirmed with live calls against production using rider **Rajan A**
(`userid 38`, `tenantid 13`, configid 1001) on 20–21 Aug 2026.
Requests are ordered by **what is blocking riders today**. Each one states the
symptom, the evidence, and the smallest change that fixes it.
---
## 0. Status — updated 21 Aug 2026
The backend team shipped requests **1, 2, 3, 5, 6** and cross-cutting items
**10.1–10.4** the same day. The app has been updated against the new contract
and the whole suite is green; each section below carries its own status line.
**What the app now does with it**
| Shipped | What the app changed |
|---|---|
| `Collected_By_Miler` | A real rung in `ConsignmentState`, excluded from the hub guard — a rider is never told the hub is holding a parcel that is in his own box. |
| `POST /consignments/:id/start-delivery` | **Start round** is a server write again. The local released-set is written **only** for what the server released; a stop it refused keeps its PICKED word rather than showing a rung the hub does not agree with. |
| `GET /consignments/:consignmentid` | The delivery gate reads state in one call instead of pulling and sorting the whole history. The logs walk stays as a fallback so a rider on this build meeting an older deployment is not stranded. |
| `consignmentid` + `consignmentstatus` on lists | The 17/29 blocker is closed, and the row itself now says where the delivery half has got to — so a delivered stop drops off the Deliveries tab without a per-stop round trip. |
| `Arrived_At_Pickup` | Parsed and mapped to the *arrived* rung; **I've arrived** now shows in the console. The undocumented `At_Customer` spelling still parses. |
| Skip from `Collected_By_Miler` | A failed attempt is reportable the moment the parcel is collected, not only once the round has started. |
| Error codes on 4xx | Every branch reads `ApiResult.code` — `INVALID_STATE`, `IDEMPOTENCY_IN_PROGRESS`. No delivery decision is made from message prose any more. |
| `Idempotency-Key` | Sent on `pickup-complete`, `deliver`, `skip`, `payment` and `start-delivery` as a stable `verb:resource:day` key. `IDEMPOTENCY_IN_PROGRESS` is waited out and re-asked twice rather than surfaced — a rider is no longer told his delivery failed while it is in the act of succeeding. |
**The flag split — confirmed and handled.** `Collected_By_Miler` +
`start-delivery` ship behind `MILER_COLLECTED_STATE_ENABLED`, **default off**,
so hyperlocal pickup still goes straight to `Out_for_Delivery` today. This
build is correct in both worlds and does not need the flag flipped:
- **Flag off** — the row's `consignmentstatus` already reads `Out_for_Delivery`
when the load reaches the Deliveries tab, so **Start round** spends no
request at all and behaves exactly as it does now. The app never fires a
`start-delivery` that can only be refused.
- **Flag on** — the row reads `Collected_By_Miler`, the press calls
`start-delivery`, and the local released-set is written only for what the
server released.
Nothing here needs coordinating: flip the flag whenever this build is out.
**Also shipped, unannounced and now consumed:** `step`, `stoptype`,
`etaminutes`, `cumulativekms` and `cumulativeeta` on the booking row. The
adapter builds a fixed map, so it had been dropping all five; the app now reads
them. `step` in particular is what lets the delivery leg follow the hub's route
instead of re-sorting nearest-first — **but see request 13: it is `0` on every
row.**
**Still open:** request 4 (upload route for proof of delivery — the photo
cannot leave the phone until this exists), **13** (populate the sequence),
**14** (an outcome for a failed attempt), 7, 8, 9.
---
## 0y. Backend reply — 25 Aug, and what the app did with it
Point by point, with what changed on this side. Where an item needed no code,
that is said rather than left implied.
| Their answer | App-side |
|---|---|
| **1 · `reached` persists** `Arrived_At_Pickup` + lat/lng + `reachedat` | Consumed. The local ARRIVED store is **kept for now** — see below. |
| **2 · `step: 0` is not a bug; gate on `sequencedat`** | **Fixed, and it was a real bug on our side.** |
| **3 · pickup-flow endpoints live**, `PATCH /addresses` built | Already wired; §3 of `MILER_LOGISTICS_API.md` describes it. |
| **4 · `pickup_proof` accepts a `bookingid`** | **Wired.** The parcel photo now leaves the phone. |
| **5 · Spaces rotation held** (jupiter still ships the key) | Understood. See the ask below. |
| **6 · Ordering not server-enforced** — do we want it? | **Yes, please.** Reasoning below. |
| **7 · Error codes present** | Consumed; nothing to do. |
### 2 · `sequencedat` — the bug was ours
The contract was read correctly and implemented correctly: `RouteOrder` gates on
`sequencedat` and treats `step: 0` with a null stamp as *no route, fall back*,
exactly as specified.
It never ran, because **`ApiConfig.pickupFromBooking` was dropping the field.**
The adapter mapped `step`, `stoptype`, `etaminutes`, `cumulativekms` and
`cumulativeeta` — and not the one that says whether any of them mean anything.
So every adapted row looked unsequenced and the app fell back to nearest-first
on routes the hub had actually solved.
Carried through now, and pinned by `route_order_test` so it cannot be dropped
again. **No backend action needed, and no `bookingno` to send you** — the rows
you saw with `step: 0` + `sequencedat: null` were correct and the app was
mishandling the correct answer.
### 1 · Why the local ARRIVED store is still there
It is redundant now and it is not yet removable, for one reason: your own
verification note says the `Miler_Assigned → Arrived_At_Pickup` flip is the one
branch that could not be exercised live — booking 179 never got assigned.
The store costs nothing while it waits. It is the weakest local record in the
app: any server answer outranks it, and it is dropped the moment the stop is
collected. So during the rollout it is a superset — it covers a build meeting an
older deployment, and it goes quiet the instant `reached` starts persisting.
**Removing it is one constant and one call site.** Confirm the flip on a real
assigned booking and it goes in the same sitting.
### 6 · Yes, enforce the ordering
`addresses` → `parcel` → `payment` → `pickup-complete` is a client convention
today, and the app follows it deliberately — the note in
`MILER_LOGISTICS_API.md` §4 states each dependency. Please make it a server
rule with `INVALID_STATE`.
Not because we expect to violate it. Because a convention only one client knows
is a convention that breaks the first time a second client — or a retry, or a
background replay — gets the order wrong, and the failure would be silent: a
consignment routed and priced from an address that arrived too late, with
nothing on either side saying so.
---
## 0x. What we still need from you
Two asks, one small and one that is costing requests today.
### A · A per-day series on `GET /miler/earnings` — P2
The Earnings chart draws seven bars. The response carries six **totals for the
period asked for** and no series, so the app was reading a `breakdown` array
that is not on the contract — it was always null, and the chart drew an empty
week while the rider had ridden all of it.
It is fixed on our side by asking **seven `period=daily&date=…` calls
concurrently** and building the week from them. That works, and it is bounded,
and it is seven requests for one chart.
**Ask:** `breakdown: [{ day, kms }]` on `period=weekly`. The app already has the
fast path for it — the seven calls are skipped the moment that field appears,
with no further change here.
### B · Per-stop `riderkms` on `GET /miler/bookings` — P2
Activity now shows the rider his distance for the day. The only source is
per-stop distance, which the app reads from `compliance.actualkm` /
`riderkms` — present on some rows and absent on most, so the figure reads as a
dash for riders who have certainly ridden.
**Ask:** return the distance the server already computes per stop on the booking
row, under whichever of those two names you prefer. The app reads both, plus its
own `actualkms` spelling, so no coordination is needed on naming.
### C · Nothing else
The ARRIVED → PICKED → ACTIVE ladder, the pickup flow, the pricing call, the
upload route and the error codes are all contracted and consumed. If item 1's
flip verifies, this list is A and B.
---
## 0z. Superseded — the two asks that were open before the 25 Aug reply
**Both are answered.** Kept because the reasoning under each is still the record
of why the app is shaped the way it is, and because §1's workaround is still in
the build. Read §0y first; this is the request that prompted it.
## 0z. Open, as of 25 Aug — the two that still shape the rider's day
Everything else on the ARRIVED → PICKED → ACTIVE flow is settled. These two are
not, and both are already worked around on the app side rather than waiting on
you — so nothing is blocked, but both workarounds are costs we would rather not
keep paying.
### 1 · `POST /miler/bookings/:id/reached` does not persist the arrival
**Today.** It answers `200 {"success": true}` and the booking stays on
`Miler_Assigned`. The arrival is never recorded, so the hub cannot see that a
rider is standing at a pickup.
**Request body the app already sends:**
```jsonc
{ "latitude": 11.0168, "longitude": 76.9558 }
```
**Please make it write:** the ARRIVED state, the latitude/longitude received,
and an arrival timestamp.
**What the app does meanwhile.** ARRIVED is treated as a **rider-owned rung**
and kept in a local store (`arrived_order_ids`), because the rung existed only
as a field on an in-memory row and the very next queue poll — which the arrival
sheet itself triggers — rebuilt that row from a server still saying
`Miler_Assigned` and put it back on ACCEPTED. From the rider's side, marking
arrived did nothing.
That store is deliberately the **weakest** local record in the app: anything the
server does know about a stop outranks it, and it is dropped the moment the stop
is collected. **Delete it the day this endpoint persists** — a local record that
outranks the server is a liability once the server has the answer. It is one
constant and one call site; say the word and it goes.
### 2 · `GET /miler/bookings` returns `step: 0` on every live row
The transport is there and the app consumes all five fields. The data is not:
every live booking arrives with `step: 0`, so the hub-planned route order cannot
be followed and the app falls back to sorting nearest-first. A rider works an
optimised route by guessing at it.
**Expected on each row:**
```jsonc
{ "step": 1, "stoptype": "...", "etaminutes": 10,
"cumulativekms": 2.5, "cumulativeeta": 15 }
```
This is request 13 below, restated because it is the largest remaining gap on
the logistics line and the one with no app-side workaround worth having.
### 3 · Nothing else is needed for this flow
The ARRIVED → PICKED → ACTIVE ladder needs no other backend change, unless
something has moved in the `pickup-complete` / consignment-state contract that
§1–§3 of `MILER_LOGISTICS_API.md` does not already describe.
**For the record, what shipped on the app side alongside this:** ARRIVED kept
locally; PICKED held after `pickup-complete` instead of mirroring the
compatibility-mode release; ACTIVE only once the rider presses **Start
delivery**; geofence enforcement restored at 10 m with GPS-accuracy and
stale-fix handling and a no-fix refusal; and tests pinning the rung ladder.
---
## 0a. Second delivery — what the app consumed on 24 Aug
The backend's follow-up batch (stop typing, COD, pre-pickup skip, earnings
counts, profile fields, reject-by-body) is **in and wired**. What changed on
this side:
| Shipped | Where it lands in the app |
|---|---|
| `stoptype` + `step` on `GET /miler/bookings` | Stop typing is authoritative now — the app no longer infers a stop's leg from its shape. The mixed pickup/delivery route is reachable. |
| `codamount` + `paymentmode` | Cash-to-collect is read from the booking instead of a field the payload never carried, so a COD stop states the real figure at the door. |
| `POST /bookings/:id/skip` | The pre-pickup skip. The app was posting a **booking** id to `/consignments/:id/skip` — the only skip route that existed — which either 404'd or skipped whichever consignment happened to hold that number. That is now the right route with the right id, and the stop stays resumable. |
| `cancelled_stops` + `total_stops` | The success rate has a denominator. It was `completed_stops` alone, which read 100% on a day with three cancellations. |
| `email` + `address` on `PUT /profile` | Both fields the edit screen has always collected now leave the device. `EMAIL_IN_USE` (409) is branched on by code. |
| `reason` in the reject body | Sent in the body; the query-string duplicate is dropped. |
**Still not shipped, and still shaping the app:** `step` is `0` on every live
row (request 13), so the admin route order the app is built to follow does not
exist in the payload and it falls back to its own ordering. That one is the
difference between the app following the hub's plan and inventing a plan, and
it is now written up on its own — **`MILER_ROUTE_ORDER_REQUEST.md`** — because
it is the single largest gap left between what the console believes a rider is
doing and what he is actually doing.
Short version: the console decides the order, the rider follows it, and the app
already works that way. But `step` is never written, and it disappears from
`/miler/assignments` the moment a stop is collected — which is the moment the
delivery leg starts. So every delivery in production today is ordered by the
app, not by the hub, and neither side can currently tell.
---
## 0c. Third delivery — answered 24 Aug, consumed the same day
Every open question came back. What the app did with each:
| Answer | What changed here |
|---|---|
| **Sequencing is automatic**, `sequencedat` is the authority signal, `step` survives the pickup leg | `RouteOrder` reads the stamp as the authority and accepts a positive `step` alongside it. `step: 0` + null stamp is now a *stated* three-case condition rather than an unexplained fallback. |
| **POD upload — `POST /miler/uploads/sign`** | Wired end to end: sign, `PUT` the bytes with the returned headers verbatim (the `x-amz-acl` is part of the signature, and the bearer token must not travel to the object store), send the public URL as `photourl`. A failed upload never blocks a hand-over — the parcel is in the customer's hands whatever the network did, and the local copy is kept. |
| **Skip: `attemptcount` is truth, ceiling of 3, no auto-RTO** | The app already parked rather than closed a skipped stop; it now reads the count off the response and, on the third, tells the rider the hub has it — the difference between a fourth attempt and a phone call. |
| **No `tripid`/`slotid`, ever** | The day-part split is documented client behaviour now rather than a guess standing in for a field. Recorded on `TripSlots`, which is the class that would be deleted if that changed. |
| **Delivery OTP is fully server-side** | Nothing to build: the app never claimed to verify a code, and it already branches on `OTP_INVALID` / `OTP_REQUIRED` by code. "OTP verified" is only ever drawn off a 200. |
| **`ridercharges` is the client price, not rider pay; `bonuspoints` unused** | The reward-points tile is **withheld** rather than showing a permanent zero — that reads as *you have earned nothing*, not as *not built yet*. It returns automatically the moment the figure is non-zero. No money figure anywhere in the app is presented as what a rider earned. |
| **`reached` persists behind the flag** | Already handled: the app reads the transition rather than the 200, so it is correct with the flag off and with it on. |
Left on our side: the console status mappings (`arrived_at_pickup → arrived`,
`collected_by_miler → picked`) before the flag is flipped. That is console work,
not rider-app work.
---
## 0b. Summary table
| # | Request | Priority | Status |
|---|---|---|---|
| 1 | A consignment state that means *collected, not yet riding* | **P0** | ✅ Shipped 21 Aug — `Collected_By_Miler` + `start-delivery` |
| 2 | `GET /miler/consignments/:id` (current state, one call) | **P0** | ✅ Shipped 21 Aug |
| 3 | Return `consignmentid` on `GET /miler/bookings` | **P0** | ✅ Shipped 21 Aug — with `consignmentstatus` |
| 4 | A file-upload route for proof of delivery | **P1** | ⏳ **Open** — POD cannot leave the phone |
| 5 | A booking state for *arrived at pickup* | **P1** | ✅ Shipped 21 Aug — `Arrived_At_Pickup` |
| 6 | Allow cancel/skip after pickup, or document the refusal | **P1** | ✅ Shipped 21 Aug — skip from collected |
| 7 | Real route distance + duration on the assignment | **P2** | ⏳ Open — figures are estimates |
| 8 | Persist `bonuspoints`, and a per-stop payout figure | **P2** | ⏳ Open — Earnings shows placeholders |
| 9 | Notification read-state table | **P3** | ⏳ Open — known stub |
| 13 | Route sequence — field shipped, **never populated** | **P0** | ⚠️ **Half** — `step` is on both endpoints but is `0` everywhere, `sequencedat` null. **Written up on its own: `MILER_ROUTE_ORDER_REQUEST.md`** |
| 14 | An outcome for a failed delivery attempt | **P1** | ⏳ **Open** — skip leaves the consignment open with no way to say why |
| 15 | `reached` must actually persist `Arrived_At_Pickup` | **P0** | 🔴 **BLOCKER** — reproduced: returns 200, writes nothing |
| 16 | Admin mapping for `Arrived_At_Pickup` + `Collected_By_Miler` | **P0** | 🔴 **Console change** — both absent; flag must not be flipped first |
| 17 | What `/admin/bookings` returns in `status` | **P1** | ✅ Answered 22 Aug — frozen booking status, plus `consignmentstatus` for the live one |
| 18 | Per-stop type + step ordering on `/miler/bookings` | **P0** | ✅ Shipped 24 Aug — consumed |
| 19 | `codamount` on the booking | **P0** | ✅ Shipped 24 Aug — consumed |
| 20 | A pre-pickup skip route | **P1** | ✅ Shipped 24 Aug — consumed; fixed a wrong-id post on our side |
| 21 | `cancelled_stops` / `total_stops` on earnings | **P2** | ✅ Shipped 24 Aug — consumed |
| 22 | `email` / `address` on `PUT /profile` | **P2** | ✅ Shipped 24 Aug — consumed |
| 23 | A trip/slot id, if the console owns the split into runs | **P2** | ✅ Answered 24 Aug — there is none; the day-part split is ours by agreement |
| 4 | A file-upload route for proof of delivery | **P1** | ✅ Shipped 24 Aug — signed URL, wired end to end |
| 13 | Route sequence — populated, with `sequencedat` as the authority | **P0** | ✅ Answered 24 Aug — automatic on every assignment; consumed |
| 24 | A rider payout rate card | **P2** | ⏳ **Open** — `ridercharges` is the client price; no rider figure is shown until there is one |
---
## 1. P0 — There is no consignment state meaning "collected, not yet riding"
> **✅ Shipped 21 Aug 2026 — Option A.** `pickup-complete` now stops at
> `Collected_By_Miler`; `POST /miler/consignments/:id/start-delivery` makes the
> release. The app's **Start round** is a server write again.
### Symptom (reported)
> *"If I try to update as Picked it is directly updating as **active** in the
> console."*
### Evidence — verified
`GET /miler/consignments/logs/34` returns the consignment's real history:
```
Out_for_Delivery "Package collected by miler and converted to consignment"
Delivered "Delivered to SEQTEST Ukkadam at (11.00506, 76.95087)"
```
The first row is written by **`pickup-complete`** — it carries that handler's
own remark. So on the hyperlocal path, `pickup-complete` moves the consignment
straight to `Out_for_Delivery`.
The console maps (`src/utils/bookingStatus.js`):
```js
picked_up: 'picked',
converted_to_consignment: 'picked',
out_for_delivery: 'active', // ← what the operator sees
```
So the moment the rider taps **Picked** at the kitchen counter, the consignment
is `Out_for_Delivery` and the operator's board says **active** — while the food
is still on the counter and the rider has not moved.
### Why this is a backend request, not a UI one
The app already models the distinction locally (it knows the rider has not set
off), but local state cannot reach the hub, and the operator's board is fed
from server state. There is no server state that expresses *collected but not
yet out for delivery*, so the information does not exist to display.
### Requested change — pick **one**
**Option A (preferred).** Add a consignment status between conversion and the
road:
```
Created → Inwarded_at_Hub → … → Out_for_Delivery → Delivered
▲
Collected_By_Miler ← NEW
```
`pickup-complete` writes `Collected_By_Miler` for hyperlocal work.
A new call moves it on:
```http
POST /miler/consignments/:id/start-delivery
→ 200 { success, data: { consignmentid, status: "Out_for_Delivery" } }
400 if the consignment is not Collected_By_Miler
```
`deliver` then accepts `Out_for_Delivery` exactly as it does now.
**Option B (smaller).** Leave the consignment alone and keep the **booking** at
`Picked_Up` until the rider starts the round, moving it to
`Converted_To_Consignment` at that point. Cheaper, but it leaves the two
vocabularies coupled, which is the root of several problems in this document.
> **Note on `POST /miler/deliveries/start`:** the app used to call this. It is
> **not** in the 38-route contract and returns `404 Cannot POST` — verified. We
> have removed the call. Request 1 Option A is the properly-specced replacement.
---
## 2. P0 — No way to read a consignment's current state in one call
> **✅ Shipped 21 Aug 2026.** `GET /miler/consignments/:consignmentid` returns
> the status plus `collected` / `out_for_delivery` / `delivered` /
> `can_start_delivery` / `can_deliver` / `can_skip`. The gate reads it; the
> logs walk remains only as a fallback for older deployments.
### Symptom (reported)
> *"I can't update it as delivered/skipped/cancelled — it's showing a lot of
> errors."*
### Evidence — verified
`POST /miler/consignments/34/deliver` →
```json
400 { "success": false, "message": "consignment is not out for delivery" }
```
That message is returned for **two opposite situations**:
| Real state | Meaning | What the rider should be told |
|---|---|---|
| `Inwarded_at_Hub` | not released yet | "the hub still has it" |
| `Delivered` | **already delivered** | "already done — nothing to do" |
In the reported case it was the second: consignment 34 had been delivered at
10:59 the previous day. The rider was shown an error for work he had completed.
### Why the app cannot fix this alone
`GET /miler/bookings` reports only the **booking** status, which is terminal at
`Converted_To_Consignment` and never learns about the delivery half. The only
route that exposes consignment state is
`GET /miler/consignments/logs/:consignmentid`, which returns the whole event
history — the app now sorts it by `historyid` and takes the last row. That
works, but it is a log-walk standing in for a state read, on the rider's mobile
data, before every delivery.
### Requested change
```http
GET /miler/consignments/:consignmentid
→ 200 {
success: true,
data: {
consignmentid: 34,
trackingno: "DM-TRK-…",
status: "Out_for_Delivery",
bookingid: 60,
attemptcount: 0,
updatedat: "2026-08-20T10:59:35Z"
}
}
404 if it is not this rider's consignment
```
**And** make the two refusals distinguishable — either distinct messages or,
better, a stable code:
```json
400 { "success": false, "code": "CONSIGNMENT_ALREADY_DELIVERED",
"message": "consignment already delivered" }
400 { "success": false, "code": "CONSIGNMENT_NOT_RELEASED",
"message": "consignment is not out for delivery" }
```
A machine-readable `code` on every 4xx across `/miler/*` would let the app stop
string-matching messages, which is fragile in exactly the way this bug proves.
---
## 3. P0 — `GET /miler/bookings` does not return `consignmentid`
> **✅ Shipped 21 Aug 2026.** Both `consignmentid` and `consignmentstatus` are
> on the list rows. The 17-of-29 gap is closed, and the consignment status now
> outranks the booking status the app draws the card from.
### Evidence — verified
24 booking rows returned for rider 38; **zero** contain any `consignment*` key.
`GET /miler/assignments` (list) does not carry it either. It appears **only** in
`GET /miler/assignments/:id`, nested inside `booking`:
```jsonc
// GET /miler/assignments/71
{ "data": { "assignment": {…}, "booking": { "bookingid": 75, …,
"consignmentid": 46 } } }
```
### This is not a performance concern — it blocks deliveries outright
**Verified 21 Aug 2026, rider 38:**
| Endpoint | Rows | Booking ids |
|---|---|---|
| `GET /miler/bookings` | **29** | 26, 28, 41, 51, **54–78**, 82 |
| `GET /miler/assignments` | **12** | 26, 28, 41, 51, 72–78, 82 |
**17 of 29 bookings have no assignment row at all.** `?status=Completed`,
`?status=all`, `?pagesize=200` and `?bookingid=59` all return the same 12 rows —
the endpoint ignores query parameters.
So for booking **59** (`DM-BK-501CB551-45130`, status
`Converted_To_Consignment`, sitting on the rider's Deliveries tab) every route
to its consignment id is a dead end:
1. the booking row — carries no consignment key *(none of the 29 do)*
2. the app's local record from `pickup-complete` — absent after a reinstall,
and absent entirely if that response did not carry the id
3. `GET /miler/assignments/:id` — **booking 59 is not in the assignments list**,
so there is no assignment id to fetch
4. `GET /miler/consignments/userlogs/:userid` — telemetry only (1 row, for an
unrelated consignment); carries no booking linkage
**There is no fifth route.** The rider is holding the parcel, the consignment
demonstrably exists (the booking is converted), and the app cannot name it — so
`deliver` can never be called and the stop can never be completed from the app.
This is the single highest-impact item in this document.
### Requested change
Add `consignmentid` (nullable) and `consignmentstatus` to every row of:
- `GET /miler/bookings`
- `GET /miler/assignments`
…and separately, **`GET /miler/assignments` should return every assignment the
rider still has work for**, not a 12-row subset. If that list is intentionally
scoped to active assignments, say so and we will stop treating it as a lookup
table — but then requirement (a) below becomes mandatory rather than merely
strongly preferred.
```jsonc
{ "bookingid": 75, "status": "Converted_To_Consignment",
"consignmentid": 46, "consignmentstatus": "Out_for_Delivery" }
```
This single change removes an N+1 call pattern **and** makes Request 2's read
unnecessary for the list case.
---
## 4. P1 — No upload route: proof of delivery cannot leave the phone
> **⏳ STILL OPEN.** The photo is captured, previewed and stored on the device,
> and it is shown on the Activity record. It cannot reach the hub until this
> route exists. This is now the highest-priority outstanding item.
### What the app now does
The rider taps **Delivered** → a proof screen opens the camera → he sees the
photo → **Mark as delivered** on the same screen. The photo is stored on the
handset and shown on the delivery record in Activity.
### The blocker
`POST /miler/consignments/:id/deliver` takes `photourl` — a **string**. Nothing
in the 38-route contract accepts a file: no multipart route, no signed-URL
endpoint, no attachment on any other call.
We deliberately **do not** send the device file path in `photourl`. A path from
somebody's phone is not a URL; writing one into the hub's record would store a
string that looks like evidence and resolves to nothing.
**So proof of delivery currently exists only on the rider's phone**, and the
hub cannot see it. For a delivery business this is the difference between
having evidence and believing you have it.
### Requested change — either shape works
**Option A — direct upload (simplest for us).**
```http
POST /miler/uploads
Content-Type: multipart/form-data
file: <binary> (jpeg, ≤ 5 MB)
purpose: "delivery_proof" ("pickup_proof" | "signature" | …)
consignmentid: 46 (optional, for association)
→ 201 { success, data: { url: "https://cdn.doormile.com/proof/46-abc.jpg" } }
```
**Option B — signed URL (cheaper server-side).**
```http
POST /miler/uploads/sign
{ "purpose": "delivery_proof", "contentType": "image/jpeg" }
→ 200 { success, data: { uploadUrl: "https://…?X-Amz-Signature=…",
url: "https://cdn.doormile.com/proof/…" } }
```
The app PUTs the bytes to `uploadUrl`, then sends `url` as `photourl`.
**Constraints from our side:** photos are JPEG, quality 70, max width 1280 —
typically 80–250 KB. Riders are on mobile data and often on 3G, so the upload
must be retryable and must **not** block the delivery: we will complete the
stop and upload in the background, then attach.
Please also confirm whether `receiversignatureurl` is expected to be fed from
the same mechanism.
---
## 5. P1 — "Arrived" does not persist: there is no booking state for it
> **✅ Shipped 21 Aug 2026.** `reached` persists `Arrived_At_Pickup`. The app
> parses it (and the older `At_Customer` spelling) to the *arrived* rung.
### Symptom (reported)
> *"If I click Arrived on the home page it is not updating as arrived."*
### Evidence
The app calls `POST /miler/bookings/:bookingid/reached` with the booking id —
correct per contract. But the booking enum is:
```
Pending_Pickup, Created, Miler_Assigned, Pickup_Scheduled,
Picked_Up, Converted_To_Consignment, Cancelled
```
**There is no `Arrived`.** Whatever `reached` writes, the operator's board maps
`pickup_scheduled → 'accepted'`, so an arrived rider is indistinguishable from
one who merely accepted the job and is still 20 km away.
### Requested change
Add `Arrived_At_Pickup` to the booking enum, written by `reached`, and return
the new status in the response so the app can confirm rather than assume:
```http
POST /miler/bookings/:bookingid/reached
→ 200 { success, data: { bookingid, status: "Arrived_At_Pickup",
reachedat: "2026-08-21T09:14:02Z" } }
```
**Every state-changing route should return the resulting entity state.** Today
most return `{success: true}` with no body, so the app cannot verify the
transition landed and must re-poll the list.
---
## 6. P1 — A rider cannot report a failed delivery
> **✅ Shipped 21 Aug 2026.** `skip` is accepted from `Collected_By_Miler` as
> well as `Out_for_Delivery`.
### Symptom (reported)
> *"I can't update it as … skipped/cancelled."*
### Evidence
- `POST /miler/consignments/:id/skip` requires `Out_for_Delivery` — so a
consignment in any other state cannot be skipped, including one the rider is
genuinely holding.
- `POST /miler/bookings/:id/cancel` is **"refused once picked up"** per the
contract. After `pickup-complete` there is therefore *no* route by which a
rider can say "this cannot be completed" — he is holding a parcel with no way
to report the failure.
### Requested change
1. Allow `skip` from `Collected_By_Miler` **and** `Out_for_Delivery` (see
Request 1).
2. Add a consignment-level failure route:
```http
POST /miler/consignments/:id/fail
{ "reason": "Customer refused", "lat": 11.0, "lon": 76.9 }
→ 200 { success, data: { status: "RTO_Initiated", attemptcount: 2 } }
```
3. Document the maximum `attemptcount` and what the hub does when it is hit —
the app should stop offering "skip" at that point rather than letting the
rider discover the ceiling by being refused.
---
## 7. P2 — The Home figures are estimates, not real data
**Direct answer to the question asked.** The four figures on Home
(`DURATION · DISTANCE · PARCELS · PAYMENT`) are:
| Figure | Source | Real? |
|---|---|---|
| **PARCELS** | sum of parcel counts on the booking rows | ✅ **Real** (backend data) |
| **PAYMENT** | sum of `collectionamt` on the booking rows | ✅ **Real** (backend data) |
| **DISTANCE** | *client-side* haversine: hub → each stop in order → hub, straight lines. Falls back to summing each row's `kms` when coordinates are missing | ⚠️ **Estimate** |
| **DURATION** | *client-side*: straight-line distance ÷ an assumed 20 km/h, **plus** a hardcoded service time per stop — 2m30 delivery / 4m00 pickup / 5m30 combined, +90s if cash, +25s per extra parcel | ⚠️ **Estimate** |
So two of the four are real and two are the app's own arithmetic. Straight-line
distance under-reads real road distance by roughly 20–40% in Coimbatore, and
the service times are guesses that have never been measured against actual
stop durations.
### Requested change
Return the routed figures on the assignment/trip, computed once server-side
(you already have the coordinates, and a routing engine gives real road
distance):
```jsonc
// GET /miler/assignments — per row, or a trip-level summary
{
"routedistancemeters": 21800, // real road distance for the leg
"routedurationseconds": 1620, // real drive time
"cumulativedistancemeters": 48200,
"cumulativedurationseconds": 5400
}
```
The assignment model already has `riderkms`, `previouskms`, `cumulativekms`,
`etaminutes` and `cumulativeeta` — **all of them return 0** today (verified on
all 24 rows). Populating those existing fields would be enough; no new schema
needed.
**Until then the app will keep showing estimates, because inventing precision
we do not have is worse than an honest approximation.**
---
## 8. P2 — Earnings has no real per-stop money
- `bonuspoints` is never written (acknowledged in your own doc).
- There is no per-stop payout anywhere in the API. `deliver` computes
`riderkms` and `ridercharges` server-side and returns **neither**;
`GET /miler/earnings` answers for a *period* only.
The app therefore shows `Stop payout · See Account` on the delivery record
rather than a number, because a figure derived from a rate the app does not
hold is the one invention a rider would act on and be wrong about.
### Requested change
Return the earnings the server already computed, on the response to `deliver`
and on the booking/consignment rows:
```jsonc
{ "riderkms": 4.6, "ridercharges": 38.50, "bonuspoints": 5 }
```
---
## 9. Battery percentage — direct answer
**Yes, it can reach the console today, and the plumbing already exists on both
sides.**
- The app reads the real battery level (`battery_plus`) in
`live_tracking_service.dart` and `foreground_service.dart`.
- It is sent on `POST /miler/logs` and `POST /miler/consignments/logs` as the
documented **string** field `battery`.
- The console already renders it — its rider-detail page (`riders.js`):
`riderLogsdata?.battery ? \`${battery}%\` : 'N/A'`.
**What to check on your side:** the console reads it from *rider logs*, so it
only appears while the app is posting telemetry (on duty, service running). If
operators see `N/A`, the likely causes in order are: (a) the rider is off duty
so nothing is being posted; (b) `GET /miler/logs` is returning the latest row
without `battery` populated; (c) the value is being stored but the console's
rider-detail query is not selecting it.
**One request:** expose the most recent telemetry row per rider in one call, so
the console does not have to page logs to find a current battery/GPS reading:
```http
GET /miler/riders/:userid/last-seen (or /admin equivalent)
→ { success, data: { latitude, longitude, battery, speed, status,
logdate, connection } }
```
---
## 10. Cross-cutting requests
> **✅ 10.1–10.4 shipped 21 Aug 2026.** Mutations return the resulting state,
> `pickup-complete` returns `consignment_id` and `next_action`, 4xx bodies
> carry a machine-readable `code`, and `Idempotency-Key` is honoured. 10.5
> (`requiredeliveryotp` on the tenant payload) is still open.
These are small and would remove whole classes of defect:
1. **Return the resulting state from every mutation.** `accept`, `reject`,
`reached`, `parcel`, `payment`, `pickup-complete`, `deliver`, `skip`,
`cancel` mostly return `{success: true}`. The app cannot confirm a
transition and must re-poll.
2. **`pickup-complete` must return the `consignmentid` it minted.** It is the
one moment the id is guaranteed to exist. The app records it locally from
the response today; when the response omits it, the delivery leg has to go
hunting (Request 3).
3. **Machine-readable error codes on every 4xx** (see Request 2). String
matching on `"consignment is not out for delivery"` is how the app ended up
telling riders the wrong thing.
4. **Idempotency.** `deliver`, `pickup-complete` and `accept` are not
idempotent. A dropped response on a bad connection means the rider retries
and gets a 400 for work that succeeded. Accepting an
`Idempotency-Key` header and replaying the original result would remove
this entire failure mode.
5. **Confirm `requiredeliveryotp` per tenant** is exposed to the app. We
currently send `otp` only when we have one and never fabricate a
placeholder; if the flag were on the profile/tenant payload we could show
or hide the OTP field correctly instead of inferring.
---
## 11. What the app already does correctly (no change needed)
Recorded so the backend team does not chase these:
- `deliver`/`skip` key on the **consignment id**, never the booking, assignment
or pickup id.
- `accept`/`reject` key on `bookingassignmentid`.
- `reached`/`parcel`/`payment`/`pickup-complete`/`cancel` key on `bookingid`.
- Telemetry sends lat/long/speed/heading/battery as **strings**, and never
sends `userid` in the body.
- Booking statuses and consignment statuses are handled as two separate
vocabularies; the app no longer infers a delivery state from a booking
status.
- Duplicate taps are collapsed client-side (one in-flight mutation per
resource+verb), but see Request 10.4 — that is not a substitute for
server-side idempotency.
---
## 11b. Request 13 — P0: the route sequence is exposed but never populated
**Re-verified live 21 Aug 2026** against rider `userid 23 / tenantid 1` —
`GET /miler/bookings` (29 rows) and `GET /miler/assignments` (11 rows).
### The transport is there. Thank you.
`step` is on **both** endpoints, alongside `sequencedat`, `etaminutes`,
`cumulativekms`, `cumulativeeta` and `stoptype`. That closes the client half of
this entirely: the app now reads `step` off the booking row, orders both legs
by it, and never re-sorts an assigned route.
### The data is empty
```
/miler/bookings step = 0 on all 29 rows
/miler/assignments step = 0 on all 11 rows
sequencedat = null on all 11 rows
etaminutes = 0, riderkms = 0, cumulativekms = 0
```
`sequencedat: null` on every assignment means **the route optimizer has never
run for this rider**. So there is no admin route to follow — not one the app is
dropping, one that does not exist.
### What the app does about it
It does not pretend. With no sequence anywhere in the set it falls back —
booked time first, then nearest-first — and **labels the queue on screen** with
which rule produced the order (`Hub route` vs `Nearest first`), so a rider is
never told the app's guess is his route. It is also logged per fetch:
```
[ROUTE][deliveries] 7 stop(s) with no assigned sequence — ordering is the
app's own fallback, not the hub's route
```
### Requested change
Populate it. Whatever writes `sequencedat` — batch assign, the console's
sequence action, a scheduled optimizer pass — is either not running or not
running for this tenant. Once `step` comes down non-zero, the app follows it on
both legs with no further change and no release needed.
**Please confirm:** is sequencing expected to be automatic on assignment, or is
it a deliberate action a hub manager has to take in the console? The answer
changes whether "no route assigned" is a normal state the rider should see or a
fault worth alerting on.
---
## 11c. Request 14 — P1: a failed delivery attempt has no outcome
`POST /miler/consignments/:id/skip` returns 200 and, as far as the app can
tell, leaves the consignment `Out_for_Delivery`. There is no state, field or
route that says *this stop was attempted and failed*.
That leaves the two systems unable to agree on whether the stop is still
actionable:
- The **rider** has been to the door. It is done with, for him, today.
- The **hub** still has an open `Out_for_Delivery` consignment.
The app refuses to resolve that by inventing a terminal state locally — it
reads the consignment after every skip and only files the stop as finished if
the server agrees it is closed. When the server keeps it open, the stop is
**parked**: still visible under SKIPPED with the rider's reason, still holding
its consignment mapping, resumable by him. Honest, but it is a workaround for a
missing contract.
### Requested change — any one of these
1. A consignment state for it — `Delivery_Failed` / `Attempt_Failed` — that
`skip` moves it to, with whatever reassignment or RTO the hub decides
happening from there.
2. An `attemptcount` + `lastattemptat` on the consignment, so the app can at
least show *"attempt 2 of 3"* and the hub can act on the count.
3. Documented confirmation that a skip is expected to leave the consignment
open and that the rider is meant to retry it in the same shift — in which
case the app will surface it as retryable rather than as a closed record.
Whichever you pick, the app needs to know **who owns the stop after a skip**.
---
## 11d. Request 15 — P0 BLOCKER: `reached` is a deployed no-op
**Reproduced against production 21 Aug 2026.** Rider `userid 23 / tenantid 1`,
booking 78, three calls in sequence:
```
GET /miler/bookings → booking 78 status = Miler_Assigned
POST /miler/bookings/78/reached
{"lat":11.0168,"lon":76.9558}
→ 200 {"success":true,"data":{"bookingid":78,"status":"Miler_Assigned"}}
GET /miler/bookings → booking 78 status = Miler_Assigned
```
The endpoint returns `success: true`, echoes the booking's **unchanged**
status, and writes nothing. `Arrived_At_Pickup` is not written and does not
appear on any of the 31 bookings in this tenant.
**This is the whole reason Arrived never reaches Admin.** It is not a client
bug: the app calls the documented endpoint with the documented body and gets a
200. It had been treating that 200 as proof of a transition, which it no longer
does — the app now reads the returned status and, when it is not
`Arrived_At_Pickup`, tells the rider his hub has not recorded the arrival
rather than drawing a tick.
### Requested change
`POST /miler/bookings/:bookingid/reached` must persist `Arrived_At_Pickup` on
the booking and return it:
```
POST /miler/bookings/:bookingid/reached
{ "lat": 11.0168, "lon": 76.9558 }
200 { "success": true,
"data": { "bookingid": 78, "status": "Arrived_At_Pickup" } }
```
**Please also confirm which is true**, because they need different fixes:
1. the handler was never wired to write, or
2. it writes only from `Pickup_Scheduled` and silently no-ops from
`Miler_Assigned` (booking 78 was assigned but not yet accepted).
If (2), say so and the app will stop offering **I've arrived** before the
accept lands. If it is meant to be callable from `Miler_Assigned`, it must
write from there too.
---
## 11e. Request 16 — P0: Admin has no mapping for the two new statuses
**Not a backend change — a console change.** Traced in
`Doormilexpress_console/src/utils/bookingStatus.js`:
```js
export const BOOKING_STATUS_TO_DELIVERY_STATUS = {
created: 'pending', pending_pickup: 'pending',
miler_assigned: 'pending', pickup_scheduled: 'accepted',
picked_up: 'picked', converted_to_consignment: 'picked',
out_for_delivery: 'active',
delivered: 'delivered', cancelled: 'cancelled'
};
```
Neither `arrived_at_pickup` nor `collected_by_miler` is in it. Unmapped
statuses "pass through lowercased", which the Deliveries page renders as an
unknown badge — and the file's own comment says the Arrived tab "will show a 0
count until the real enum is confirmed".
Two consequences the console team must act on:
1. **Even after request 15 ships, Admin still will not show Arrived.** Add
`arrived_at_pickup: 'arrived'`.
2. **Enabling `MILER_COLLECTED_STATE_ENABLED` today would make Admin worse,
not better.** With no `collected_by_miler` key, every freshly collected
parcel would render as an unknown badge instead of Active. Add
`collected_by_miler: 'picked'` **before** the flag is flipped.
### Ordering — this matters
```
1. console adds arrived_at_pickup + collected_by_miler mappings
2. backend fixes `reached` to persist Arrived_At_Pickup (request 15)
3. this app build ships — it already supports both lifecycles
4. only then flip MILER_COLLECTED_STATE_ENABLED = true
```
Flipping the flag before step 1 or step 3 breaks the rider's Picked rung.
---
## 11f. Request 17 — the one thing still unproven: what `/admin/bookings` puts in `status`
`GET /miler/bookings` reports the **booking** status: booking 77 is
`Converted_To_Consignment` with `consignmentstatus: Out_for_Delivery`.
The console reads `b.status` from `/admin/bookings` and maps it
(`api.js:611` and `api.js:664`). If that field held `Converted_To_Consignment`
too, the console would render **Picked** — which is not what we see; it shows
**Active**.
So exactly one of these is true, and we cannot tell which from the rider token:
| # | Possibility | How to confirm | Owner |
|---|---|---|---|
| A | `/admin/bookings` projects the consignment status onto `status` | one network call in the console's dev tools | backend |
| B | the backend also writes `Out_for_Delivery` onto the booking row for hyperlocal, and `/miler/bookings` reports something different | compare both endpoints for the same bookingid | backend |
| C | the deployed console is older than this source (the repo comment says `converted_to_consignment` "used to map to `accepted`") | check the deployed bundle | console |
**Please open the console's Deliveries page, look at the `/admin/bookings`
response for one collected booking, and tell us the value of `status`.** That
single value decides which of the three it is, and none of them is fixed in the
rider app.
Whichever it is, note that in compatibility mode **Active is not wrong** — the
consignment genuinely is `Out_for_Delivery`. The app now agrees with the
console instead of showing `Picked` over it. Picked/Collected becomes a real,
separate rung only once the flag is on.
---
## 12. Open questions for the backend team
~~1. Is `Collected_By_Miler` (Request 1 Option A) acceptable, or do you prefer
holding the booking at `Picked_Up` (Option B)?~~ **Answered: Option A, shipped.**
2. Which upload shape do you want to support — direct multipart or signed URL?
3. Is there an existing object store / CDN we should target for proof photos,
and what retention applies to them?
4. Can `riderkms` / `etaminutes` / `cumulativekms` be populated from the
routing engine, or is there no routing engine in the stack today?
5. What is the intended `attemptcount` ceiling, and what happens at it?
6. **Who owns the split into Trip 1 / Trip 2 / Trip 3?** The app groups a
rider's stops into up to three runs by *day part* — morning, afternoon,
evening — because nothing in the payload names a run. If the console assigns
runs, we should be following its ids rather than guessing, and a rider's
"Trip 2" and the hub's would then be the same object. Either answer is
workable; we need to know which:
- **The console owns it** → return `tripid` (and `slotid`, if separate) on
every row of `GET /miler/bookings`, stable for the day. The app follows it
and drops the day-part split entirely.
- **Nobody owns it** → say so, and the day-part split stays as the documented
client behaviour rather than an unlabelled guess.
7. **Is `step` ever written today?** Request 13 says the field exists and is `0`
on every live row. Before we spend more time on the fallback: is sequencing
generated automatically when assignments are created, or does an operator
have to trigger a route/optimizer action? If it is manual, the app should
probably say *unsequenced* rather than silently ordering the stops itself.
8. **Where should a re-quoted price go?** A logistics rider re-prices a shipment
at the door. `POST /bookings/:id/payment` records what was *collected*, and
`bookingserviceoptions.estimatedprice` is the customer's original figure and
is not rider-writable — so the new quote has nowhere to land and the booking
keeps an estimate nobody honoured. A rider-writable price field on the
booking, or an accepted field on `parcel`, closes it.
9. **Delivery OTP.** `deliver` takes an `otp` and the tenant flag
`requiredeliveryotp` gates it, but there is no delivery-OTP column on
consignments — so with the flag on there is nothing to check the code
against. Is the column planned? Until it exists the app records that a code
was *presented*, not that it was correct, and we would rather not draw a
verification the backend cannot perform.