Files
doormile_customer_app/docs/BACKEND_CHANGES.md
Thiru-tenext c3e25feaea Five payload bugs, four pages behind dead rows, and one sheet
── The full-address path was reaching the Miler empty ──

`DestinationGroup.toBookingJson` spread its details FLAT across the
destination. The contract nests them under `details{}`, and a destination
carrying keys the server does not recognise is accepted without a word — so
every building number, street, landmark, recipient name, recipient phone and
pin a customer typed was written, answered 201, and thrown away. The Miler
arrived with a district.

Four more on the same call. The destination pin spelled `latitude`/`longitude`
— the same spelling that answered 422 unserviceable for months on the pickup
before it was fixed there and missed here. A PATCH that sent `null` to clear a
field, with a comment saying so, when the server writes only non-nil values, so
a landmark could be added and never removed. Per-destination `instructions`
folded into the visit's one `remarks` line on the belief the contract had no
per-destination note; it has one. And `contactName`/`contactPhone` on the
pickup object, which the create contract has no room for and drops.

The fix ships unverified, deliberately. If `details{}` is also the wrong shape
the fields drop exactly as they do today — it cannot be worse, and holding it
costs every full-address booking in the meantime. docs/BACKEND_CHANGES.md asks
for the confirmation; tool/verify_booking.sh runs it in one command.

── Who the Miler rings ──

One number reaches the rider and it is the account's: `GET /miler/bookings`
returns a single `customerphone`, verified against production and written down
in the rider app's own stop_contact.dart. So "Someone else is handing it over?"
was collecting a number that reached nobody.

Review now shows the number that will actually be dialled, and the handover
person travels in `remarks` with a name, labelled for whoever reads it. Both
screens say plainly that the rider's call button still dials the account —
better than letting somebody hand their parcel to a neighbour believing
otherwise.

── Account's rows led nowhere ──

Two had no `onTap` at all — a chevron pointing at a page that did not exist —
and three answered with a toast. Five rows making a promise, one keeping it.

Notifications, Payment, Help and About are real screens now, written to one
rule: say only what is true of this app today. There is no notification
endpoint, no stored payment instrument and no push SDK wired in, so none of
them pretends to manage any of that. Support shows no contact block at all
rather than a number that rings nowhere — AppConfig carries the fields empty
until somebody fills them in.

── ONE TOUCH is one sheet ──

It was two in sequence with a dismissal between them, and the destination step
made you open a state to see any city — two levels of navigation for something
its own search already flattened. One flat list headed by state, which is also
the answer to "where do you deliver?", and one surface that changes its
question instead of closing so another can open.

Home says the reach in a line, and it needed two fixes to appear at all:
`cachedCities` walked closed states looking for districts that are only fetched
for open ones, and `loadCities` filled two caches while notifying nobody.

── Sending a second parcel ──

`maxDestinations` is 1 in production, so two parcels for two places means
booking twice — and that cost the whole flow twice, re-answering a door the
customer had not moved from. `startBookingFrom` carries the door, carries the
destination only when asked, and never carries the window: a slot fills up, and
a second booking pinned to one that is now full is refused at confirm with
nothing the customer can act on.

Review also says why there is no "add another destination", so a cap reads as a
limit rather than a missing button.

── Bundle ──

pubspec named its images one by one. Declaring `assets/images/` as a folder
shipped a 974 KB launcher-icon master to every customer for a file no code
opens.
2026-09-29 12:31:58 +05:30

285 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Doormile CX — Backend Changes Needed
| | |
|---|---|
| Date | 2026-09-29 |
| From | `doormile_cx` (customer app) team |
| Checked against | Customer App API Reference v1.0 (backend code at `44ba33e`) |
| Evidence | Live probes against `https://api.doormile.com/api/v1` on 2026-09-29, quoted inline |
**The two most urgent items here need no app work at all — they are deployment
configuration.** One of them is a live security exposure. Everything below §3 is
ordinary product work and can wait its turn.
| # | Item | Owner | Effort |
|---|---|---|---|
| 1 | `ENV` is not set to production | Backend / DevOps | One line |
| 2 | SMS is unfunded — new customers cannot sign in | Product decision | One line, or weeks |
| 3 | If PIN becomes the only auth, three holes must close first | Backend | Days |
| 4–8 | Payload, contacts, multi-destination, push, staging | Both | Ordinary |
---
## 1. `ENV` is not production on `api.doormile.com`
**Verified today.** `POST /customer/auth/otp/request` with a valid phone
returns:
```json
{"data":{"codeLength":4,"resendAfterSeconds":30,"sent":true},"success":true}
```
Per API Reference §3.5, when no SMS gateway is configured the log sink is used,
and it behaves differently by environment: **in production, sending fails and
this endpoint returns `500`.** It only writes the code to the server log and
reports `sent: true` when `ENV` is *not* production.
It reported `sent: true`. The readiness probe confirms the gateway is absent:
```
GET /api/v1/ready → "sms": { "configured": false, "transport": "log" }
```
**Why this matters beyond OTP.** `ENV != production` is the gate on two other
things:
- **`CX_STAGING_OTP`.** If that variable is set in this environment, *every*
issued code is a fixed constant — for every account, including real customers.
Anyone who knows it can sign in as anyone. We did not test this, because
confirming it means signing in to an account we do not own. **Please check the
deployment config for it and treat it as urgent if it is set.**
- **`POST /customer/ops/bookings/:reference/stage`**, the QA stage override, is
one env var (`CX_ALLOW_STAGE_OVERRIDE`) away from being reachable by any
customer token.
**What to do:** set `ENV=production` on `api.doormile.com`, and confirm
`CX_STAGING_OTP` is unset there. This is the single highest-priority item in
this document.
---
## 2. SMS is unfunded — new customers cannot sign in
**The OTP routes are not removed.** We probed all six auth endpoints with
malformed bodies; every one returned a normal `400` validation error rather than
a `404`:
```
POST /auth/otp/request → 400 invalid "Enter a valid phone number or email address"
POST /auth/signup → 400 invalid_name "Enter your full name"
POST /auth/otp/verify → 400 invalid "Enter the code we sent you"
POST /auth/login → 400 invalid "Enter a valid phone number"
POST /auth/set-pin → 400 invalid "Enter a valid phone number"
POST /auth/verify-pin → 400 invalid "Enter your PIN"
```
So the server still issues a valid 4-digit code on every request. **The code
goes to the server log instead of the customer's handset.** The practical result
is that anyone with log access can sign in as anybody, and no customer can sign
in at all.
**The failure is slow, which is what makes it dangerous.** Refresh tokens live
60 days and rotate, so customers already signed in keep working. Only *new*
sign-ins break — new installs, reinstalls, new customers, anyone signed out.
Nobody reports this for weeks, then everybody does at once as tokens age out.
**The customer app has no other way in.** Its entire auth surface is
`/auth/otp/request`, `/auth/signup` and `/auth/otp/verify`
(`lib/data/live_doormile_api.dart:44-95`). There is no PIN code in the app.
### The decision we need
| Option | Backend work | App work | Consequence |
|---|---|---|---|
| **A — Fund SMS again** | Set `SMS_GATEWAY_URL` | **None** | Sign-in works immediately. The app ships unchanged. You inherit none of §3 |
| **B — PIN becomes permanent** | §3 first | Three new screens | Weeks. You inherit all three holes in §3, on the only door into the product |
**We recommend A.** The routes are already live and validating; it is one
environment variable against weeks of work on both sides, and it avoids §3
entirely. If cost is the blocker, say so and we will price the alternatives —
but B is not the cheap option, it only looks like it.
---
## 3. If PIN becomes the only auth, three things must change first
An earlier version of this document asked you to **remove** the PIN routes,
because the app did not use them and `set-pin` is an account-takeover path. That
ask is withdrawn. If PIN becomes the only door, those same holes stop being a
side risk and become the front door.
| # | Hole | Why it matters as the only auth | What we need |
|---|---|---|---|
| 1 | **`set-pin` requires no proof of ownership.** It sets the first PIN on *any* account that has none — including every account created by OTP signup, by `otp/verify`, or by the console | Anyone who types a stranger's number owns their account, their address history and their active pickups. Every existing OTP-created account is currently claimable | Proof of ownership before the first PIN. **This is the same question OTP was answering** — it does not disappear when SMS does |
| 2 | **No PIN reset or change endpoint** | A customer who forgets a 4-digit PIN is locked out permanently, and support has no lever. OTP always provided a way back; nothing does now | A reset path, and a way for support to trigger it |
| 3 | **No per-account lockout on `verify-pin`** | 10,000 combinations. The only limit is 10 req/min per IP, shared with `/miler/login`, `/admin/login` and `/hub/login`, and defeated by rotating IPs | Per-account attempt limit and backoff |
Also: **`POST /auth/login` tells an anonymous caller whether a number is
registered, and returns the account holder's name.** Free customer enumeration.
---
## 4. What we found and fixed in the app
Separately from auth, we audited the booking payload against the API reference.
Five things on the wire were wrong. All five are fixed.
| # | What the app sent | What the contract wants | Consequence |
|---|---|---|---|
| 1 | Destination details spread **flat** on the destination | Nested under `details{}` | **Every building number, street, landmark, recipient name, recipient phone and pin was dropped.** 201 returned, nothing reached the Miler |
| 2 | Destination pin as `latitude`/`longitude` | `pin: {lat, lng}` | Pin discarded. Same spelling that caused months of `422 unserviceable` on the pickup before it was fixed there |
| 3 | `PATCH .../destinations/{index}` sent `null` to clear a field | `""` clears; `null` means "leave alone" | A customer could add a landmark and never remove one |
| 4 | Per-destination `instructions` folded into the visit's `remarks` | `details.instructions` exists per destination | One note for a multi-stop visit instead of one per door |
| 5 | `contactName` / `contactPhone` on the **pickup** object | Pickup is `{title, sub, lat, lng}` only | Written on every booking, read by nobody. See §6 |
A customer using the full-address path typed a flat number, a street and a
recipient, saw them on the review screen, confirmed — and the Miler arrived with
only a district. There was no error anywhere in that chain.
**We are shipping the fix before it is verified**, because it cannot be worse
than today: if `details{}` is also the wrong shape, the fields are dropped
exactly as they are now. Verification below is confirmation, not a gate.
---
## 5. Please confirm the nested shape
One booking created with the body below, then a read of the stored destination
confirming `street`, `building`, `landmark`, `recipientName`, `recipientPhone`,
`instructions` and the pin all landed. If they did not, tell us the shape that
does work — we will follow the server, not the document.
```json
{
"stateCode": "TN",
"districtCode": "TN-MAA",
"packageCount": 3,
"details": {
"recipientName": "Meera S",
"recipientPhone": "9876543210",
"building": "12/A",
"street": "MG Road",
"landmark": "Opp. bus depot",
"instructions": "Call before arriving",
"pin": { "lat": 13.085, "lng": 80.21 }
}
}
```
**Worth fixing regardless:** a destination carrying keys the server does not
recognise is accepted silently. That property is what let this run for months.
If unknown keys on `destinations[]` returned `400 invalid` naming them, this
class of bug would surface on the first request instead of in the field.
---
## 6. A real pickup contact field
The Miler gets exactly one phone number for a collection, and it is the number
the customer signed in with. `GET /miler/bookings` returns a single phone field,
`customerphone`, which the rider app stores as `pickupcontactno` — verified
against production on 21 August 2026 and recorded in the rider app's own source
(`miler/lib/data/stop_contact.dart`). Nothing the customer app sends on `pickup`
can change it: the create contract's pickup is `{title, sub, lat, lng}` and
extra keys are dropped.
**Why it matters.** A parcel handed over by a spouse, a neighbour, a
receptionist or a shop assistant is ordinary in collections. Today the rider
rings the account holder, who may be at work, and the collection fails on the
doorstep. **A failed pickup costs a whole rider trip — the most expensive unit
in this business.**
**Meanwhile** the app puts a handover person in `remarks`, labelled so a human
reads it unambiguously (`Handover contact: Meera S, +91 9003144518`), and both
the pickup and review screens tell the customer plainly that the rider's call
button still dials their own number.
**What we are asking for**
1. `pickup.contactName` and `pickup.contactPhone` accepted on
`POST /customer/bookings`, stored on the booking.
2. Surfaced to the rider as a **second** contact on the stop — not replacing
`customerphone`, because the account holder is still who the rider escalates
to.
3. Meanwhile, confirm whether `remarks` reaches the rider at all. The rider app
reads a `notes` field per stop; we do not know whether the customer app's
`remarks` lands there or stops at the admin console.
**Related:** `details.recipientPhone` is stored raw when normalisation fails and
`details.codAmount` accepts a negative number. Both are money-or-contact fields
reaching a rider unvalidated.
---
## 7. The rest, ranked
| Ask | Why | What to change |
|---|---|---|
| **Multi-destination** | A rider makes **one trip** to the door. Three parcels for three cities in one visit is one trip instead of three. Production advertises `maxDestinations: 1` (confirmed live 2026-09-28), so a customer books three times and either three visits get scheduled or the hub merges them by hand | Key the Miler build on `consignmentid`, then add the `customerbookinglimits` row. The customer app already supports N destinations end to end and obeys the cap it reads on every launch. **Raising the number is the only change; none on our side** |
| **Push is configured but dead** | `POST /customer/devices` exists and the stage pushes are written, but they reach nobody. Several older paths still write `appcustomers.device_token`, which no `/customer/*` endpoint fills — so a customer cancel sends no push and an ops cancel usually sends none either | Confirm `FIREBASE_SERVICE_ACCOUNT_PATH` is set, or every push is skipped silently. We will wire FCM and register on sign-in. Retire the legacy `device_token` paths |
| **Failed delivery is invisible** | RTO, missing, damaged and failed attempts have no customer stage, so the order keeps showing `out_for_delivery` forever. The parcel is in trouble and the app says it is on its way | Add stage keys. Needs an app release first — the client falls back to `booked` for an unknown key — so tell us before shipping them |
| **Two responses break the envelope** | We branch on `error.code`. The idempotency `409` puts `code` at the **top level**; the city gate `400` sends `error` as a **string**, not an object. Both fall through to a generic message, so "an identical request is still being processed" renders to a customer as a booking conflict | Move both into `error: {code}` |
| **`estimate` on booking create** | Documented with a ±15% tamper rule. We do not send it, so the server always re-quotes — the band the customer saw can differ from the stored `fare` | Confirm you want it and we will send the exact `min`/`max` |
| **`maxCodAmount`** | We parse it from `/config/booking-limits`; the reference says it is never returned | Send it, or we drop the field |
---
## 8. There is no test environment
`AppConfig._stagingBase` is `_prodBase`; no staging host has ever been named.
Every request this app makes — including anything we do to test it — is
production.
That is why §5 is unanswered. Verifying a JSON field name currently costs a real
pickup in a real slot that a real Miler can be dispatched to. With SMS down, the
only way to get a session for a test is `set-pin`, which creates a permanent
production account with no reset and no delete path.
**A staging host closes §5 in ten minutes.** Cheapest item here; unblocks the
most.
---
## 9. Remaining security items
From API Reference §9.2, excluding the OTP items now covered by §1.
- **`/ws/bookings/:id/track` has no authentication** and streams a rider's name
and live GPS for any sequential booking id. The app does not use WebSockets;
we poll `GET /bookings/:reference`. Nobody should be able to enumerate rider
positions.
- **The rider's real mobile number is exposed** in `miler.phone` unless
`MILER_CALL_PROXY` is set. The reference marks this "a setting to close before
launch". Please confirm it is set.
---
## 10. What we need back
1. **`ENV=production` set, and `CX_STAGING_OTP` confirmed unset** (§1). Today.
2. **A or B on SMS** (§2). Everything about auth waits on this one answer.
3. **A staging host** (§8) — cheapest item, unblocks §5 immediately.
4. **Yes or no on the pickup contact field** (§6), and whether `remarks` reaches
the rider today.
5. **A date for multi-destination** (§7), or a note that it is not planned, so we
can word the app honestly. It currently says "one destination per pickup for
now".
---
## Appendix — how to verify a payload change
Use the method that settled the `lat`/`lng` bug: **two requests, one apart,
differing in one field.** That bug ran for months because the app sent
`latitude`/`longitude` on the pickup while the server reads `lat`/`lng` — it saw
a booking with no coordinates and answered `422 unserviceable`, blaming the
customer's address for a field name. Settled on 2026-09-23 with two requests:
same pickup, same destination, same slot. `latitude`/`longitude` → 422.
`lat`/`lng` → 201, booking DM-252803.
`tool/verify_booking.sh` in the customer app repo automates exactly that: signs
in, takes the first available slot, books one pickup with every detail field
filled in and marked `VERIFY …`, reads it back, prints which of the seven fields
survived, and cancels the booking. Point it at staging with `--base` the moment
there is one.