diff --git a/constants/constants.go b/constants/constants.go index b85d8f6..bc604a1 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -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 ( diff --git a/constants/miler_work_test.go b/constants/miler_work_test.go new file mode 100644 index 0000000..b755c50 --- /dev/null +++ b/constants/miler_work_test.go @@ -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)) + } +} diff --git a/controllers/hubController.go b/controllers/hubController.go index e4e106d..de3125b 100644 --- a/controllers/hubController.go +++ b/controllers/hubController.go @@ -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), }) } diff --git a/docs/customer-app-integration-handbook.md b/docs/customer-app-integration-handbook.md new file mode 100644 index 0000000..9baffb3 --- /dev/null +++ b/docs/customer-app-integration-handbook.md @@ -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 ` | +| `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` diff --git a/internal/assignment/ai_layer.go b/internal/assignment/ai_layer.go index 2b18200..c054611 100644 --- a/internal/assignment/ai_layer.go +++ b/internal/assignment/ai_layer.go @@ -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 } diff --git a/internal/assignment/crm_assignment.go b/internal/assignment/crm_assignment.go index f099960..8617f78 100644 --- a/internal/assignment/crm_assignment.go +++ b/internal/assignment/crm_assignment.go @@ -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 diff --git a/internal/assignment/maxactive_test.go b/internal/assignment/maxactive_test.go new file mode 100644 index 0000000..8a452f0 --- /dev/null +++ b/internal/assignment/maxactive_test.go @@ -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) + } + } +} diff --git a/scratch/fix_miler_hubs.go b/scratch/fix_miler_hubs.go new file mode 100644 index 0000000..485e0aa --- /dev/null +++ b/scratch/fix_miler_hubs.go @@ -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) +}