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>
34 KiB
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
consignmentstatusalready readsOut_for_Deliverywhen 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 astart-deliverythat can only be refused. - Flag on — the row reads
Collected_By_Miler, the press callsstart-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-completenow stops atCollected_By_Miler;POST /miler/consignments/:id/start-deliverymakes 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):
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:
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 returns404 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/:consignmentidreturns the status pluscollected/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 →
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
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:
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
consignmentidandconsignmentstatusare 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:
// 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:
- the booking row — carries no consignment key (none of the 29 do)
- the app's local record from
pickup-complete— absent after a reinstall, and absent entirely if that response did not carry the id GET /miler/assignments/:id— booking 59 is not in the assignments list, so there is no assignment id to fetchGET /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/bookingsGET /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.
{ "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).
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).
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.
reachedpersistsArrived_At_Pickup. The app parses it (and the olderAt_Customerspelling) 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:
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.
skipis accepted fromCollected_By_Mileras well asOut_for_Delivery.
Symptom (reported)
"I can't update it as … skipped/cancelled."
Evidence
POST /miler/consignments/:id/skiprequiresOut_for_Delivery— so a consignment in any other state cannot be skipped, including one the rider is genuinely holding.POST /miler/bookings/:id/cancelis "refused once picked up" per the contract. Afterpickup-completethere 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
- Allow
skipfromCollected_By_MilerandOut_for_Delivery(see Request 1). - Add a consignment-level failure route:
POST /miler/consignments/:id/fail
{ "reason": "Customer refused", "lat": 11.0, "lon": 76.9 }
→ 200 { success, data: { status: "RTO_Initiated", attemptcount: 2 } }
- Document the maximum
attemptcountand 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):
// 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
bonuspointsis never written (acknowledged in your own doc).- There is no per-stop payout anywhere in the API.
delivercomputesriderkmsandriderchargesserver-side and returns neither;GET /miler/earningsanswers 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:
{ "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) inlive_tracking_service.dartandforeground_service.dart. - It is sent on
POST /miler/logsandPOST /miler/consignments/logsas the documented string fieldbattery. - 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:
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-completereturnsconsignment_idandnext_action, 4xx bodies carry a machine-readablecode, andIdempotency-Keyis honoured. 10.5 (requiredeliveryotpon the tenant payload) is still open.
These are small and would remove whole classes of defect:
-
Return the resulting state from every mutation.
accept,reject,reached,parcel,payment,pickup-complete,deliver,skip,cancelmostly return{success: true}. The app cannot confirm a transition and must re-poll. -
pickup-completemust return theconsignmentidit 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). -
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. -
Idempotency.
deliver,pickup-completeandacceptare not idempotent. A dropped response on a bad connection means the rider retries and gets a 400 for work that succeeded. Accepting anIdempotency-Keyheader and replaying the original result would remove this entire failure mode. -
Confirm
requiredeliveryotpper tenant is exposed to the app. We currently sendotponly 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/skipkey on the consignment id, never the booking, assignment or pickup id.accept/rejectkey onbookingassignmentid.reached/parcel/payment/pickup-complete/cancelkey onbookingid.- Telemetry sends lat/long/speed/heading/battery as strings, and never
sends
useridin 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_Deliveryconsignment.
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
- A consignment state for it —
Delivery_Failed/Attempt_Failed— thatskipmoves it to, with whatever reassignment or RTO the hub decides happening from there. - An
attemptcount+lastattemptaton the consignment, so the app can at least show "attempt 2 of 3" and the hub can act on the count. - 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:
- the handler was never wired to write, or
- it writes only from
Pickup_Scheduledand silently no-ops fromMiler_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:
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:
- Even after request 15 ships, Admin still will not show Arrived. Add
arrived_at_pickup: 'arrived'. - Enabling
MILER_COLLECTED_STATE_ENABLEDtoday would make Admin worse, not better. With nocollected_by_milerkey, every freshly collected parcel would render as an unknown badge instead of Active. Addcollected_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 Answered: Option A, shipped.Collected_By_Miler (Request 1 Option A) acceptable, or do you prefer
holding the booking at Picked_Up (Option B)?
- Which upload shape do you want to support — direct multipart or signed URL?
- Is there an existing object store / CDN we should target for proof photos, and what retention applies to them?
- Can
riderkms/etaminutes/cumulativekmsbe populated from the routing engine, or is there no routing engine in the stack today? - What is the intended
attemptcountceiling, and what happens at it?