Files
doormile_milderapp/MILER_API_REQUIREMENTS.md
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
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>
2026-08-22 05:40:35 +05:30

34 KiB
Raw Blame History

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):

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 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 →

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

// 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.

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

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:
POST /miler/consignments/:id/fail
{ "reason": "Customer refused", "lat": 11.0, "lon": 76.9 }
→ 200 { success, data: { status: "RTO_Initiated", attemptcount: 2 } }
  1. 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):

// 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:

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

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:

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.

  1. Which upload shape do you want to support — direct multipart or signed URL?
  2. Is there an existing object store / CDN we should target for proof photos, and what retention applies to them?
  3. Can riderkms / etaminutes / cumulativekms be populated from the routing engine, or is there no routing engine in the stack today?
  4. What is the intended attemptcount ceiling, and what happens at it?