updates
This commit is contained in:
692
docs/customer-app-integration-handbook.md
Normal file
692
docs/customer-app-integration-handbook.md
Normal 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`
|
||||
Reference in New Issue
Block a user