Replace the customer app with Doormile CX
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>
This commit is contained in:
187
docs/API_READINESS.md
Normal file
187
docs/API_READINESS.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# API-readiness audit — `doormile_cx`
|
||||
|
||||
Every mocked call still to be replaced, what it must send, what it must return,
|
||||
and which screen breaks if it is wrong.
|
||||
|
||||
**Rules that hold across all of them**
|
||||
|
||||
* All 19 live in `lib/data/doormile_api.dart`. Replace the method body, keep the
|
||||
return type, and no screen changes.
|
||||
* No widget calls `DoormileApi` directly any more — screens go through
|
||||
`AppState`, which holds the injected instance. The UI cannot tell mock from
|
||||
real, which is the property that makes this a body-swap rather than a rewrite.
|
||||
* Every failure must throw `ApiException(code, message)` where `message` is
|
||||
customer-safe: `DmAsyncList` renders it verbatim with a Retry.
|
||||
* `LocationService` (device GPS) and `DmMapConfig` (tile provider) are **not**
|
||||
on this list — they are platform and build configuration, not backend.
|
||||
|
||||
Status legend — **Blocking**: the flow does not work without it ·
|
||||
**Wired**: seam exists and is called, mock returns a no-op ·
|
||||
**Delete**: prototype-only, remove at go-live.
|
||||
|
||||
---
|
||||
|
||||
## Auth
|
||||
|
||||
| # | Method | Sends | Must return | Consumed by | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | `sendOtp(identifier)` | `identifier` — E.164 phone (`+919876543210`) or email | nothing; **please add** `resendAfterSeconds`, `codeLength` so the 30 s timer is server-driven | `login_screen`, `otp_screen` (Resend) | Blocking |
|
||||
| 2 | `signUp({name, phone, email?})` | `name` ≥ 2 chars, `phone` E.164, `email` optional | nothing — creates the account *and* sends the code | `signup_screen` | Blocking |
|
||||
| 3 | `verifyOtp(identifier, code, {name})` | `identifier`, 4-digit `code`, `name` only on signup | `Customer{id, name, phone, email}` — `email` may be `""` but **never null**. **Please add** `accessToken`, `refreshToken`, `expiresIn` | `otp_screen` → `AppState.verifyOtp` | Blocking |
|
||||
|
||||
Not yet seamed: token refresh, `GET /me`, logout. Session persistence is
|
||||
unbuilt and waits on these.
|
||||
|
||||
## Catalogue
|
||||
|
||||
| # | Method | Sends | Must return | Consumed by | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| 4 | `getServiceableStates()` | — | `List<ServiceArea>` · `code`, `name`, `districtCount` (**available districts only** — the app hides a state at 0), `transitTag?` ≤ 22 chars | Step 2 state picker (`destination_screen`) | Blocking |
|
||||
| 5 | `getServiceableDistricts(stateCode)` | `stateCode` | `List<District>` · `code`, `name`, `available`, `note?`, `hub?`, `promise?`, **`lat`/`lng`** (hub position — the route map draws to it). **Return unavailable districts too**; the client filters them but uses the names for "Coming soon" | Step 2 district picker | Blocking |
|
||||
| 6 | `getPickupSlots({pickup})` | pickup `lat`/`lng` | `List<PickupSlot>` · `id`, `day`, `window`, `available`, `note?`, `tag?`, `milersNearby`, `caption?`. `day`/`window` are **server-formatted IST display strings** | Step 3 (`slot_screen`) | Blocking |
|
||||
| 7 | `getBookingLimits()` | — | `maxPackages`, `maxDestinations` | Step 2 caps, add-destination button | Blocking (safe defaults 20/5 on failure) |
|
||||
|
||||
## Location
|
||||
|
||||
| # | Method | Sends | Must return | Consumed by | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| 8 | `reverseGeocode(lat:, lng:)` | `lat`, `lng` under the pin | `Place{title, sub, lat, lng}` — `title` primary line ≤ 32 chars, `sub` supporting street/city. Both required | Pickup card address box | Blocking · **hottest call in the flow** — fires on every map settle. p95 < 300 ms, cache by rounded coordinate |
|
||||
| 9 | `searchPlaces(query)` | `query` (empty ⇒ recent/saved, ≤ 4), bias to `lat`/`lng` | `List<Place>` — **`lat`/`lng` are mandatory**; a result without them cannot become a pin | Place search sheet | Blocking |
|
||||
|
||||
## Booking
|
||||
|
||||
| # | Method | Sends | Must return | Consumed by | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| 10 | `estimateFare({pickup, destinations})` | pickup `lat`/`lng`; per destination `stateCode`, `districtCode`, `packageCount` | `FareEstimate{min, max, paymentMethod, parcel}` — ints in ₹, combined for the whole visit | Review total, receipt lines | Blocking (a failure must not block booking) |
|
||||
| 11 | `createBooking({pickup, destinations, slotId, fare})` | `pickup` (title, sub, **lat, lng**), `slotId`, `destinations[]` (`stateCode`, `districtCode`, `packageCount`, optional `details`), shown `estimate` | `Booking` with `reference` (`DM-######`), `stage: booked`, `status: active`, `cancellable: true`, one-entry `history`, **no `trackingId` anywhere** | Review → Confirmed | Blocking · needs `Idempotency-Key` |
|
||||
| 12 | `cancelBooking(reference, reason)` | `reference`, `reason?` (5 presets or free text, may be null) | nothing; `409` once past `arrived` | Cancel sheet, Tracking footer | Blocking |
|
||||
| 13 | `isCancellable(stage)` | — (pure policy mirror) | `true` through `arrived` | Tracking footer | Blocking · server re-checks regardless |
|
||||
|
||||
## Reading back
|
||||
|
||||
| # | Method | Sends | Must return | Consumed by | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| 14 | `getBookings({status})` | `status?`, `limit`, `cursor` | `List<Booking>?` — **null means "keep the local copy"**, which is all the mock does. Return rows and `AppState` adopts them wholesale | Home "Recent", Orders tabs, pull-to-refresh | Wired |
|
||||
| 15 | `getBooking(reference)` | `reference` | `Booking?` — same null contract. Full object per §9.3 of the requirements doc | Tracking (on open), Order details | Wired · **this is what retires the debug stage stepper** |
|
||||
| 16 | `slotById(id)` | — (sync) | `PickupSlot` for a booking's window | Review, Confirmed, Tracking, Order details, Home card, Cancel sheet | ⚠️ **Design gap** — served from whatever `getPickupSlots` last returned. A booking made yesterday cannot name its window. Fix by putting `slot{day, window}` **on the booking object**, then this method disappears |
|
||||
| 17 | `districtByCode(code)` | — (sync) | `District` with `lat`/`lng` for the route map | Route map on Review, Confirmed, Tracking | Falls back to a built-in centroid table until #5 sends coordinates |
|
||||
|
||||
## Delete at go-live
|
||||
|
||||
| # | Item | Why |
|
||||
|---|---|---|
|
||||
| 18 | `newReference()`, `newTrackingId()` | Ids are minted by the backend — `DM-######` at booking, `DMX########` per destination at `order_created` |
|
||||
| 19 | `ApiFlags` + the Account ▸ Prototype controls | Empty / error / slow simulation. Replace with real staging error injection |
|
||||
| — | `AppState.seedDemoOrders()`, `_applyStage` side effects | Every side effect in `_applyStage` (assigning a Miler, minting tracking ids, settling `amountPaid`, capturing weight and photos) is something the **backend** performs. It exists so the prototype can show each stage; it must not survive real status events |
|
||||
| — | `_StageStepper` on the tracking screen | The stand-in for push updates. Deleting it is the only client change once #15 and push land |
|
||||
|
||||
## Not seamed at all yet
|
||||
|
||||
These have no mock method to replace — they need new client work alongside the
|
||||
backend, and are listed so nobody assumes they are covered:
|
||||
|
||||
* **Push registration** (`POST /devices`) and notification handling — no seam.
|
||||
* **Deep links** — `doormile://track/…` has no intent filter on Android and no
|
||||
URL types on iOS.
|
||||
* **Session persistence** — no token storage; depends on auth #3.
|
||||
* **Delivery details write-back** (`PATCH …/destinations/{i}`) — the sheet edits
|
||||
the draft in memory; there is no method to push a post-booking edit.
|
||||
* **Parcel photos** — `ParcelVerification.photos` are rendered as placeholder
|
||||
tiles; they need fetchable signed URLs before the receipt shows real images.
|
||||
|
||||
---
|
||||
|
||||
## Contract conformance pass — 8 Sep 2026
|
||||
|
||||
Audited against the customer API document (28 routes, `/customer/*`). The seams
|
||||
were all in place; what was wrong was the **wire shape** inside them, which is
|
||||
the class of mistake that costs nothing at compile time and a whole feature at
|
||||
runtime. Fixed here:
|
||||
|
||||
| What | Was | Now |
|
||||
|---|---|---|
|
||||
| `POST /auth/otp/verify` | sent `code` | sends `otp`. Nothing verified before this |
|
||||
| `POST /fare/estimate` | sent `lat`/`lng` + `packageCount`; read `min`/`max` | sends `latitude`/`longitude` + `packages[]`; reads `minRupees`/`maxRupees`/`routeKm`. No price could ever have been shown |
|
||||
| `POST /bookings` | nested `details{}`, `lat`/`lng`, no contact, no `remarks`, extra `estimate` | flat destinations, `latitude`/`longitude`, `contactName`/`contactPhone` from the session, per-destination notes joined into `remarks` |
|
||||
| Booking response | `destination.label` gated on `stateCode`/`districtCode`, which the response does not send | reads the **names**. Every read-back destination rendered as `—` before |
|
||||
| `pickup.latitude` | read only `lat` | reads either. The tracking map had no pin |
|
||||
| `error.code` | compared capitals to lowercase constants | translated by `ApiException.normalise`. `UNAUTHORIZED` did not satisfy `isAuthFailure`, so **the token refresh never fired** and every session died at one hour |
|
||||
| `isCancellable` | `<= arrived` | `< arrived`. `arrived` is the contract's cutoff; offering Cancel there invites a `BOOKING_NOT_CANCELLABLE` the app then has to explain |
|
||||
| List envelopes | `data` had to be a bare array | also read through `data: { items: [...] }`, cursor and total from either level |
|
||||
| `maxCodAmount`, `codAmount`, destination `index` | dropped on the floor | parsed and carried. No screen sets a COD amount yet — see below |
|
||||
| `POST /ops/bookings/{ref}/stage` | not wired at all | `DoormileApi.setStage`, driving the tracking stepper on a staging build (`--dart-define=DM_ALLOW_STAGE_OVERRIDE=true`) |
|
||||
| `GET /pickup-slots` | no revalidation | `ETag`, keyed per zone |
|
||||
| `GET /places/search` | sent `q` only | biases by the pickup coordinates |
|
||||
|
||||
### Open with the backend
|
||||
|
||||
1. **`packages[].weightKg` on the fare estimate.** This app never asks the
|
||||
customer what a parcel weighs — the Miler weighs it at the door, which is
|
||||
when the price settles — so it sends one unweighted entry per package. If
|
||||
the field is mandatory, the estimate needs a documented default rather than
|
||||
a number the client invents.
|
||||
2. **The saved-locations body** (`POST`/`PUT /customer/locations`) is not in the
|
||||
document. It is sent as `{title, sub, latitude, longitude, label}`, matching
|
||||
the coordinate spelling used everywhere the contract *is* explicit.
|
||||
3. **List envelopes.** Whether a paged route answers with a bare `data` array or
|
||||
`data: { items, nextCursor }` is not specified; the client reads both.
|
||||
4. **COD.** `codAmount` is parsed and echoed but no screen offers to set one, and
|
||||
`maxCodAmount` defaults to `0` = not offered. Needs a product decision before
|
||||
it is more than a field.
|
||||
|
||||
### Still not wired, and not a contract problem
|
||||
|
||||
* `GET /orders/{trackingId}` — implemented, but nothing calls it: there are no
|
||||
deep links (no Android intent filter, no iOS URL types).
|
||||
* `POST`/`DELETE /devices` — implemented, but no push SDK produces a token.
|
||||
* `PATCH /bookings/{ref}/destinations/{i}` — implemented through
|
||||
`AppState.saveDestinationDetails`, but the details sheet only edits the
|
||||
**draft**. There is no post-booking edit screen to call it from.
|
||||
|
||||
---
|
||||
|
||||
## Mock removal — 8 Sep 2026
|
||||
|
||||
The "Delete at go-live" list above is done, and then some. `lib/` now contains
|
||||
**one** API implementation.
|
||||
|
||||
| Removed from `lib/` | Where it went |
|
||||
|---|---|
|
||||
| `data/mock_doormile_api.dart` | `test/support/fake_doormile_api.dart`, injected via `DoormileApi.overrideInstance`. Not compiled into the app |
|
||||
| `AppConfig.useMock` / `DM_MOCK` / `isTest` / `showPrototypeControls` | gone — there is no define that serves invented data |
|
||||
| `AppConfig.skipAuth` / `DM_SKIP_AUTH` and its "Dev Build" customer | gone — the app is signed into by signing in |
|
||||
| `ApiFlags` and Account ▸ Prototype controls | `FakeFlags` on the test double |
|
||||
| `DoormileApi.isMock`, `newReference()`, `newTrackingId()` | gone — the server owns both identifiers |
|
||||
| `AppState.seedDemoOrders()` / `resetDemo()` / `advanceTo()` / `_applyStage()` | the fake's own `_seed()` and `applyStage()`. **The app can no longer move a booking's stage at all** |
|
||||
| `AppState.setFlag()` / `flag()` | gone |
|
||||
| `ui/screens/dev_entry.dart` (`DM_START`) | deleted — it fabricated a signed-in customer and a pickup point |
|
||||
| `getBookings()` | gone — dead since `refreshOrders` moved to `getBookingPage` |
|
||||
|
||||
`AppState.refreshOrders` now adopts every page it is given, empty ones included.
|
||||
The client keeps no booking the backend has not sent.
|
||||
|
||||
### Invented defaults, also removed
|
||||
|
||||
These were the quieter half: fields that read as facts on screen but were
|
||||
client-side constants when the backend said nothing.
|
||||
|
||||
| Was | Now |
|
||||
|---|---|
|
||||
| `Person.rating = 4.9`, `trips = 1240`, `vehicleType = 'E-Scooter'` | nullable. The "4.9 · 1240+ pickups" line and the vehicle type are **absent** unless the backend sent them — a default put an invented reputation on a real person's card |
|
||||
| `Booking.routeKm = 6.4` → "6.4 km" on the receipt | nullable; the Distance row is dropped |
|
||||
| `Booking.milersInZone = 3` → "3 verified Milers active in your zone" | defaults to 0 and the banner is not shown |
|
||||
| `fare.parcel ?? 'Standard box'`, `fare.paymentMethod ?? 'UPI'` | nullable; both rows dropped |
|
||||
| Account's `customer?.name ?? 'Joe Oommen'` | empty |
|
||||
| Parcel photos drawn as a placeholder tile | `Image.network` when the reference is a fetchable URL, falling back to the frame |
|
||||
| "not part of this prototype" | "not available yet" |
|
||||
|
||||
### What this does not fix
|
||||
|
||||
A booking created here reaches the backend and the admin console's booking list.
|
||||
Its **badge** still stalls at `picked`, because the console derives it from
|
||||
`mapBookingStatusToDeliveryStatus(booking.status)` and booking status ends at
|
||||
`Converted_To_Consignment` — the delivery half of the lifecycle lives on the
|
||||
consignment, exactly as §4 of the customer API document marks `in_transit`,
|
||||
`out_for_delivery` and `delivered` as *Per Destination*. That is a console-side
|
||||
join (`getConsignments()` by `booking.consignmentid`) and cannot be fixed from
|
||||
this app.
|
||||
782
docs/BACKEND_REQUIREMENTS.md
Normal file
782
docs/BACKEND_REQUIREMENTS.md
Normal file
@@ -0,0 +1,782 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user