Book a pickup, track it to delivery — the rebuilt customer app. - Design language from doormile-screens.html: brand #8F0F06, Manrope + Geist Mono (variable fonts), bordered cards instead of shadows, crimson brand headers, sliding tab indicator, mono for anything read digit by digit. - lib/data (one live API implementation, plus a debug-only offline fake), lib/state, lib/ui (tokens, widgets, screens). - 84 tests, plus a design snapshot harness that renders every screen with the real fonts: flutter test test/design_snapshot_test.dart --run-skipped --update-goldens This replaces the previous app (pubspec 'doormile', app id com.doormile.customer). That tree remains in history at 6c7d656; note its android/app/google-services.json is not carried over, and the application id here is in.doormile.customer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
783 lines
37 KiB
Markdown
783 lines
37 KiB
Markdown
# Doormile — Backend Requirements (Customer App v1)
|
||
|
||
**Audience:** backend team
|
||
**Client:** `doormile_cx` — Flutter customer app, Android/iOS, app id `in.doormile.customer`
|
||
**Client status:** UI complete, `flutter analyze` clean, 20 widget tests green. It runs
|
||
entirely against an in-app mock (`lib/data/doormile_api.dart`) — every value on every
|
||
screen comes from that one file. This document specifies the service that replaces it.
|
||
**Match these shapes and no screen changes are required.**
|
||
|
||
**Read §2 first.** A production Doormile backend already exists and the Miler app is
|
||
live against it. The customer app is a *new namespace on that same service*, not a new
|
||
system, and it must read the records the Miler app writes. Anything that treats the
|
||
customer API as greenfield will produce two databases and a reconciliation problem.
|
||
|
||
---
|
||
|
||
## 1. The product model you are implementing
|
||
|
||
This is not the usual courier model. Read this before the endpoints.
|
||
|
||
> **The customer books a pickup, not a shipment.**
|
||
|
||
```
|
||
Pickup booking (reference: DM-482913)
|
||
├── destination 1 : Chennai, Tamil Nadu · 2 packages
|
||
├── destination 2 : Ernakulam, Kerala · 1 package
|
||
└── destination 3 : Bengaluru Urban, KA · 1 package
|
||
│
|
||
the Miler collects everything in ONE visit
|
||
│
|
||
▼
|
||
Orders are created HERE — one per destination
|
||
order 1: DMX10482913 order 2: DMX10559120 order 3: DMX10662004
|
||
each with its own tracking number and its own journey
|
||
```
|
||
|
||
Consequences you must honour:
|
||
|
||
1. **No tracking number exists at booking time.** `POST /customer/bookings` returns a
|
||
`reference` only. Tracking numbers are minted **per destination** when the Miler
|
||
completes pickup (stage `order_created`).
|
||
2. **One booking → 1..N destinations → 1..N orders.** A single-destination booking is
|
||
the common case and must not be a special case in the schema.
|
||
3. **Weight is never collected from the customer.** The Miler weighs and photographs
|
||
each package at the door — that is also when the **price settles**. Everything the
|
||
customer sees before then is an estimate range.
|
||
4. **Only state + district are required per destination.** Street, building, landmark,
|
||
recipient name/phone and instructions are optional at booking time and may be
|
||
completed by the Miler at pickup. The UI shows missing ones as *"Not added — the
|
||
Miler can confirm this at pickup."*
|
||
5. **Doormile is the carrier, not the seller.** The goods belong to the customer or to
|
||
the customer's own buyer. Any money at the door is the customer's, collected on
|
||
their behalf. Never write copy or schema that treats Doormile as the merchant.
|
||
6. **Cancellation is whole-pickup only**, and only up to and including `arrived`.
|
||
|
||
### Glossary
|
||
|
||
| Term | Meaning |
|
||
| --- | --- |
|
||
| **Miler** | The agent who collects from the customer's door. Also used for the delivery agent. |
|
||
| **Pickup booking** | What the customer creates. Identified by `reference`, format `DM-######`. |
|
||
| **Destination (group)** | One place inside a booking + how many packages go there. |
|
||
| **Order** | Created per destination at pickup completion. Identified by `trackingId`, format `DMX########`. |
|
||
| **Consignment** | The existing backend's name for what the customer calls an order (delivery leg). |
|
||
| **Stage** | Operational status, 9 values (§8). |
|
||
| **Milestone** | The rolled-up status the customer sees, 7 values (§8). |
|
||
|
||
---
|
||
|
||
## 2. Where this fits: the backend that already exists
|
||
|
||
The Miler (rider) app runs against **`https://api.doormile.com/api/v1`** with a bearer
|
||
JWT, and uses ~40 endpoints, all under `/miler/*`:
|
||
|
||
```
|
||
/miler/login /miler/verify-pin /miler/profile
|
||
/miler/duty/current /miler/duty/start /miler/duty/end
|
||
/miler/assignments /miler/assignments/{id}/accept | /reject
|
||
/miler/bookings /miler/bookings/status
|
||
/miler/bookings/{id}/reached /miler/bookings/{id}/parcel
|
||
/miler/bookings/{id}/pickup-complete /miler/bookings/{id}/payment
|
||
/miler/bookings/{id}/addresses /miler/bookings/{id}/cancel | /skip
|
||
/miler/consignments/{id} /miler/consignments/{id}/start-delivery
|
||
/miler/consignments/{id}/deliver /miler/consignments/{id}/inward-at-hub
|
||
/miler/location /miler/notifications /miler/device-token /miler/support …
|
||
```
|
||
|
||
**Therefore:**
|
||
|
||
- The customer API is a **sibling namespace, `/customer/*`, on the same host and the
|
||
same `booking` / `consignment` records.** A customer booking must become the same
|
||
row the Miler app later reads from `/miler/bookings`; a customer "order" must be the
|
||
same row as a `consignment`.
|
||
- **§9 ("what ops must be able to write") is mostly already built.** `reached`,
|
||
`parcel`, `pickup-complete`, `start-delivery`, `deliver`, `inward-at-hub` already
|
||
exist. What is missing is not the writes — it is the **derivation of the customer's
|
||
9 stages from those writes** (§8.3) and the read endpoints that expose them.
|
||
- Conventions below are **the real ones**, taken from the live service, not invented.
|
||
|
||
### 2.1 Corrections to the previous draft of this document
|
||
|
||
The earlier version of this file was written before the live backend was inspected. If
|
||
you were given that version, these four things were wrong:
|
||
|
||
| Was written | Reality |
|
||
| --- | --- |
|
||
| Base URL `https://api.doormile.in/v1` | `https://api.doormile.com/api/v1` |
|
||
| Envelope `{ "data": … }` | `{ "success": bool, "data": …, "message": string }`; lists add `total` |
|
||
| A greenfield ops/"Miler app" section | Those endpoints exist; only the customer projection is missing |
|
||
| Timestamps assumed epoch millis end-to-end | The live service sends **naive IST wall-clock strings**, sometimes with a spurious trailing `Z` (§3.3) |
|
||
|
||
---
|
||
|
||
## 3. Conventions
|
||
|
||
| Item | Requirement |
|
||
| --- | --- |
|
||
| Base URL | `https://api.doormile.com/api/v1` + a staging host with the same contract |
|
||
| Namespace | **`/customer/*`** for everything in this document |
|
||
| Transport | HTTPS only, TLS 1.2+ |
|
||
| Format | JSON, UTF-8 |
|
||
| Success envelope | `{ "success": true, "data": <payload>, "message": "" }` |
|
||
| List envelope | `{ "success": true, "data": [...], "total": <int>, "nextCursor": <string|null> }` |
|
||
| Error envelope | `{ "success": false, "message": "<customer-safe English>", "error": { "code": "…" } }` |
|
||
| Auth | `Authorization: Bearer <accessToken>` on everything except §4.1–§4.3 |
|
||
| Money | Integer **rupees**, not paise. `min: 49` renders as `₹49` |
|
||
| Client headers | `X-Client: doormile-cx/<version>+<build>`, `X-Platform: android\|ios` — accept and log |
|
||
| Request id | Echo `X-Request-Id` on every response, including errors |
|
||
| Display strings | Any human string (`day`, `window`, `expectedDelivery`) is formatted **server-side in IST** |
|
||
|
||
### 3.1 Envelope consistency — one thing not to copy
|
||
|
||
`/miler/verify-pin` returns its payload **outside** `data`, as
|
||
`{success, token, user:{…, profile:{…}}}`. That inconsistency cost the Miler client a
|
||
release to discover. **Do not repeat it here.** Every `/customer/*` response — auth
|
||
included — puts its payload in `data`.
|
||
|
||
### 3.2 Field naming
|
||
|
||
The `/miler/*` surface uses lowercase run-together keys (`bookingid`, `createdat`,
|
||
`slotstarttime`). The customer client models are **camelCase** (`districtCode`,
|
||
`packageCount`, `trackingId`).
|
||
|
||
**Preferred: serve `/customer/*` in camelCase** exactly as specified below. If your ORM
|
||
makes that expensive, say so — the client will add one mapping layer — but decide now
|
||
and freeze it, because a mixed-case response is what forces the client to guess.
|
||
|
||
### 3.3 Timestamps — decide this explicitly
|
||
|
||
The client currently parses `DateTime.fromMillisecondsSinceEpoch(int)`.
|
||
The live service emits naive IST wall-clock strings, occasionally suffixed `Z` for a
|
||
timezone they are not in.
|
||
|
||
- **Preferred:** `/customer/*` sends **epoch milliseconds, UTC, integer** for
|
||
`createdAt`, `history[].at`, `verification.capturedAt`, `deliveredAt`.
|
||
- **If you cannot:** send **ISO-8601 with a real offset** (`2026-09-04T14:30:00+05:30`).
|
||
Tell us and the client adds a tolerant parser.
|
||
- **Never** send a naive local string with a `Z` on it. That is a wrong instant, not a
|
||
formatting nit, and it has already produced "yesterday's work shown as today" bugs on
|
||
the Miler app.
|
||
|
||
### 3.4 Error contract
|
||
|
||
Every failure returns the error envelope. The app funnels all failures into one error
|
||
state with a Retry, and shows `message` **verbatim** — so it must be customer-safe
|
||
English, never an enum key, stack trace or HTML page.
|
||
|
||
| HTTP | `error.code` | When | `message` shown |
|
||
| --- | --- | --- | --- |
|
||
| 400 | `invalid` | Validation failed | "Every destination needs a serviceable state and district" |
|
||
| 400 | `invalid_name` | Name < 2 chars at signup | "Enter your full name" |
|
||
| 401 | `invalid_otp` | Wrong or expired code | "That code did not match" |
|
||
| 401 | `unauthorized` | Missing/expired access token | "Please sign in again" |
|
||
| 403 | `forbidden` | Token valid, resource not the caller's | "You do not have access to this" |
|
||
| 404 | `not_found` | Unknown reference / state / order | Context-specific |
|
||
| 409 | `conflict` | Cancelling after the window; slot filled | "This pickup can no longer be cancelled" |
|
||
| 422 | `unserviceable` | District closed between selection and booking | "That district is no longer available" |
|
||
| 429 | `rate_limited` | Throttled; include `Retry-After` | "Too many attempts. Try again in a minute" |
|
||
| 5xx | `server_error` | Anything else | "Something went wrong" |
|
||
| — | `network` | Client-side only | "We could not reach Doormile" |
|
||
|
||
### 3.5 Non-functional
|
||
|
||
- **Latency:** p95 ≤ 400 ms for every GET in §5 (each sits behind a loading skeleton);
|
||
p95 ≤ 1.2 s for `POST /customer/bookings`.
|
||
- **Idempotency:** `POST /customer/bookings` and `POST /customer/auth/otp/verify` must
|
||
accept `Idempotency-Key` and replay the original response for 24 h. The client
|
||
retries on flaky networks; duplicate pickups are unacceptable.
|
||
- **Caching:** serviceability supports `ETag`/`If-None-Match`. Slots are volatile —
|
||
`Cache-Control: max-age=30` at most.
|
||
- **Rate limits:** OTP request ≤ 5 per number per hour; ≤ 3 verify attempts per code.
|
||
- **Pagination:** `?limit=&cursor=`, default 20, newest first, `nextCursor` in the
|
||
envelope.
|
||
- **PII:** phone, email, recipient name/phone and delivery instructions are PII.
|
||
Encrypt at rest, redact in logs, never leak across customers.
|
||
- **Audit:** every stage transition recorded with actor (miler id / ops user / system),
|
||
timestamp and source. The customer timeline is derived from this, so it must be real.
|
||
- **Tenancy:** the JWT already carries a `tenantid` claim. Customer tokens must carry
|
||
it too, and every read must be tenant-scoped server-side.
|
||
|
||
---
|
||
|
||
## 4. Auth & session
|
||
|
||
4-digit OTP over phone (primary) or email. **No password anywhere in the app.**
|
||
Note this is deliberately *not* the Miler's phone+PIN flow — do not reuse `verify-pin`.
|
||
|
||
Phone is currently sent as `+91 98765 43210` (with spaces). **Normalise to E.164
|
||
server-side and accept both**; the client will be tightened to send E.164.
|
||
|
||
### 4.1 `POST /customer/auth/otp/request`
|
||
|
||
```jsonc
|
||
// request
|
||
{ "identifier": "+919876543210" } // or "you@example.com"
|
||
|
||
// 200
|
||
{ "success": true, "data": { "sent": true, "resendAfterSeconds": 30, "codeLength": 4 } }
|
||
```
|
||
- The resend countdown is server-driven; the client stops hardcoding 30 s once this ships.
|
||
- Code TTL 5 minutes, single use.
|
||
- Errors: `rate_limited`, `invalid`.
|
||
|
||
### 4.2 `POST /customer/auth/signup`
|
||
|
||
```jsonc
|
||
// request
|
||
{ "name": "Joe Oommen", "phone": "+919876543210", "email": "joe@example.com" } // email optional
|
||
|
||
// 200 — creates the account AND sends the OTP
|
||
{ "success": true, "data": { "sent": true, "resendAfterSeconds": 30 } }
|
||
```
|
||
- `name` ≥ 2 chars, else `invalid_name`.
|
||
- If the phone already exists, **do not error** — treat it as a sign-in and send the
|
||
code. The UI has no "account exists" state. Object now if you disagree.
|
||
|
||
### 4.3 `POST /customer/auth/otp/verify`
|
||
|
||
```jsonc
|
||
// request
|
||
{ "identifier": "+919876543210", "code": "4821", "name": "Joe Oommen" } // name only on signup
|
||
|
||
// 200
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"accessToken": "…", "refreshToken": "…", "expiresIn": 3600,
|
||
"customer": { "id": "cust_10241", "name": "Joe Oommen",
|
||
"phone": "+919876543210", "email": "joe@example.com" }
|
||
}
|
||
}
|
||
```
|
||
- `name`, `phone`, `email` are all rendered on Account and drive the avatar initials.
|
||
- **`email` must not be null** — the client types it as `String`. Send `""` if unknown.
|
||
- Errors: `invalid_otp`.
|
||
|
||
### 4.4 `POST /customer/auth/refresh` · `POST /customer/auth/logout` · `GET /customer/auth/me`
|
||
|
||
- Refresh: `{ "refreshToken": "…" }` → new access token; rotation preferred; refresh TTL
|
||
60 days so the customer stays signed in.
|
||
- Logout: revokes the refresh token **and** unregisters the device push token.
|
||
- `GET /customer/auth/me` → the same `customer` object, for cold-start session restore.
|
||
|
||
Session persistence is client work (§Appendix B) but it cannot start until refresh
|
||
exists. Ship §4.3 and §4.4 together.
|
||
|
||
---
|
||
|
||
## 5. Catalogue & configuration
|
||
|
||
These four drive the entire booking form. **Highest priority — nothing else works
|
||
without them.**
|
||
|
||
### 5.1 `GET /customer/serviceability/states`
|
||
|
||
```jsonc
|
||
{ "success": true, "data": [
|
||
{ "code": "TN", "name": "Tamil Nadu", "districtCount": 6, "transitTag": "Ultra-fast transit" },
|
||
{ "code": "KL", "name": "Kerala", "districtCount": 3, "transitTag": "Next-day transit" },
|
||
{ "code": "PY", "name": "Puducherry", "districtCount": 0, "transitTag": "Opening soon" }
|
||
]}
|
||
```
|
||
|
||
| Field | Type | Req | Notes |
|
||
| --- | --- | --- | --- |
|
||
| `code` | string | yes | Stable; stored on the booking |
|
||
| `name` | string | yes | Display name |
|
||
| `districtCount` | int | yes | **Count of `available` districts only.** The client hides any state with `0` |
|
||
| `transitTag` | string | no | ≤ 22 chars |
|
||
|
||
Return `[]` when nothing is serviceable — the app has a designed "no service" state.
|
||
|
||
### 5.2 `GET /customer/serviceability/states/{stateCode}/districts`
|
||
|
||
```jsonc
|
||
{ "success": true, "data": [
|
||
{ "code": "TN-CBE", "name": "Coimbatore", "available": true,
|
||
"hub": "Coimbatore Central Hub", "promise": "Next-day delivery",
|
||
"lat": 11.0168, "lng": 76.9558 },
|
||
{ "code": "TN-MDU", "name": "Madurai", "available": false, "note": "Opening soon",
|
||
"lat": 9.9252, "lng": 78.1198 }
|
||
]}
|
||
```
|
||
|
||
| Field | Type | Req | Notes |
|
||
| --- | --- | --- | --- |
|
||
| `code` / `name` | string | yes | Stable code + display name |
|
||
| `available` | bool | yes | `false` districts are not offered |
|
||
| `note` | string | no | Why not: "Opening soon", "Paused this week" |
|
||
| `hub` | string | no | Serving hub, shown on the destination card |
|
||
| `promise` | string | no | "Next-day delivery" / "2-day delivery" |
|
||
| `lat` / `lng` | number | no | **Where the hub is.** The route map draws pickup → hub from these. Omit them and the map draws the pickup end only — no line, no destination marker |
|
||
|
||
**Return the full list including unavailable districts** — the client filters them out
|
||
of the picker but uses their names for a quiet "Coming soon" line.
|
||
Unknown `stateCode` → `404 not_found`, "That state is no longer serviceable".
|
||
|
||
### 5.3 `GET /customer/pickup-slots?lat=&lng=`
|
||
|
||
```jsonc
|
||
{ "success": true, "data": [
|
||
{ "id": "slot_t_1", "day": "Today", "window": "2:00 – 4:00 PM", "available": true,
|
||
"tag": "Fastest pickup", "milersNearby": 4, "caption": "Arriving in approx. 45 mins" },
|
||
{ "id": "slot_t_3", "day": "Today", "window": "6:00 – 8:00 PM", "available": false,
|
||
"note": "Fully booked" }
|
||
]}
|
||
```
|
||
|
||
| Field | Type | Req | Notes |
|
||
| --- | --- | --- | --- |
|
||
| `id` | string | yes | Opaque; sent back as `slotId` |
|
||
| `day` | string | yes | "Today" / "Tomorrow" / "Mon, 8 Sep" — **server-formatted, IST** |
|
||
| `window` | string | yes | "2:00 – 4:00 PM" (en dash, spaced) |
|
||
| `available` | bool | yes | Capacity remaining in the customer's zone |
|
||
| `note` | string | no | "Fully booked" |
|
||
| `tag` | string | no | At most one slot carries "Fastest pickup" |
|
||
| `milersNearby` | int | no | 0 hides the line |
|
||
| `caption` | string | no | "Arriving in approx. 45 mins" |
|
||
|
||
Slots are **capacity- and location-aware** — use the `lat`/`lng` of the pickup point.
|
||
Roughly today + tomorrow; the design expects ~6 windows.
|
||
The slot ids must resolve to whatever the Miler assignment engine consumes — a customer
|
||
slot that ops cannot staff is worse than no slot.
|
||
|
||
### 5.4 `GET /customer/config/booking-limits`
|
||
|
||
```jsonc
|
||
{ "success": true, "data": { "maxPackages": 20, "maxDestinations": 5 } }
|
||
```
|
||
|
||
Nothing in the UI hardcodes these; they exist so ops can vary them by city or tier
|
||
without an app release. Must degrade safely (client keeps 20/5 on failure). Never `0`.
|
||
If they become per-city, key them off the pickup location and tell us — the client will
|
||
re-fetch when the pickup point moves.
|
||
|
||
---
|
||
|
||
## 6. Location
|
||
|
||
Maps in the customer app are real as of this revision: `flutter_map` over
|
||
OpenStreetMap/CARTO raster tiles, and `geolocator` for the device fix. No key is held
|
||
by the client, which matches the decision already taken on the Miler side. The
|
||
coordinates reaching these endpoints are real GPS coordinates.
|
||
|
||
### 6.1 `GET /customer/places/reverse-geocode?lat=&lng=`
|
||
|
||
```jsonc
|
||
{ "success": true, "data": { "title": "12 Nehru Street",
|
||
"sub": "Gandhipuram, Coimbatore 641012", "lat": 11.0183, "lng": 76.9725 } }
|
||
```
|
||
`title` = short label (≤ 32 chars), `sub` = full address line. **Both required, neither
|
||
nullable.** Used to pre-fill the pickup point from the device location.
|
||
|
||
**This is the hottest endpoint in the booking flow.** The pickup screen puts the pin at
|
||
the centre of the map and moves the map under it; every time the map comes to rest, this
|
||
is called and the answer is what the customer reads in the box below. Target **p95 under
|
||
300 ms** and cache by rounded coordinate (~5 decimal places is more precision than any
|
||
door needs). The client already debounces 320 ms after the map settles and discards a
|
||
response that a later drag has superseded, so you will not get a request per frame — but
|
||
you will get one per correction, and each one is on the critical path to "Next".
|
||
|
||
### 6.2 `GET /customer/places/search?q=&lat=&lng=`
|
||
|
||
```jsonc
|
||
{ "success": true, "data": [ { "title": "Brookefields Mall",
|
||
"sub": "Brookebond Road, Coimbatore 641001", "lat": 10.9987, "lng": 76.9628 } ] }
|
||
```
|
||
- Empty `q` → the customer's recent/saved places (≤ 4). The search sheet opens on this.
|
||
- Bias to `lat`/`lng` and to serviceable areas.
|
||
- **Proxy the geocoder through the backend** — the Miler app's Google Maps key was
|
||
revoked and that side now runs on OSM/OSRM with no key. Do not hand the customer app
|
||
a key to hold; serve results and cache them.
|
||
|
||
---
|
||
|
||
## 7. Fare estimate
|
||
|
||
### `POST /customer/fare/estimate`
|
||
|
||
```jsonc
|
||
// request
|
||
{ "pickup": { "lat": 11.0183, "lng": 76.9725 },
|
||
"destinations": [
|
||
{ "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 2 },
|
||
{ "stateCode": "KL", "districtCode": "KL-EKM", "packageCount": 1 } ] }
|
||
|
||
// 200
|
||
{ "success": true, "data": {
|
||
"min": 167, "max": 267,
|
||
"paymentMethod": "UPI · Cash at doorstep",
|
||
"parcel": "3 boxes (up to 3 kg each)",
|
||
"routeKm": 6.4 } }
|
||
```
|
||
|
||
| Field | Type | Notes |
|
||
| --- | --- | --- |
|
||
| `min` / `max` | int (₹) | Rendered "₹167 – ₹267". An **estimate range**, never final |
|
||
| `paymentMethod` | string | Shown as-is on Review and the receipt |
|
||
| `parcel` | string | What is being priced, e.g. "Standard box (up to 3 kg)" |
|
||
| `routeKm` | number | Pickup→destination distance, drives the route outline and receipt |
|
||
|
||
- Called on every route or package-count change — must be cheap and cacheable.
|
||
- A failed estimate **must not block booking**; the client swallows the error.
|
||
- Must be the **combined** price for one visit, including multi-stop uplift.
|
||
|
||
---
|
||
|
||
## 8. Stages, milestones and how they are derived
|
||
|
||
### 8.1 The nine stages
|
||
|
||
The backend owns nine operational stages; the customer sees seven milestones. The
|
||
rollup happens on the client — **always send the raw stage key**, lowercase snake_case,
|
||
spelled exactly as below. An unknown key silently falls back to `booked` on the client,
|
||
so new keys require a client release.
|
||
|
||
| # | Stage key | Customer milestone | Must also be true |
|
||
| --- | --- | --- | --- |
|
||
| 0 | `booked` | Pickup booked | Set by `POST /customer/bookings` |
|
||
| 1 | `assigned` | Miler assigned | `miler` object present |
|
||
| 2 | `on_the_way` | Pickup in progress | `milerDistanceKm`, `milerEtaMinutes` set |
|
||
| 3 | `arrived` | Pickup in progress | Distance 0, ETA 0. **Last cancellable stage** |
|
||
| 4 | `picked_up` | Package collected | `verification` populated; `amountPaid` settles |
|
||
| 5 | `order_created` | Package collected | **`trackingId` minted per destination**; `expectedDelivery` set |
|
||
| 6 | `in_transit` | In transit | Per-order from here on |
|
||
| 7 | `out_for_delivery` | Out for delivery | `deliveryAgent` present |
|
||
| 8 | `delivered` | Delivered | `deliveredAt` set; booking `status` → `completed` |
|
||
|
||
- **Stages 0–5 belong to the booking; 6–8 belong to each order** and may differ between
|
||
destinations of the same booking. The client already renders independent journeys.
|
||
- The timeline is built from a **history array** — one entry per stage actually reached,
|
||
with the real timestamp. **Do not synthesise or backfill times.** (The mock does; that
|
||
is a prototype affordance, not a spec.)
|
||
- `status` (`active` / `completed` / `cancelled`) is derived but must be sent
|
||
explicitly. Do not make the client infer it.
|
||
|
||
### 8.2 Milestone rollup (client-side, for your reference)
|
||
|
||
```
|
||
booked → Pickup booked
|
||
assigned → Miler assigned
|
||
on_the_way | arrived → Pickup in progress
|
||
picked_up | order_created → Package collected
|
||
in_transit → In transit
|
||
out_for_delivery → Out for delivery
|
||
delivered → Delivered
|
||
```
|
||
|
||
### 8.3 Derivation from the writes that already exist ← **the actual work**
|
||
|
||
| Existing Miler write | Customer stage it must produce |
|
||
| --- | --- |
|
||
| assignment created / `POST /miler/assignments/{id}/accept` | `assigned` (+ `miler` from the accepting rider) |
|
||
| rider goes on-route (duty + location stream) | `on_the_way` (+ distance/ETA from `/miler/location`) |
|
||
| `POST /miler/bookings/{id}/reached` | `arrived` — **cancellation closes here** |
|
||
| `POST /miler/bookings/{id}/parcel` (weight + photos) | populates `verification` |
|
||
| `POST /miler/bookings/{id}/pickup-complete` | `picked_up`, then `order_created`; **mint one consignment + `trackingId` per destination**; settle `amountPaid` |
|
||
| `POST /miler/consignments/{id}/inward-at-hub` | `in_transit` (per order) |
|
||
| `POST /miler/consignments/{id}/start-delivery` | `out_for_delivery` (+ `deliveryAgent`) |
|
||
| `POST /miler/consignments/{id}/deliver` | `delivered`, `deliveredAt`, booking → `completed` |
|
||
| `POST /miler/bookings/{id}/cancel` \| `/skip`, or ops cancel | `cancelled` + `cancelReason` |
|
||
|
||
If any of these does not currently emit an event the customer projection can read, that
|
||
is the gap to close first.
|
||
|
||
### 8.4 Known gaps in the existing backend that block this
|
||
|
||
Found while integrating the Miler app; each one has a customer-visible consequence:
|
||
|
||
| Gap | Customer-app consequence |
|
||
| --- | --- |
|
||
| Booking carries no `assignmentid` / `consignmentid` (the app assumes `== bookingid`) | Multi-destination bookings cannot mint N orders; §1 breaks |
|
||
| No COD / collection amount on the booking object | `amountPaid` and the receipt cannot settle |
|
||
| No per-stop `type` (pickup vs delivery) or `step` ordering | Stages 6–8 cannot be attributed to the right order |
|
||
| No skip/resume and no customer-side cancel endpoint | §9.4 cannot be built |
|
||
| Parcel weight/photos not exposed on any read | `verification` block stays empty; the receipt loses its evidence |
|
||
|
||
---
|
||
|
||
## 9. Bookings
|
||
|
||
### 9.1 `POST /customer/bookings` — create the pickup
|
||
|
||
```jsonc
|
||
// request (Idempotency-Key header required)
|
||
{
|
||
"pickup": { "title": "12 Nehru Street", "sub": "Gandhipuram, Coimbatore 641012",
|
||
"lat": 11.0183, "lng": 76.9725 },
|
||
"slotId": "slot_t_1",
|
||
"destinations": [
|
||
{ "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 2,
|
||
"details": { // every field optional, may be omitted entirely
|
||
"street": "12th Main", "building": "3B", "landmark": "Near bus stand",
|
||
"recipientName": "Meera S", "recipientPhone": "+919884412210",
|
||
"instructions": "Call before delivery",
|
||
"pin": { "lat": 13.0827, "lng": 80.2707 } } },
|
||
{ "stateCode": "KL", "districtCode": "KL-EKM", "packageCount": 1 }
|
||
],
|
||
"estimate": { "min": 167, "max": 267 } // what the customer was shown, for dispute audit
|
||
}
|
||
```
|
||
|
||
Server-side validation — mirror exactly, the client displays your `message`:
|
||
|
||
| Rule | Error |
|
||
| --- | --- |
|
||
| ≥ 1 destination, each with serviceable `stateCode` + `districtCode` | `400 invalid` — "Every destination needs a serviceable state and district" |
|
||
| `slotId` present and still available | `400 invalid` — "Pick a pickup slot" / `409 conflict` — "That pickup window just filled up" |
|
||
| `packageCount` ≥ 1, total ≤ `maxPackages` | `400 invalid` — "Up to 20 packages per pickup" |
|
||
| destinations ≤ `maxDestinations` | `400 invalid` — "Up to 5 destinations per pickup" |
|
||
| district still `available` | `422 unserviceable` |
|
||
|
||
Response: the **full booking object** (§9.3) with `reference` `DM-######`,
|
||
`stage: "booked"`, `status: "active"`, `cancellable: true`, a one-entry `history`, and
|
||
**no `trackingId` on any destination**.
|
||
|
||
### 9.2 `GET /customer/bookings?status=active|completed|cancelled&limit=&cursor=`
|
||
|
||
Backs the Orders tabs, Home's "Recent" (3 most recent non-active) and pull-to-refresh.
|
||
Array of booking objects, newest first.
|
||
|
||
### 9.3 `GET /customer/bookings/{reference}` — the canonical object
|
||
|
||
The single most important response in the API: the tracking screen and the receipt are
|
||
both rendered from it.
|
||
|
||
```jsonc
|
||
{ "success": true, "data": {
|
||
"reference": "DM-482913",
|
||
"stage": "in_transit",
|
||
"status": "active",
|
||
"cancellable": false,
|
||
"createdAt": 1757056800000,
|
||
|
||
"pickup": { "title": "12 Nehru Street", "sub": "Gandhipuram, Coimbatore 641012",
|
||
"lat": 11.0183, "lng": 76.9725 },
|
||
"slotId": "slot_t_1",
|
||
|
||
"destinations": [
|
||
{ "stateCode": "TN", "stateName": "Tamil Nadu",
|
||
"districtCode": "TN-MAA", "districtName": "Chennai",
|
||
"packageCount": 2,
|
||
"district": { "code": "TN-MAA", "name": "Chennai", "available": true,
|
||
"hub": "Chennai Guindy Hub", "promise": "Next-day delivery" },
|
||
"details": { "street": "12th Main", "recipientName": "Meera S",
|
||
"recipientPhone": "+919884412210" },
|
||
"trackingId": "DMX10482913", // null until order_created
|
||
"stage": "in_transit", // null until order_created
|
||
"verification": { // null until picked_up
|
||
"weightKg": 2.8,
|
||
"photos": ["https://cdn.doormile.com/pv/abc.jpg"],
|
||
"capturedAt": 1757060400000,
|
||
"capturedBy": "Arun Kumar" } }
|
||
],
|
||
|
||
"miler": { "name": "Arun Kumar", "vehicle": "TN 37 BX 4412", "phone": "+919000011223",
|
||
"rating": 4.9, "trips": 1240, "vehicleType": "E-Scooter" },
|
||
"deliveryAgent": null, // populated from out_for_delivery
|
||
|
||
"milerDistanceKm": null, // set during on_the_way / arrived
|
||
"milerEtaMinutes": null,
|
||
"milersInZone": 3, // shown while finding a Miler
|
||
"routeKm": 6.4,
|
||
|
||
"expectedDelivery": "Thu, 12 Sep", // server-formatted, IST
|
||
"fare": { "min": 49, "max": 64, "paymentMethod": "UPI · Cash at doorstep",
|
||
"parcel": "Standard box (up to 3 kg)" },
|
||
"amountPaid": 64, // null until picked_up
|
||
"deliveredAt": null,
|
||
"cancelReason": null,
|
||
|
||
"history": [
|
||
{ "stage": "booked", "at": 1757056800000 },
|
||
{ "stage": "assigned", "at": 1757057400000 },
|
||
{ "stage": "on_the_way", "at": 1757058900000 },
|
||
{ "stage": "arrived", "at": 1757059800000 },
|
||
{ "stage": "picked_up", "at": 1757060400000 },
|
||
{ "stage": "order_created", "at": 1757060460000 },
|
||
{ "stage": "in_transit", "at": 1757062200000 }
|
||
]
|
||
}}
|
||
```
|
||
|
||
Field notes that matter:
|
||
|
||
- `destinations[].stateName` / `districtName` are **required** — the client renders
|
||
"Chennai, Tamil Nadu" from them and does not look codes up.
|
||
- `pickup` and `slotId` must be **present on every booking, including cancelled ones** —
|
||
the client parser types them non-nullable and will throw on `null`.
|
||
- `verification.photos` must be **fetchable URLs** (signed, ≥ 15 min TTL), one per
|
||
package. `weightKg` is the number the price settled on; shown as "2.8 kg".
|
||
- `miler.phone` powers a **Call Miler** action. Real dialable number or masked-calling
|
||
proxy — say which. Masked preferred.
|
||
- `amountPaid` is the settled total in ₹, present from `picked_up`. The receipt renders
|
||
`amountPaid − fare.min` as "Weight adjustment", so **keep `fare` on the booking
|
||
forever**, even after settlement.
|
||
- `expectedDelivery` is a display string, formatted server-side in IST.
|
||
- `history` is append-only and ordered; every timestamp on the timeline comes from it.
|
||
|
||
### 9.4 `POST /customer/bookings/{reference}/cancel`
|
||
|
||
```jsonc
|
||
// request
|
||
{ "reason": "Package not ready" } // optional; free text or one of the 5 presets
|
||
|
||
// 200
|
||
{ "success": true, "data": { "reference": "DM-482913", "status": "cancelled",
|
||
"cancelReason": "Package not ready" } }
|
||
```
|
||
|
||
- Preset reasons in the UI: *Booked by mistake · Package not ready · Sending it another
|
||
day · Changed the destination · Other*. `reason` may be `null`.
|
||
- **Allowed only through `arrived`.** From `picked_up` onward return `409 conflict`.
|
||
The server is the authority; `cancellable` on the booking mirrors the same policy so
|
||
the UI can hide the button, but the server must re-check.
|
||
- Cancels the **whole pickup**, every destination. No partial cancellation in v1.
|
||
- The UI promises *"You can cancel free of charge until the Miler collects your
|
||
package."* — **confirm no fee applies in that window.**
|
||
|
||
### 9.5 `PATCH /customer/bookings/{reference}/destinations/{index}`
|
||
|
||
The customer can fill in street / landmark / recipient / instructions / map pin **after**
|
||
booking, up to collection.
|
||
|
||
```jsonc
|
||
{ "street": "12th Main", "landmark": "Near bus stand",
|
||
"recipientName": "Meera S", "recipientPhone": "+919884412210",
|
||
"instructions": "Call before delivery", "pin": { "lat": 13.08, "lng": 80.27 } }
|
||
```
|
||
- Any subset; `null` clears a field.
|
||
- Accept until `picked_up`; after that `409 conflict`.
|
||
- **These edits must reach the Miler app in near real time** — that app reads addresses
|
||
via `/miler/bookings/{id}/addresses`, so the write must land on the same record.
|
||
|
||
### 9.6 `GET /customer/orders/{trackingId}`
|
||
|
||
One order by tracking number, returning the booking object focused on that destination.
|
||
Needed for push deep links. A slimmer order object is acceptable if you prefer — say so;
|
||
the client currently reuses the booking shape.
|
||
|
||
---
|
||
|
||
## 10. Live updates
|
||
|
||
The tracking screen currently advances through a debug stepper. Production needs real
|
||
events. **Required for launch:**
|
||
|
||
1. **Push (FCM + APNs)** — one notification per customer-visible *milestone* change:
|
||
|
||
```jsonc
|
||
{ "type": "stage_change", "reference": "DM-482913",
|
||
"trackingId": "DMX10482913", // null before order_created
|
||
"stage": "out_for_delivery",
|
||
"title": "Out for delivery",
|
||
"body": "Arriving today at the delivery address.",
|
||
"deepLink": "doormile://track/DM-482913" }
|
||
```
|
||
- `POST /customer/devices` registers `{ token, platform, appVersion }`; logout
|
||
unregisters. (The Miler side already has `/miler/device-token` — mirror it.)
|
||
- Do **not** notify on every operational stage: `on_the_way` and `order_created` roll
|
||
up on the timeline. Agree the notification set with product.
|
||
- ⚠️ The client has **no deep-link intent filter yet** (§Appendix B) — send the
|
||
`deepLink` field from day one; the client will start honouring it.
|
||
|
||
2. **Polling fallback:** `GET /customer/bookings/{reference}` is polled while tracking
|
||
is open. Make it cheap; support `If-None-Match` → `304`.
|
||
|
||
3. **Phase 2, optional:** SSE/WebSocket for live Miler position while `on_the_way`.
|
||
The Miler app already streams to `/miler/location`, so the data exists. The customer
|
||
UI shows distance and ETA and will simply not update them without this.
|
||
|
||
---
|
||
|
||
## 11. Deliverables from the backend team
|
||
|
||
1. **OpenAPI 3.1 spec** for §4–§10 plus a Postman collection.
|
||
2. **Staging environment** seeded to mirror the mock, because these exact cases back our
|
||
widget tests and design QA: Tamil Nadu / Kerala / Karnataka open; **Puducherry
|
||
serviceable with no open district**; **Madurai / Kozhikode / Mangaluru unavailable
|
||
with a reason**; at least one fully-booked slot.
|
||
3. **Test accounts + a fixed staging OTP** (e.g. `1234`) so automated tests can sign in.
|
||
4. **A way to force a booking to any stage on staging** (ops endpoint or console button).
|
||
Every tracking state must be reachable for QA — this is what lets us delete the debug
|
||
stepper.
|
||
5. Documented error responses, ideally error injection, so the app's empty / error /
|
||
retry states can be verified against the real service.
|
||
6. **A written answer to §13.**
|
||
|
||
## 12. Suggested delivery order
|
||
|
||
| Phase | Endpoints | Unblocks |
|
||
| --- | --- | --- |
|
||
| **1** | §5.1 §5.2 §5.3 §5.4 | The whole booking form. Highest priority |
|
||
| **2** | §4 auth (request / verify / refresh / me) | Real sign-in + session persistence |
|
||
| **3** | §7 estimate, §9.1 create, §9.2 list, §9.3 detail | End-to-end booking |
|
||
| **4** | §8.3 stage derivation from the existing Miler writes | Real tracking; removes the debug stepper |
|
||
| **5** | §9.4 cancel, §9.5 details patch | Feature-complete v1 |
|
||
| **6** | §10 push, §6 places | Live updates and real pickup-point search |
|
||
|
||
Phase 4 is the one with hidden cost — it depends on §8.4 being closed first.
|
||
|
||
## 13. Open questions — please answer in writing
|
||
|
||
1. **Payment.** The app shows "UPI · Cash at doorstep" and an `amountPaid` but has no
|
||
payment step. Is v1 cash/UPI-at-the-door, settled outside the app? If an in-app
|
||
payment or payment link is coming, we need the contract now. Note the money at the
|
||
door may be the customer's own COD collection — confirm who owns that flow.
|
||
2. **Masked calling** for `miler.phone` — real number or proxy?
|
||
3. **Partial pickup.** What happens if the Miler collects some destinations but not all?
|
||
The model has no partial-pickup state today.
|
||
4. **Failed delivery / reattempt.** Does it exist operationally? There is no customer
|
||
screen for it, and no stage key. If it exists we need both.
|
||
5. **Saved addresses, notification preferences, payment methods** — rows exist on the
|
||
Account screen with no backend. In v1 scope? If yes we need
|
||
`GET/POST/DELETE /customer/addresses` and a preferences endpoint.
|
||
6. **Support.** "Need help with this order?" is a dead end today. Chat, ticket API
|
||
(the Miler side has `/miler/support`), or a phone number?
|
||
7. **Slot capacity semantics.** Is `available` per zone, per Miler count, or a hard cap?
|
||
It decides how often the client re-fetches before confirming.
|
||
8. **Pricing.** Who owns the estimate formula, and does the customer see a breakdown
|
||
when `amountPaid` exceeds `fare.max`?
|
||
9. **Cancellation fee** — confirmed free through `arrived`?
|
||
10. **Retention** for parcel photos and PII.
|
||
11. **Tenancy.** Does the customer app serve both the logistics line and the meal line,
|
||
or logistics only in v1? The Miler app switches its entire mode on `tenantid`.
|
||
|
||
---
|
||
|
||
## Appendix A — Client source of truth
|
||
|
||
| Contract element | File |
|
||
| --- | --- |
|
||
| Every model + `fromJson` | `lib/data/models.dart` |
|
||
| Every endpoint the app calls (currently mocked) | `lib/data/doormile_api.dart` |
|
||
| Stage side-effects the backend must perform | `AppState._applyStage`, `lib/state/app_state.dart` |
|
||
| End-to-end flow the contract must satisfy | `test/booking_flow_test.dart` (18 tests) |
|
||
|
||
To go live, each method body in `DoormileApi` becomes a network call and the return
|
||
types stay identical. No screen changes are needed.
|
||
|
||
## Appendix B — Client-side work to do alongside this
|
||
|
||
Not backend scope, listed so nobody plans around capabilities the app does not yet have:
|
||
|
||
- **`pubspec.yaml` still has no network dependency** — no `http`, no
|
||
`shared_preferences`, no push SDK. Those three sections need packages added first.
|
||
Maps and geolocation are no longer on this list: `flutter_map` (OpenStreetMap/CARTO
|
||
tiles, no key) and `geolocator` are in and working on both maps.
|
||
- `District.lat`/`lng` (§5.2) are parsed and drive the route map. Until the API sends
|
||
them the app falls back to a built-in table of district centroids.
|
||
- `Booking.fromJson` does not yet parse `miler`, `deliveryAgent`, `fare`, `verification`,
|
||
`history`, `amountPaid`, `deliveredAt`, `status`, per-destination `details` /
|
||
`district` / `stage` (§9.3).
|
||
- `Destination` and `DeliveryDetails` have no `fromJson`/`toJson` yet.
|
||
- No deep-link intent filter in `AndroidManifest.xml` and no URL types on iOS, so
|
||
`doormile://track/…` does nothing today (§10).
|
||
- Location permissions **are** declared now (`ACCESS_FINE_LOCATION` /
|
||
`ACCESS_COARSE_LOCATION`, `NSLocationWhenInUseUsageDescription`) and the pickup pin
|
||
comes from the real device fix; only the address behind it is still mocked.
|
||
- Session persistence is unbuilt — it depends on §4.4.
|
||
- The tracking screen's `_StageStepper` is the prototype stand-in for real events;
|
||
deleting it is the only client change needed once §10 lands.
|