This commit is contained in:
2026-09-22 15:27:13 +05:30
parent 29e189b7e0
commit ee79c80338
8 changed files with 1042 additions and 4 deletions

View File

@@ -13,6 +13,40 @@ const (
MilerBlocked = "Blocked"
)
// MilerWorkingStatuses are the states in which a miler may be GIVEN more work.
//
// A miler carrying an order is still a miler on the road. Courier rounds are
// multi-stop by nature, and every candidate query used to test
// `availabilitystatus = 'Available'`, which treats the first booking as a
// lock: the moment a rider took one order they vanished from every assignment
// path, and the per-rider load caps that exist precisely to govern this never
// got a chance to run. Whether a rider can take another job is a question
// about how much they are already carrying — counted from their open
// assignments — not about whether they are carrying anything at all.
//
// Offline, Break and Blocked are the only states that take a miler out. They
// are excluded by naming the ones that are in, so a status added later is
// off-duty until someone decides otherwise.
var MilerWorkingStatuses = []string{
MilerAvailable,
MilerAssigned,
MilerOnPickup,
MilerAtCustomer,
MilerPickedUp,
MilerOnDelivery,
}
// MilerCanTakeWork reports whether a miler in this state may receive another
// booking. Load is capped separately, by counting open assignments.
func MilerCanTakeWork(status string) bool {
for _, s := range MilerWorkingStatuses {
if s == status {
return true
}
}
return false
}
// Booking sources — where a booking originated. Left as their stored literals:
// "CRM_Console" predates the outward express rename and is an existing DB value.
const (

View File

@@ -0,0 +1,57 @@
package constants
import "testing"
// Who may be handed another booking.
//
// The rule these lock down replaced `availabilitystatus == "Available"`, which
// treated a rider's first order as a lock: from then on they were invisible to
// every assignment path, and the per-rider load caps that exist to decide this
// never ran. A courier round is multi-stop; carrying something is the normal
// state of a working miler, not a reason to be skipped.
func TestMilerCanTakeWork(t *testing.T) {
cases := []struct {
status string
want bool
why string
}{
{MilerAvailable, true, "idle and on duty"},
{MilerAssigned, true, "holding stops — the case the old rule wrongly excluded"},
{MilerOnPickup, true, "mid-collection, still adding to the round"},
{MilerAtCustomer, true, "at a door, next stop can still be planned"},
{MilerPickedUp, true, "parcels in hand"},
{MilerOnDelivery, true, "running the round"},
{MilerOffline, false, "not on duty"},
{MilerBreak, false, "on a break — do not pile work on"},
{MilerBlocked, false, "blocked by ops"},
{"", false, "unknown status is off duty, never a default yes"},
{"available", false, "the stored values are capitalised; a case slip must not silently pass"},
{"Retired", false, "a status nobody has taught this rule about is off duty"},
}
for _, tc := range cases {
if got := MilerCanTakeWork(tc.status); got != tc.want {
t.Errorf("MilerCanTakeWork(%q) = %v, want %v (%s)", tc.status, got, tc.want, tc.why)
}
}
}
// The off-duty states are excluded by omission, so a status added to the enum
// later is off duty until somebody decides otherwise. This fails if a new
// constant is added to the list without being considered here.
func TestMilerWorkingStatusesExcludesOffDuty(t *testing.T) {
offDuty := []string{MilerOffline, MilerBreak, MilerBlocked}
for _, bad := range offDuty {
for _, s := range MilerWorkingStatuses {
if s == bad {
t.Errorf("%q must not be in MilerWorkingStatuses", bad)
}
}
}
if len(MilerWorkingStatuses) != 6 {
t.Errorf("MilerWorkingStatuses has %d entries, expected 6 — a status was added or removed; "+
"confirm it should receive work before updating this count", len(MilerWorkingStatuses))
}
}

View File

@@ -1993,16 +1993,32 @@ func HubBatchAssign(c *fiber.Ctx) error {
return utils.OK(c, fiber.Map{"assigned": 0, "skipped": 0, "results": []fiber.Map{}})
}
// Every rider on duty at this hub, not only the idle ones. capPerRider is
// what limits a round; requiring Available made that limit unreachable,
// because a rider stopped being Available the moment they took the first
// booking of the very batch being built.
var riderProfiles []models.MilerProfile
if err := db.DB.Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerAvailable).
if err := db.DB.Where("hubid = ? AND availabilitystatus IN ?", hubID, constants.MilerWorkingStatuses).
Find(&riderProfiles).Error; err != nil {
return utils.Internal(c, "failed to fetch available riders")
}
candidates := make([]*batchRiderCandidate, 0, len(riderProfiles))
for _, mp := range riderProfiles {
// Seed the count with what the rider is ALREADY holding. capPerRider
// has to mean "stops in hand", not "stops added by this call" — now
// that busy riders are eligible, counting only this call's additions
// would hand five more to someone already carrying five.
var openStops int64
db.DB.Model(&models.BookingAssignment{}).
Where("mileruserid = ? AND assignmentstatus IN ?", mp.Userid, []string{
constants.AssignmentAssigned,
constants.AssignmentAccepted,
}).
Count(&openStops)
candidates = append(candidates, &batchRiderCandidate{
userid: mp.Userid, lat: mp.Currentlatitude, lon: mp.Currentlongitude,
assigned: int(openStops),
})
}

View File

@@ -0,0 +1,692 @@
# Doormile Backend — Customer App Integration Handbook
**For:** the developer building `doormile_customer_app` (Flutter)
**Backend:** `doormile_backend` @ `main` (Go · Fiber v2 · PostgreSQL · Redis · NATS)
**Base URL:** `https://api.doormile.com/api/v1`
**Namespace:** everything you call lives under `/customer/*` — 28 routes, no exceptions
**Verified against source:** 16 Sep 2026
> This is the *practical* handbook. Two companion docs already exist and are still correct:
> `docs/customer-app-api.md` (the full change record and design rationale) and
> `docs/customer-app-api-crisp.md` (the terse endpoint reference).
> This file is what you need to actually ship — the flows, the gotchas, and the things that will
> waste your week if nobody tells you.
---
## 0. Read this first — two things are blocking you today
### 0.1 There is no SMS gateway. Nobody can sign in.
`sms.Register()` had **zero callers** in the entire backend until this week. The package shipped with a
logging sink standing in for a real gateway, and the sink was never replaced. So:
```
POST /customer/auth/otp/request
-> backend generates a 4-digit code
-> writes it to the APPLICATION LOG
-> returns { success: true } <-- a lie
-> no text is ever sent
```
The endpoint reports success. No customer has ever received a code.
**What changed:** `sms.Configure()` is now called at boot (`main.go`), and the log sink now *refuses*
in production instead of pretending. The boot log says plainly which transport it got, and
`GET /api/v1/ready` reports it:
```json
{ "sms": { "transport": "log", "configured": false } }
```
**What has NOT changed:** there is still no provider. `SMS_GATEWAY_URL` is unset. That is a
procurement item (an Indian provider plus DLT template registration), not something you or I can code
around.
**How you get in *today* — pick one:**
| Method | How | Notes |
|---|---|---|
| **Staging OTP** (best) | Ops sets `CX_STAGING_OTP=1234` on a **non-production** deployment | A fixed code that always verifies. Refused outright when `ENV=production` — `internal/sms/sms.go:105`. **Not yet set on the cluster.** |
| **Read the log** | Ask backend/ops for the code out of the pod log | Works right now, no deploy needed. Tedious. |
| **Paste a token** | `--dart-define=DM_DEV_TOKEN=eyJ...` | A **real** server-issued token. Everything after it is genuinely authorised. Expires in 1 hour unless you also pass `DM_DEV_REFRESH_TOKEN`. |
### 0.2 `DM_MOCK=true` bookings never reach the server. At all.
This is the root cause of *"I created a booking in the app and it doesn't show in the admin console."*
`DevDoormileApi.createBooking()` (`lib/data/dev_doormile_api.dart:440`) builds a `Booking` object in
memory and pushes it onto a local list. **It never opens a socket.** Nothing is POSTed, nothing is
stored, nothing exists.
Because sign-in was impossible (§0.1), offline mode became the only practical way into the app — and
every booking made that way was fiction. The database confirms it: the newest `Customer_App` booking
is **id 565, dated 8 Sep 2026**. The count has not moved since.
**Rule:** a booking is only real if `AppConfig.useDevData == false`. If the Account screen says
`DEV DATA (offline)`, nothing you do on that screen reaches Doormile.
Use `DM_LOGIN_AS` + `DM_LOGIN_CODE` instead when you want to skip the login *screen* without faking
the login — it runs the real `otp/request` + `otp/verify` pair and the token it gets back is the
server's.
---
## 1. Connection basics
### 1.1 Base URL
| Environment | URL |
|---|---|
| Production | `https://api.doormile.com/api/v1` |
| Staging | *not yet provisioned* — staging currently points at production |
| Local | `http://10.0.2.2:8080/api/v1` (Android emulator to host) |
Every customer path is then prefixed `/customer`, so a full URL looks like:
```
https://api.doormile.com/api/v1/customer/bookings
```
### 1.2 Headers
| Header | When | Value |
|---|---|---|
| `Authorization` | every authenticated call | `Bearer <accessToken>` |
| `Content-Type` | every POST/PUT/PATCH | `application/json` |
| `Idempotency-Key` | `POST /bookings`, `POST /auth/otp/verify` | any stable unique string per logical action (mint a UUID when the user taps the button) |
| `X-Client` | optional | `doormile-cx/1.0.0+1` |
| `X-Platform` | optional | `android` / `ios` |
> **Flutter Web only:** the backend's CORS `AllowHeaders` is
> `Origin,Content-Type,Accept,Authorization,Idempotency-Key` (`main.go:169`).
> `X-Client` and `X-Platform` are **not** in it, so a browser preflight will reject them.
> On a native build CORS does not apply and they are fine. If you target web, either drop those
> two headers or ask backend to add them.
### 1.3 Timeouts
| Call | Budget |
|---|---|
| Reads | 15s |
| `POST /bookings` | 30s — it writes across several tables; better to wait than orphan a booking the server did create |
---
## 2. The response envelope
Customer endpoints use their **own** envelope (`utils/response_cx.go`), deliberately different from
the miler/console one. Do not copy parsing code from another Doormile client.
### 2.1 Success
```json
{ "success": true, "data": { }, "message": "" }
```
`message` is **always present** on success — empty string, never omitted.
### 2.2 List
```json
{
"success": true,
"data": [],
"total": 48,
"nextCursor": "540",
"message": ""
}
```
- `data` is **always an array**, never `null`. Type it as a list.
- `nextCursor` is `null` on the last page.
- `total` is the size of the *filtered* set — the count matches the tab you asked for.
### 2.3 Error
```json
{
"success": false,
"message": "That code has expired. Request a new one.",
"error": { "code": "invalid_otp" }
}
```
The machine-readable code is **nested under `error.code`**, not at the top level. `message` is
customer-safe English — render it verbatim in your error state.
### 2.4 Error codes
| Code | HTTP | Meaning | What the app should do |
|---|---|---|---|
| `invalid` | 400 | Malformed or rejected request | Show `message`, let them fix it |
| `invalid_name` | 400 | Name failed validation at signup | Focus the name field |
| `invalid_otp` | 401 | Wrong or expired code | Clear the field, offer resend |
| `unauthorized` | 401 | Missing or expired access token | Refresh once, then sign out |
| `forbidden` | 403 | Not your resource | Go back |
| `not_found` | 404 | No such booking or order | Go back, refresh the list |
| `conflict` | 409 | Already done, or in progress | Refresh and re-read state |
| `unserviceable` | 400 | Outside operating cities | Show the serviceability message |
| `rate_limited` | 429 | Throttle hit | Back off, show a countdown |
| `server_error` | 500 | We broke | Generic retry state |
---
## 3. Authentication
No passwords anywhere. A 4-digit code to a phone **or** an email address.
### 3.1 The flow
```
+- new user ---> POST /customer/auth/signup {name, phone, email}
| |
user -+ v
+- returning --> POST /customer/auth/otp/request {identifier}
|
v (code delivered - see 0.1)
POST /customer/auth/otp/verify {identifier, code}
+ Idempotency-Key
|
v
{ accessToken, refreshToken, expiresIn, customer }
|
+------------------+------------------+
v v
use for 1 hour POST /customer/auth/refresh
{refreshToken} -> new pair
```
### 3.2 Session response
```json
{
"success": true,
"message": "",
"data": {
"accessToken": "eyJhbGciOi...",
"refreshToken": "9f2c... (64 hex chars)",
"expiresIn": 3600,
"customer": { "id": 1, "name": "", "phone": "", "email": "" }
}
}
```
### 3.3 Lifetimes and limits — code against these exactly
| Thing | Value | Source |
|---|---|---|
| Access token TTL | **1 hour** | `cxAccessTTL` |
| Refresh token TTL | **60 days** | `cxRefreshTTL` |
| OTP TTL | **5 minutes** | `cxOtpTTL` |
| OTP length | **4 digits** | `cxOtpLength` |
| Resend cooldown | **30 seconds** | `cxResendWait` |
| Verify attempts per code | **3**, then the code dies | `cxOtpMaxVerify` |
| Codes per identifier per hour | **5** | `cxOtpMaxRequests` |
| Credential endpoint throttle | **10/min shared** across `otp/request`, `signup`, `otp/verify`, `refresh` | `authLimiter()` |
That last one matters: the budget is **shared**. An aggressive resend loop will 429 the verify call
too. Put a real 30-second countdown on the resend button.
### 3.4 Token handling
- Persist the refresh token. 60 days means a returning customer should never see the login screen.
- Refresh **once** on a 401, then give up and sign out. Do not loop — the throttle is shared.
- The refresh token is stored **hashed** server-side. If you lose it, it cannot be recovered; the
customer signs in again.
### 3.5 `POST /auth/otp/verify` needs an `Idempotency-Key`
The client retries over flaky networks, and a replayed verify must return the **original** session
rather than mint a second one. Send a key.
> **Fixed this week:** the idempotency middleware used to cache any status below 500 for 24 hours,
> including the **401** from a mistyped code. One typo locked a customer out for a day — confirmed
> live, the retry came back carrying `Idempotent-Replay: true`. Only 2xx is cached now
> (`middlewares/idempotency.go:104`). A wrong code now genuinely re-executes.
---
## 4. Endpoint reference — all 28
### 4.1 Public (no token)
| Method | Path | Purpose |
|---|---|---|
| POST | `/customer/auth/otp/request` | Send a code to a phone or email |
| POST | `/customer/auth/signup` | Create an account |
| POST | `/customer/auth/otp/verify` | Exchange code for a session · **Idempotency-Key** |
| POST | `/customer/auth/refresh` | Exchange refresh token for a new pair |
| GET | `/customer/serviceability/states` | State picker |
| GET | `/customer/serviceability/states/:stateCode/districts` | District picker |
| GET | `/customer/pickup-slots` | Bookable time slots |
| GET | `/customer/config/booking-limits?lat=&lng=` | `maxPackages`, `maxDestinations` |
> The booking form is explorable **before** sign-in by design. Do not put a login wall on the first
> screen.
### 4.2 Authenticated (`Bearer` + role 9)
**Session and profile**
| Method | Path | Purpose |
|---|---|---|
| GET | `/customer/auth/me` | Who am I |
| POST | `/customer/auth/logout` | Revoke this session |
| GET · PUT | `/customer/profile` | Read / update profile |
**Saved addresses**
| Method | Path |
|---|---|
| GET · POST | `/customer/locations` |
| PUT · DELETE | `/customer/locations/:id` |
**Push**
| Method | Path | Purpose |
|---|---|---|
| POST | `/customer/devices` | Register an FCM token |
| DELETE | `/customer/devices/:token` | Unregister |
> One row per device token, not one column per customer — a phone and a tablet must both receive the
> delivery notification. Re-register on every token rotation.
**Places** — proxied, never keyed
| Method | Path |
|---|---|
| GET | `/customer/places/reverse-geocode?lat=&lng=` |
| GET | `/customer/places/search?q=` |
> The legacy rider app shipped a Google Maps key inside the binary and it had to be revoked. **You
> are never handed a key.** Ask the backend, the backend asks the geocoder.
**Pricing**
| Method | Path |
|---|---|
| POST | `/customer/fare/estimate` |
**Bookings**
| Method | Path | Purpose |
|---|---|---|
| POST | `/customer/bookings` | Create · **Idempotency-Key** · city-gated |
| GET | `/customer/bookings?status=&limit=&cursor=` | List |
| GET | `/customer/bookings/:reference` | Detail / tracking poll |
| POST | `/customer/bookings/:reference/cancel` | Cancel |
| PATCH | `/customer/bookings/:reference/destinations/:index` | Edit one destination |
| GET | `/customer/orders/:trackingId` | One order, for `doormile://track/...` deep links |
**QA only**
| Method | Path |
|---|---|
| POST | `/customer/ops/bookings/:reference/stage` |
> Refused unless `ENV` is non-production **and** `CX_ALLOW_STAGE_OVERRIDE=true` — two independent
> switches, because either one wrong in production would let any customer mark their own parcel
> delivered. It exists so every tracking state is reachable for design QA, which means **your debug
> stepper can be deleted.**
---
## 5. The payloads that matter
### 5.1 `POST /customer/fare/estimate`
Call this on every route and package-count change. It is cheap, cached 60s, and a failed estimate
**must never block a booking**.
```json
{
"pickup": { "lat": 11.0168, "lng": 76.9558 },
"destinations": [
{ "stateCode": "TN", "districtCode": "CBE", "packageCount": 2 }
]
}
```
```json
{
"min": 180,
"max": 240,
"paymentMethod": "UPI · Cash at doorstep",
"parcel": "2 parcels",
"routeKm": 12.4
}
```
### 5.2 `POST /customer/bookings`
```json
{
"pickup": {
"title": "Home",
"sub": "12 Gandhi St, RS Puram",
"lat": 11.0168,
"lng": 76.9558
},
"slotId": "2026-09-16T10:00",
"destinations": [
{
"stateCode": "TN",
"districtCode": "CBE",
"packageCount": 2,
"details": {
"street": "45 Cross Cut Rd",
"building": "Flat 3B",
"landmark": "opp. the bakery",
"recipientName": "Priya",
"recipientPhone": "9876543210",
"instructions": "Ring twice",
"pin": { "lat": 11.0041, "lng": 76.9662 },
"codAmount": 500
}
}
],
"estimate": { "min": 180, "max": 240 },
"remarks": "Handle with care"
}
```
Field notes that will bite you:
| Field | Why it matters |
|---|---|
| `estimate` | What the customer was **shown on Review**. Recorded for dispute audit — when the settled price is questioned months later, the number on the screen is the fact that matters. The server validates it against its own quote and rejects a tampered band. |
| `remarks` | **Top-level, not inside a destination.** Lands in `PickupBooking.Notes`, which the admin Orders table displays and searches. Put it in the wrong place and every booking reaches the console with an empty note. |
| `codAmount` | Money collected at that door **on the customer's behalf**. Doormile is the carrier, not the seller. |
| `details.pin` | Overrides the district centroid with an exact drop pin. Send it whenever you have one. |
Caps: `maxDestinations` and `maxPackages` from `/config/booking-limits` (hard ceiling 25,
`cxAbsoluteMaxDestinations`). Re-fetch the limits when the pickup point moves — they vary by city.
**City gate:** pickup must be in an operating city, matched on the 3-digit pincode prefix —
`641` Coimbatore, `600` Chennai, `560` Bengaluru, `500` Hyderabad, `629` Nagercoil. Anything else is
refused. The backend resolves your `lat`/`lng` to a pincode itself, so you do not send one.
### 5.3 `GET /customer/bookings`
| Param | Values | Default |
|---|---|---|
| `status` | `active` · `completed` · `cancelled` | all |
| `limit` | 1–**50** | 20 |
| `cursor` | the `nextCursor` from the previous page | — |
**Keyset pagination, not offset.** Offsets drift when a new booking lands mid-scroll and show the
same row twice. Pass the cursor back verbatim; stop when `nextCursor` is `null`.
---
## 6. The booking object
One shape. The list row and the detail read are **identical** — build one parser.
```json
{
"reference": "DM2609160042",
"stage": "on_the_way",
"status": "active",
"cancellable": true,
"createdAt": 1789564800000,
"pickup": { "title": "Home", "sub": "12 Gandhi St", "lat": 11.0168, "lng": 76.9558 },
"slotId": "2026-09-16T10:00",
"destinations": [
{
"stateCode": "TN", "stateName": "Tamil Nadu",
"districtCode": "CBE", "districtName": "Coimbatore",
"packageCount": 2,
"district": { "code": "CBE", "name": "Coimbatore", "available": true,
"hub": "CBE Central", "promise": "Same day" },
"details": { "street": "45 Cross Cut Rd", "recipientName": "Priya", "codAmount": 500 },
"trackingId": null,
"stage": null,
"verification": null
}
],
"miler": { "name": "Ravi", "vehicle": "TN 37 AB 1234", "phone": "9000000000",
"rating": 4.8, "trips": 412, "vehicleType": "bike" },
"deliveryAgent": null,
"milerDistanceKm": 2.4,
"milerEtaMinutes": 9,
"milersInZone": 0,
"routeKm": 12.4,
"expectedDelivery": 1789600000000,
"fare": { "min": 180, "max": 240,
"paymentMethod": "UPI · Cash at doorstep", "parcel": "2 parcels" },
"amountPaid": null,
"deliveredAt": null,
"cancelReason": null,
"history": [ { "stage": "booked", "at": 1789564800000, "actor": "customer" } ]
}
```
### 6.1 Contract guarantees — your type declarations depend on these
- `pickup` and `slotId` are present on **every** booking, cancelled ones included.
- `destinations[].stateName` and `districtName` are **always populated**. Render
`"Coimbatore, Tamil Nadu"` from them; never look a code up client-side.
- All timestamps are **epoch milliseconds**, integers.
### 6.2 Nullability — when each field appears
| Field | Null until |
|---|---|
| `miler` | a rider is assigned (stage >= `assigned`) |
| `milerDistanceKm` / `milerEtaMinutes` | **only** at `on_the_way` and `arrived` — after pickup the number would describe a journey that already ended |
| `milersInZone` | `0` except on a **single-booking read** at stage `booked` (it is a Redis GEOSEARCH; running it across a 20-row list would put 20 of them behind one page load) |
| `destinations[].trackingId` | `order_created` |
| `destinations[].stage` | `order_created` |
| `destinations[].verification` | `picked_up` — the weight and photos are what the rider recorded at the door |
| `amountPaid` | `picked_up` |
| `deliveryAgent` | an order is out for delivery |
| `deliveredAt` | every destination is delivered |
| `cancelReason` | cancelled |
Parcel photo URLs are **signed and expire in 30 minutes**. Long enough to open the receipt; short
enough that a forwarded link is dead. Do not cache them — re-fetch the booking.
---
## 7. The stage machine
### 7.1 Nine stages
The wire format is **lowercase snake_case, verbatim**. Your client rolls these into its seven
milestones and falls back to `booked` on an unknown key — **silently**. A new stage added without an
app release therefore makes a parcel look un-started. Never rename; coordinate.
| # | `stage` | Means | Set by |
|---|---|---|---|
| 0 | `booked` | Pickup requested, nobody assigned | booking create |
| 1 | `assigned` | A rider was allotted the job | assignment |
| 2 | `on_the_way` | Rider heading over — distance/ETA live | rider taps Accept |
| 3 | `arrived` | Rider at the door — **last cancellable stage** | rider taps Reached |
| 4 | `picked_up` | Weighed, photographed, price settled | pickup-complete |
| 5 | `order_created` | One tracking number minted per destination | pickup-complete |
| 6 | `in_transit` | In the Doormile network — **per order** | hub inward / tripsheet |
| 7 | `out_for_delivery` | Delivery agent carrying it | start-delivery |
| 8 | `delivered` | Handed over | deliver |
### 7.2 `status` — three values
| Value | When |
|---|---|
| `active` | stages 0–7 |
| `completed` | stage reaches `delivered` |
| `cancelled` | customer, ops, or rider stand-down. **Terminal** — late rider telemetry cannot resurrect it |
### 7.3 Which leg is which
```
customer's door --(1)--> HUB --(2)--> recipient's door
| |
order_created out_for_delivery
+- in_transit -+
```
`in_transit` is the **middle** leg — the only stage where no rider is holding the parcel. The
first-mile ride (customer to hub) is still `order_created`; the last mile is `out_for_delivery`.
### 7.4 Four behaviours that will look like bugs and are not
**A hyperlocal booking never shows `in_transit`.** Same postal area means no hub leg — the same rider
carries it door to door. The stage jumps `order_created` to `out_for_delivery`. Your milestone UI
must tolerate a skipped rung.
**Stages 6–8 take the *slowest* destination.** Three parcels, one still at a hub, and the booking
stays `in_transit`. Deliberate: showing "Delivered" while a parcel is in Kerala is worse than being
pessimistic. Per-destination progress is on `destinations[].stage` — use that for the per-parcel rows.
**`booked` can be reached *backwards*.** If a rider cancels or skips, the pickup is not cancelled —
it returns to the pool, `stage` walks back to `booked`, `miler` goes null. Your tracking screen must
handle a rider card disappearing. History entries for `assigned` and `on_the_way` stay, because those
things did happen.
**`in_transit` currently fires before the parcel reaches the hub.** With `MILER_HUB_HANDOVER_ENABLED`
off (the default, and it is unset everywhere), pickup-complete stamps `Inwarded_at_Hub` immediately.
The customer sees "In transit" while the rider is still standing at their door. Known — the backend
comment says so in as many words. Do not build UI that assumes the parcel is physically at a hub.
### 7.5 Cancellation window
`cancellable` is on the booking — **use it, do not compute it.** It closes after `arrived`
(rank <= 3). The server re-checks on the cancel call regardless; the flag is a hint for hiding the
button, never the authority. Expect a `409` if the stage moved between render and tap, and handle it
by refreshing rather than erroring.
### 7.6 `history`
Append-only, one entry per stage **actually reached**, with the real time and the real actor
(`customer` · `miler` · `ops` · `system`). **Nothing is backfilled.** A booking that predates this
surface has a short history — a short honest history beats a long invented one, because the customer
cannot tell which entries were guessed. Render what you get; do not pad it.
A cancellation rides the event log with `remarks` rather than as a stage, because `cancelled` is not
one of the nine. Read `status` and `cancelReason` for the display.
---
## 8. Rules the app must implement
1. **Idempotency keys on `POST /bookings` and `POST /auth/otp/verify`.** Mint a UUID when the user
taps, reuse it across retries, discard it on success. A duplicate pickup is unacceptable.
2. **Keyset pagination.** Pass `nextCursor` back; never construct offsets.
3. **Refresh once on 401, then sign out.** The throttle is shared across all credential endpoints.
4. **Fetch `/config/booking-limits` when the pickup point moves.** Caps vary by city and are
deliberately not hardcoded anywhere in the UI.
5. **A failed fare estimate must not block the booking.** Let them proceed on the last good quote.
6. **Never cache signed photo URLs.** 30-minute expiry.
7. **Render `message` verbatim** on any failure. It is written to be customer-safe.
8. **Poll the detail endpoint while tracking is open.** It is the canonical read and is built for it
— the whole bundle loads in batch specifically so a poll is not six queries.
---
## 9. Backend quirks worth knowing
| Quirk | Impact on you |
|---|---|
| `Arrived_At_Pickup` is a declared status that is **never written** | Arrival is stored as a fact (`arrivedat` plus GPS), not a status. You get it via `stage: "arrived"`. Do not look for the status string. |
| `Picked_Up` is **transient** | Written and overwritten to `Converted_To_Consignment` inside the same transaction. You will effectively never observe it. |
| Two response envelopes exist in this backend | `/customer/*` uses `CxOK`/`CxFail` (nested `error.code`). `/miler/*` and `/admin/*` use `OK`/`Fail` (top-level code). Never copy parsing code across. |
| `/miler/verify-pin` returns its payload **outside** the envelope | A known inconsistency that cost the miler client a release. It is not repeated on your surface — every `/customer/*` response, auth included, puts its payload in `data`. |
| `ENV` is currently **not** `production` on `api.doormile.com` | Confirmed via a CORS probe: an `OPTIONS` from an un-allowlisted origin was echoed back. This means `CX_STAGING_OTP` *would* work there — and also that the production safety guard is inert. Flag for ops. |
---
## 10. Build flags
```bash
# Real backend, real login - what a release build does
flutter run --dart-define=DM_ENV=prod
```
```bash
# Skip the login SCREEN, keep the login REAL <-- use this for day-to-day dev
flutter run --dart-define=DM_LOGIN_AS=9876543210 --dart-define=DM_LOGIN_CODE=1234
```
```bash
# Paste a real token you already hold
flutter run --dart-define=DM_DEV_TOKEN=eyJ... --dart-define=DM_DEV_REFRESH_TOKEN=abc...
```
```bash
# Offline fake - UI work only. NOTHING reaches a server.
flutter run --dart-define=DM_MOCK=true
```
| Flag | Default | Effect |
|---|---|---|
| `DM_ENV` | `staging` | `prod` · `staging` · `dev` |
| `DM_API_BASE` | — | Overrides the per-environment base URL |
| `DM_MOCK` | `false` | Offline fake API. **Never reaches a server.** |
| `DM_DEV_LOGIN` | `true` | With `DM_MOCK`, opens straight on Home |
| `DM_LOGIN_AS` / `DM_LOGIN_CODE` | — | Real auto sign-in against the real API |
| `DM_DEV_TOKEN` | — | A real server-issued access token |
| `DM_ALLOW_STAGE_OVERRIDE` | `false` | Offers the QA stage stepper (server must also allow it) |
Every one of these is guarded by `!kReleaseMode`. **No define can put any of them in a release
build**, and each is named on the Account screen whenever it is on.
Backend-side flags that change what you observe:
| Var | Default | Effect on the app |
|---|---|---|
| `CX_STAGING_OTP` | unset | A fixed code that always verifies. Ignored when `ENV=production`. |
| `CX_ALLOW_STAGE_OVERRIDE` | unset | Enables `POST /ops/bookings/:ref/stage` |
| `SMS_GATEWAY_URL` | unset | Unset means codes go to the log and no text is sent |
| `MILER_HUB_HANDOVER_ENABLED` | unset | Off means `in_transit` fires at pickup, not at the hub |
| `MILER_COLLECTED_STATE_ENABLED` | unset | Off means hyperlocal goes straight to `out_for_delivery` |
---
## 11. Pre-release checklist
- [ ] `DM_MOCK` is off and the Account screen shows a real base URL, not `DEV DATA (offline)`
- [ ] A booking made on the build appears in the admin console within 15 seconds
- [ ] Idempotency keys sent on `POST /bookings` and `POST /auth/otp/verify`
- [ ] Resend button has a real 30-second countdown
- [ ] A 401 triggers exactly **one** refresh, then sign-out
- [ ] Pagination uses `nextCursor`, never an offset
- [ ] Tracking UI survives a skipped `in_transit` (hyperlocal)
- [ ] Tracking UI survives the rider card disappearing (release back to `booked`)
- [ ] An unknown `stage` value does not crash — falls back to `booked` and logs
- [ ] Cancel button driven by `cancellable`, and a `409` refreshes rather than errors
- [ ] Photo URLs re-fetched, not cached
- [ ] Debug stage stepper removed (the server's QA endpoint replaces it)
- [ ] `X-Client` / `X-Platform` dropped if you ship a web target
---
## 12. Where the source is
| Topic | File |
|---|---|
| Routes and middleware wiring | `routes/routes.go:105-171` |
| Response envelope | `utils/response_cx.go` |
| Auth, OTP, sessions | `controllers/cxAuthController.go` |
| Booking create / list / detail / cancel | `controllers/cxBookingController.go` |
| **The booking JSON shape** | `controllers/cxBookingView.go` |
| Stage machine and timeline | `internal/cxstage/stage.go` |
| Stage and status constants | `constants/constants.go:190-230` |
| Consignment to stage mapping | `controllers/cxConsignmentHooks.go` |
| Fare | `controllers/cxFareController.go` |
| Serviceability, slots, limits | `controllers/cxCatalogueController.go` |
| City gate | `middlewares/city_gate.go` |
| Idempotency | `middlewares/idempotency.go` |
| SMS gateway | `internal/sms/` |
Full contract and design rationale: `docs/customer-app-api.md`
Terse endpoint list: `docs/customer-app-api-crisp.md`
Machine-readable: `docs/openapi-customer.yaml`

View File

@@ -140,7 +140,12 @@ func collectEligibleCandidates(nearby []redis.GeoLocation) ([]*milerCandidate, [
continue
}
if profile.Availabilitystatus != constants.MilerAvailable {
// Carrying an order is not a reason to be skipped — maxActive below
// is what decides how much one miler can hold. Testing for Available
// here made that cap unreachable: a rider was eligible only while
// idle, so activeCount was always 0 and the multi-stop round the cap
// was written for could never be built.
if !constants.MilerCanTakeWork(profile.Availabilitystatus) {
continue
}
@@ -152,7 +157,7 @@ func collectEligibleCandidates(nearby []redis.GeoLocation) ([]*milerCandidate, [
}).
Count(&activeCount)
if activeCount >= maxActive {
if activeCount >= maxActiveBookings() {
continue
}

View File

@@ -4,6 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"time"
"doormile/constants"
@@ -21,9 +24,28 @@ const (
retryDelay = 2 * time.Minute
geoRadiusKm = 10.0
geoMaxCount = 10
maxActive = 3
// defaultMaxActive is how many open stops one miler may hold at once.
// Override with MILER_MAX_ACTIVE_BOOKINGS — how many parcels a rider can
// realistically run in one round is an operational call, not a constant,
// and it differs between a dense city round and an intercity leg.
defaultMaxActive = 3
)
// maxActiveBookings reads the per-miler concurrent-stop cap, read per call so
// it can be changed without a redeploy. A non-numeric or non-positive value
// falls back to the default rather than uncapping the fleet by typo.
func maxActiveBookings() int64 {
if v := strings.TrimSpace(os.Getenv("MILER_MAX_ACTIVE_BOOKINGS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return int64(n)
}
utils.Warn("MILER_MAX_ACTIVE_BOOKINGS is not a positive integer, using the default",
"value", v, "default", defaultMaxActive)
}
return defaultMaxActive
}
type milerCandidate struct {
profile models.MilerProfile
distanceKm float64

View File

@@ -0,0 +1,32 @@
package assignment
import "testing"
// The per-miler concurrent-stop cap.
//
// This is the knob that now decides how much one rider carries, because
// eligibility no longer stops at the first order. It is read per call so it
// can be changed without a redeploy — and a typo must never uncap the fleet.
func TestMaxActiveBookings(t *testing.T) {
cases := []struct {
env string
want int64
why string
}{
{"", defaultMaxActive, "unset falls back to the default"},
{"10", 10, "a plain number is honoured"},
{" 8 ", 8, "surrounding whitespace is tolerated"},
{"1", 1, "one stop at a time is a legitimate policy"},
{"0", defaultMaxActive, "zero would assign to nobody — treated as unset, not as a cap"},
{"-4", defaultMaxActive, "negative is meaningless here"},
{"lots", defaultMaxActive, "a typo must not uncap the fleet"},
{"3.5", defaultMaxActive, "not an integer"},
}
for _, tc := range cases {
t.Setenv("MILER_MAX_ACTIVE_BOOKINGS", tc.env)
if got := maxActiveBookings(); got != tc.want {
t.Errorf("MILER_MAX_ACTIVE_BOOKINGS=%q gave %d, want %d (%s)", tc.env, got, tc.want, tc.why)
}
}
}

180
scratch/fix_miler_hubs.go Normal file
View File

@@ -0,0 +1,180 @@
//go:build ignore
// Give every Available miler the hub it plainly belongs to.
//
// Why this is needed: assignment everywhere filters
// `hubid = ? AND availabilitystatus = 'Available'`, and that intersection was
// empty — all 15 Available milers had hubid NULL, while all 15 milers that
// had a hub were Offline or already busy. Two seed runs populated disjoint
// sets, so no rider was reachable by HubBatchAssign, HubAutoAssign, or the
// auto-assign retry loop.
//
// The hub is not guessed. Each miler is matched to the NEAREST ACTIVE HUB
// WITHIN ITS OWN applocationid — a rider is never handed a hub in another
// city, and within a city the closest one wins. The seeded GPS sits almost
// exactly on a hub in every case, so this reproduces the intended pairing
// rather than inventing one.
//
// Run: go run scratch/fix_miler_hubs.go (plan only, writes nothing)
// go run scratch/fix_miler_hubs.go --apply (writes, in one transaction)
package main
import (
"fmt"
"math"
"os"
"strings"
"doormile/config"
"doormile/db"
"github.com/joho/godotenv"
)
type hub struct {
Hubid int
Hubname string
Applocationid int
Latitude float64
Longitude float64
}
type miler struct {
Userid int
Displayname string
Applocationid int
Currentlatitude float64
Currentlongitude float64
}
func haversineKM(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371
toRad := func(d float64) float64 { return d * math.Pi / 180 }
dLat, dLon := toRad(lat2-lat1), toRad(lon2-lon1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}
func main() {
apply := false
for _, a := range os.Args[1:] {
if a == "--apply" {
apply = true
}
}
_ = godotenv.Load()
db.Connect(config.Load())
if db.DB == nil {
fmt.Println("no database connection")
os.Exit(1)
}
var hubs []hub
db.DB.Raw(`SELECT hubid, COALESCE(hubname,'') AS hubname,
COALESCE(applocationid,0) AS applocationid,
COALESCE(latitude,0) AS latitude, COALESCE(longitude,0) AS longitude
FROM hubs
WHERE deletedat IS NULL AND status = 'Active'
AND latitude IS NOT NULL AND longitude IS NOT NULL
AND latitude <> 0 AND longitude <> 0`).Scan(&hubs)
var milers []miler
db.DB.Raw(`SELECT userid, COALESCE(displayname,'') AS displayname,
COALESCE(applocationid,0) AS applocationid,
COALESCE(currentlatitude,0) AS currentlatitude,
COALESCE(currentlongitude,0) AS currentlongitude
FROM milerprofiles
WHERE availabilitystatus = 'Available' AND hubid IS NULL
ORDER BY applocationid, userid`).Scan(&milers)
fmt.Printf("candidate hubs: %d milers to fix: %d\n\n", len(hubs), len(milers))
type change struct {
userid int
name string
hubid int
hubname string
km float64
}
var changes []change
var skipped []string
for _, m := range milers {
best := -1
bestKM := math.MaxFloat64
for _, h := range hubs {
// Never cross a city boundary, whatever the distance says.
if h.Applocationid != m.Applocationid {
continue
}
d := haversineKM(m.Currentlatitude, m.Currentlongitude, h.Latitude, h.Longitude)
if d < bestKM {
bestKM, best = d, h.Hubid
}
}
if best == -1 {
skipped = append(skipped, fmt.Sprintf("user=%d %s (no active hub at applocationid=%d)",
m.Userid, m.Displayname, m.Applocationid))
continue
}
name := ""
for _, h := range hubs {
if h.Hubid == best {
name = h.Hubname
}
}
changes = append(changes, change{m.Userid, m.Displayname, best, name, bestKM})
}
fmt.Println("PLAN — nearest active hub in the miler's own city:")
for _, c := range changes {
fmt.Printf(" user=%-5d %-28s -> hub %-4d %-30s (%.2f km)\n",
c.userid, c.name, c.hubid, c.hubname, c.km)
}
if len(skipped) > 0 {
fmt.Println("\nSKIPPED (left untouched):")
for _, s := range skipped {
fmt.Println(" " + s)
}
}
// Rollback is trivial and worth printing either way: every row being
// written currently holds NULL, so undoing this is one statement.
ids := make([]string, 0, len(changes))
for _, c := range changes {
ids = append(ids, fmt.Sprint(c.userid))
}
fmt.Printf("\nROLLBACK:\n UPDATE milerprofiles SET hubid = NULL WHERE userid IN (%s);\n",
strings.Join(ids, ","))
if !apply {
fmt.Println("\n(plan only — nothing written. Re-run with --apply to commit.)")
return
}
tx := db.DB.Begin()
for _, c := range changes {
// The hubid IS NULL guard makes this idempotent and means a concurrent
// write cannot be clobbered: if someone set a hub in the meantime,
// theirs stands.
res := tx.Exec(`UPDATE milerprofiles SET hubid = ?, updatedat = NOW()
WHERE userid = ? AND hubid IS NULL`, c.hubid, c.userid)
if res.Error != nil {
tx.Rollback()
fmt.Println("FAILED, rolled back:", res.Error)
os.Exit(1)
}
}
if err := tx.Commit().Error; err != nil {
fmt.Println("commit failed:", err)
os.Exit(1)
}
fmt.Printf("\nAPPLIED: %d milers updated.\n", len(changes))
var eligible int64
db.DB.Raw(`SELECT COUNT(*) FROM milerprofiles
WHERE availabilitystatus = 'Available' AND hubid IS NOT NULL`).Scan(&eligible)
fmt.Println("Available AND hubid set is now:", eligible)
}