Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
790 lines
34 KiB
Markdown
790 lines
34 KiB
Markdown
# Miler App — API requirements for the backend team
|
||
|
||
**From:** Miler rider-app engineering
|
||
**Date:** 21 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.
|
||
|
||
---
|
||
|
||
## 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 |
|
||
| 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** | ❓ Unproven — one console network call answers it |
|
||
|
||
---
|
||
|
||
## 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 — `src/pages/nearle/riders/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?
|