updates on the api endpoints on the customer page and more

This commit is contained in:
2026-09-02 16:32:53 +05:30
parent 8e2c484bcb
commit d12629a1e4
31 changed files with 3541 additions and 121 deletions

271
docs/DEV_ONBOARDING.md Normal file
View File

@@ -0,0 +1,271 @@
# Doormile Backend — Developer Onboarding & Working Memory
Read this before touching the backend. It is the portable version of knowledge
that otherwise lives only in one machine's Claude session memory. It covers two
things a new dev (or a fresh Claude session on another machine) needs:
1. **How we use Claude on this project** — the project-memory file, the skills,
the working conventions.
2. **Operational knowledge that isn't in the code** — deploy topology, build
gotchas, production landmines, and the incident history behind current
design choices.
Related docs already in this repo:
- [`CLAUDE.md`](../CLAUDE.md) — the full project memory (architecture, data
model, route surface). Start there for *what the system is*.
- [`docs/doormile-flow.md`](doormile-flow.md) — end-to-end booking/assignment flow.
- [`docs/miler-app-api.md`](miler-app-api.md), [`docs/express-console-api.md`](express-console-api.md) — API contracts.
- [`docs/logistics-base-handover.md`](logistics-base-handover.md) — the pickup-source
and base-handover flow (requests 25–31), including the state transitions.
- [`docs/jupiter2doormile.md`](jupiter2doormile.md) — the legacy→new migration map.
- [`docs/test-booking-runbook.md`](test-booking-runbook.md) — how to run a test booking.
- [`skills.md`](../skills.md) — note on the installed Claude skill pack.
---
## Part 1 — How Claude is used on this project
### 1.1 Project memory (`CLAUDE.md`)
`CLAUDE.md` at the repo root is the single source of project context, loaded
automatically into every Claude session in this repo. It is checked into git, so
it travels to every machine and every dev. If you change how the system works,
update `CLAUDE.md` in the same change — it is treated as authoritative.
Sections in `CLAUDE.md` are tagged **[verified this session]** (confirmed
against source) vs **[carried forward]** (reported by a prior session, not
re-verified). Respect the distinction — don't treat carried-forward claims as
confirmed.
### 1.2 Session memory (machine-local — this is why this doc exists)
Claude also keeps per-fact memory files under
`~/.claude/projects/<project>/memory/`, indexed by `MEMORY.md`. These are
**not in git** and **do not travel** to another machine or dev. They accumulate
operational facts, incidents, and gotchas across sessions.
Part 2 below is a distillation of those files into a form the whole team can
read. When a memory fact changes, update *both* the memory file (for Claude) and
this doc (for humans).
### 1.3 Claude skills in use
The repo has the `addyosmani/agent-skills` pack installed at `.agents/skills/`
and symlinked into `.claude/skills/`. These are third-party, broad-trigger
skills that run with full agent permissions. Roster (see `skills.md` for the
full table):
`api-and-interface-design`, `browser-testing-with-devtools`,
`ci-cd-and-automation`, `code-review-and-quality`, `code-simplification`,
`context-engineering`, `debugging-and-error-recovery`,
`deprecation-and-migration`, `documentation-and-adrs`,
`doubt-driven-development`, `frontend-ui-engineering`,
`git-workflow-and-versioning`, `idea-refine`, `incremental-implementation`,
`interview-me`, `observability-and-instrumentation`,
`performance-optimization`, `planning-and-task-breakdown`,
`security-and-hardening`, `shipping-and-launch`, `source-driven-development`,
`spec-driven-development`, `test-driven-development`, `using-agent-skills`.
**How we treat them:** available on request, *not* auto-adopted over the
conventions in `CLAUDE.md` §7. The established conventions win: use the `utils`
response helpers, the `constants` enums, reuse `AssignMilerToBooking` and
`haversineKM`, prefer minimal-leverage fixes. Invoke a skill explicitly when its
scope fits; don't let a broad trigger override house style.
### 1.4 Standing working preferences (from `CLAUDE.md`)
- Direct, honest technical assessments. Don't declare victory early.
- Production-grade from the start, minimal-effort highest-leverage fixes.
- **Warn before any consequential server/schema change.**
- Once a decision is made, proceed and report — but re-ask on money/data-correctness.
---
## Part 2 — Operational knowledge (not derivable from the code)
### 2.1 Build & toolchain
- Go **1.25** (`go.mod` says `go 1.25.0`). Module `doormile`.
- On the primary dev Mac, Go is installed at `/Users/tenext/go` but **not on
PATH**. Prefix: `export PATH="/Users/tenext/go/bin:$PATH"` before any `go`
command, or a bare `go build` returns "command not found".
- `go build ./...`, `go vet ./...`, `go test ./...` all pass repo-wide.
- `scratch/*.go` are throwaway `func main()` scripts tagged `//go:build ignore`
so the toolchain skips them. If a `main redeclared` error appears, it's a
stripped build tag under `scratch/`, **not** a real app problem.
- `go build .` emits a ~65MB `doormile` binary in the repo root; it's
gitignored — delete it, don't commit it.
- Tests exist only for pure logic (`isHyperlocal`, `calculateVolumetricWeight`,
`ParsePage`). Everything else is verified by build+vet, review, or live test.
### 2.2 Deploy topology — how a change reaches production
- Live backend runs on **k3s** (not plain docker), server `66.116.225.226` port
**4422** (SSH, key-based auth from the primary Mac; `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`).
- Workload: **statefulset `doormile`**, namespace `doormile`, **3 replicas**.
Binary is `/app/server` in the pod; image
`docker.io/doormile/doormile-backend:latest`, `imagePullPolicy: Always`.
Pods report `APP_ENV=staging`.
- **There is NO CI.** Nothing watches the repo. A `kubectl rollout restart`
alone rebuilds nothing — it re-pulls the same image digest.
- **A change reaches prod only by a manual build+push** (needs Docker daemon up
+ Docker Hub creds):
```
docker build -t doormile/doormile-backend:latest .
docker push doormile/doormile-backend:latest
kubectl rollout restart statefulset/doormile -n doormile
```
- **The image builds from the working tree** (`ADD . /app/`). Uncommitted local
edits and any throwaway file under `cmd/`/`scratch/` get baked in. **Always
`git status` before building**, and **confirm code is committed *after* a
deploy** — `git log origin/main` tells you nothing about what's running.
- **Verify what's actually live** rather than assuming: `kubectl exec -n
doormile doormile-0 -- ls -la /app` shows the binary's build date; or probe a
response field only the new code emits. Group auth (`/admin/*`, `/hub/*`,
`/miler/*`) returns 401 for unmatched routes, so a 404-vs-401 probe can't tell
you if a route exists — use an authenticated request.
### 2.3 Env vars & the source-of-truth manifest
- Env is **inline on the statefulset spec** (`.spec.template.spec.containers[0].env`),
no ConfigMap/envFrom. Set a flag with
`kubectl -n doormile set env statefulset/doormile KEY=value` (auto-rolls). A
plain restart does **not** add a var that isn't already in the spec.
- **Source-of-truth manifest**: `/opt/kubernetes/manifests/doormile/miletruth.yaml`
(git repo at `/opt/kubernetes/.git`; second copy under `/root/kubernetes/...`).
A `kubectl apply` of it **overwrites** live `set env` changes — so any live
flag change must also be written into this manifest or the next deploy reverts
it.
- The manifest supplies DB/Redis/NATS passwords via `secretKeyRef`
(`doormile-secrets`) while the live pods carry literals — verify that secret
exists before relying on `kubectl apply`.
- **Known live flag:** `MILER_COLLECTED_STATE_ENABLED=true` (hyperlocal two-step
pickup). `TRUSTED_PROXIES` must be set (api.doormile.com sits behind a
reverse proxy) or per-IP rate limits collapse all clients into one bucket.
- **`MILER_HUB_HANDOVER_ENABLED` — default off, and must stay off** until a rider
build that calls `POST /miler/consignments/:id/inward-at-hub` is live. On, a
hub-routed parcel stops at `Created` until the rider records the handover; off,
pickup-complete marks it `Inwarded_at_Hub` immediately, which is what the
deployed app expects. Flipping it early strands every intercity parcel on
`Created` with no button in the app to advance it and no row in any base's
received list. See [`logistics-base-handover.md`](logistics-base-handover.md).
### 2.4 Timezone convention (subtle — read before touching any time field)
The DB and backend time helpers run on **IST (Asia/Kolkata) wall-clock**.
`dbLocation = Asia/Kolkata`; `DBNow()`/`DBToday()` (`utils/helper.go`) return
IST wall-clock digits *tagged as UTC*. Consequence: clients (e.g. the miler app)
that send a timestamp — such as `logdate` on `POST /miler/logs` — should send
**IST wall-clock (phone local time in India), not UTC**. Sending UTC misaligns
Redis zset scores and time-window queries by 5h30m.
### 2.5 Postgres CHECK constraints predate the codebase
Status-column CHECK constraints are **not** created by GORM AutoMigrate — they
predate this codebase. Adding a new status *constant* in Go is not enough; the
DB rejects it with **SQLSTATE 23514**. Before adding any status enum value,
widen the matching `*_status_check` constraint in `migrations/migrate.go`. This
is also why the base-handover reconciliation path raises a `Lost` exception
rather than a more precise `Handover_Not_Received` — the latter would need
`consignmentexceptions` widened first.
Constraints exist on: `consignments`, `pickupbookings`,
`milerprofiles.availabilitystatus`, `bookingassignments`,
`consignmentexceptions`, `tripsheets`. `consignmenthistory` has no status check.
(This bit us live: `consignments_status_check` was missing `Collected_By_Miler`
and `Cancelled`, 500-ing every hyperlocal pickup and admin cancel.)
### 2.6 Miler telemetry pipeline (HTTP → Redis; there is NO MQTT)
MQTT is a jupiter concept; Doormile has none. Miler telemetry is 4 HTTP
endpoints, identity always taken from `c.Locals("userid")` (never the body):
- `PUT /miler/location` → Postgres (`MilerProfile` lat/lng/pincode) + Redis
(`miler:gps:{userid}` 30min TTL, `milers:locations` GEO set feeding dispatch).
- `POST /miler/logs` → **Redis-only** periodic telemetry point
(`miler_periodic_log:{userid}:{ts}` + zsets, scored by timestamp).
- `POST /miler/status` → Redis-only (`miler_status:{userid}`).
- `POST /miler/consignments/logs` → Redis (list+zset) + Postgres
`ConsignmentHistory`; takes an **array** (batch).
Device sensors (GPS/speed/heading/accuracy, battery/is_charging, connection)
come from Flutter plugins regardless of transport — MQTT isn't needed to collect
them. Console reads of the trail use **newest-first** fetch
(`ZRevRangeByScore`), then reverse to chronological, so a `?limit=N` window
keeps the *latest* fixes (fixed in `6e5da09` — previously `?limit=1` returned
the day's first blank early-boot ping).
### 2.7 Route optimizer
`routes.workolik.com` (env `ROUTE_OPTIMIZER_URL`) = the rider-bike FastAPI
service (OR-Tools + Valhalla). Doormile endpoint
`POST /api/v1/optimization/doormile/sequence`, contract matches
`internal/routing/optimizer.go`. As of `f6d339a`,
`routing.SequenceMilerStopsAsync` fires after every assignment path. A `step=0`
in prod is never the service being down — it means the rider had <2 active
stops, a stop had missing coords, or the assignment predated the wiring.
### 2.8 NATS ownership
Doormile has its **own** NATS (`nats://66.116.226.161:4223`, user `doormile`),
separate from jupiter's (`nats.workolik.com:4222`). Streams are declared by the
Go app in `db/streams.go` (`EnsureStreams`, add-only — never deletes/drops).
**Adding a `js.Publish` without adding its subject to `streamSubjects` silently
drops the event.** Publishing is best-effort: `if db.Js != nil { ... }`,
warn-log on failure, never fail the request. Do not run the old Python
`setup_jetstream.py` scripts — they used to clobber the subject list (now
neutered to read-only, but that change lives only on disk, not in git).
### 2.9 Config gotcha: milers need `configid = 1001`
`LoginMiler`/`VerifyMilerPin` look up `WHERE contactno = ? AND configid = 1001`.
`AppUser.configid` column-defaults to `1`, so any miler created without
explicitly setting configid authenticates against nothing and returns a
misleading `404 no miler account found` even though the row exists and is
Active. `CreateMiler` now defaults it to 1001. **When a miler "doesn't exist"
but the row is visibly there, check `configid` first.** Same trap applies to
`AppCustomer`.
---
## Part 3 — Access control status (know before adding client logins)
- **Admin/express console (`/admin/*`) has NO tenant scoping.** `LoginAdmin`
hardcodes `tenantid = 0` in the JWT and no admin handler filters by tenant —
every console login sees every tenant's data. **Do not create a client-facing
`doormile_auth` login** until this is fixed (mirror the `HubStaffAccount.Tenantid
*int` pattern: nil = Doormile staff/unrestricted, set = client/scoped).
- **Hub console** is only partially scoped: `scopeBookingsToOwnTenant` is applied
at ~3 of ~20 hub handlers that return booking/consignment data.
- Recurring flaw class in this codebase: **trusting a client-supplied
identifier** (body `userid`, path `:userid`, request `tenantid`). When
reviewing any handler, confirm identity comes from the token and ownership is
proven before read/write. (Several account-takeover/IDOR bugs of this shape
were fixed 2026-08-05.)
---
## Part 4 — Deliberate decisions (do not re-raise unprompted)
These are conscious calls by the project owner, recorded so they aren't
re-litigated:
- **`.env` and the Firebase key are committed to git.** Flagged as critical,
deliberately deferred. Config also hardcodes the same values as `getEnv`
fallbacks. Don't re-raise unless the owner opens the topic. **Do not add new
secret literals to any committed file.**
- **`/crm/*` stays unauthenticated** — the field-sales Flutter app sends no
token. Revisit only when that app can send a key.
- **B2C `PickupBooking.Tenantid` stays nil** — whether to attribute
direct-to-consumer traffic to a Doormile-ops tenant is a business decision.
- **Delivery OTP stays off for DailyGrubs.**
- Hyperlocal is decided per-booking with a 30km coord fallback (Option A); a
tenant-level service-type flag (Option B) was deferred.
- `PartnerInfo` vs `Tenant` naming, and `Customer` (legacy) vs `AppCustomer`
(new B2C) duplication — flagged, not acted on.
---
## Part 5 — Credentials & test accounts (where they live, not the values)
Secrets are **not** reproduced here (see Part 4). Pointers:
- **App/DB/Redis/NATS secrets** — `.env` (committed) and the `doormile-secrets`
k8s secret referenced by `miletruth.yaml`.
- **Live production DB** — `logistics` on `31.97.228.132:5433`. Read-only
inspection via a throwaway Go program using `.env` creds is the safe path.
- **Live backend Redis** — the manifest and `.env` have historically drifted
(stale `31.97.228.132:6379` vs live `66.116.226.255:6380`); confirm which the
running pods actually use before trusting either.
- **Test rosters** live in Claude session memory (machine-local): the Coimbatore
miler roster (6 riders, PIN `1234`), 3 customer app test accounts, and the
DailyGrubs onboarding (tenant 13, master admin `developer@doormile.com`). Ask
the owner for current values rather than assuming — they rotate.
> **Note on production writes:** the auto-mode classifier blocks production
> writes (kubectl set env, DB UPDATE/ALTER) inconsistently. Do **not** route
> around a block — hand the exact command to the owner to run. Read-only
> inspection is fine.

View File

@@ -0,0 +1,360 @@
# Logistics pickup-source and base-handover flow
The backend contract for requests 25–31 on the Miler logistics line. Written as
the answer to that register: what shipped, what the wire values are, and the
state transitions for each of the three journeys.
**Vocabulary.** The wire says *hub* — `inward_at_hub`, `Inwarded_at_Hub`,
`next_hub`, `pickup_source_type: "hub"`. The rider app renders that as *Base*.
Nothing here changes a wire value to match the app's wording, and the app's
wording never leaks back into this API. Console and backend keep saying hub.
---
## The flag
`MILER_HUB_HANDOVER_ENABLED` (env, read per request, **default off**).
| | off (today) | on |
|---|---|---|
| A hub-routed parcel at pickup-complete | `Inwarded_at_Hub` immediately | `Created` — collected, in the rider's hands |
| `next_action` returned | `handed_to_hub` | `inward_at_hub` |
| Rider's assignment | closed at pickup-complete | closed at the handover |
| Rider availability after pickup | `Available` | `Picked_Up` (still carrying) |
| Base sees it on `/hub/inbound/expected` | no — it is already received | yes |
Off is not a placeholder: it is what the currently deployed rider app expects. A
build that cannot call the handover endpoint would, with the flag on, collect an
intercity parcel and have no way to advance it — the parcel would sit on
`Created` in the rider's queue and appear in no base's received list. Turn it on
when a rider build that calls `inward-at-hub` is live:
```bash
kubectl -n doormile set env statefulset/doormile MILER_HUB_HANDOVER_ENABLED=true
```
Write it into `/opt/kubernetes/manifests/doormile/miletruth.yaml` at the same
time, or the next `kubectl apply` reverts it (see `DEV_ONBOARDING.md` §2.3).
**Everything else below is ungated** and live regardless of the flag: `next_hub`,
the handover endpoint, `next_action`/`next_hub` on the queue read,
`pickup_source_type`, base master data, inbound visibility, reconciliation and
the routing block.
---
## State transitions — the three journeys
`consignmentstatus` is the consignment's own state; `booking.status` moves to
`Converted_To_Consignment` at pickup-complete in all three and stops there.
### Base/Hub H1 → Customer
Pickup source is a base; the parcel then goes to a person. Routing is decided by
pincode, exactly as for any other pickup — a base-origin booking delivering into
the same postal area is hyperlocal.
| Step | Call | `consignmentstatus` | `next_action` |
|---|---|---|---|
| assigned | — | (no consignment yet) | `pickup` |
| collected at the base | `POST /miler/bookings/:id/pickup-complete` | `Collected_By_Miler` | `start_delivery` |
| heading out | `POST /miler/consignments/:id/start-delivery` | `Out_for_Delivery` | `deliver` |
| delivered | `POST /miler/consignments/:id/deliver` | `Delivered` | `none` |
The booking row carries `pickup_source_type: "hub"` and `sourceid` /
`pickuplocationid` = the base id, so Home names the base as the pickup source
rather than the rider's own office.
With `MILER_COLLECTED_STATE_ENABLED` off, pickup-complete goes straight to
`Out_for_Delivery` / `deliver` and there is no start-delivery step. That flag is
already `true` in production.
### Customer → Customer (hyperlocal)
Identical to the table above from pickup-complete onward; the only difference is
`pickup_source_type: "customer"` and `sourceid: null`, with the sender's own name
and address on the row.
### Customer → Base (intercity / interstate)
| Step | Call | `consignmentstatus` | `next_action` | `next_hub` |
|---|---|---|---|---|
| assigned | — | (no consignment yet) | `pickup` | null |
| collected | `POST /miler/bookings/:id/pickup-complete` | `Created` | `inward_at_hub` | the base, six fields |
| handed over at the base | `POST /miler/consignments/:id/inward-at-hub` | `Inwarded_at_Hub` | `handed_to_hub` | null |
After `Inwarded_at_Hub` the parcel is the network's problem, not the rider's —
tripsheet, transit, and a final-mile rider at the other end.
**With the flag off**, the middle row does not exist: pickup-complete returns
`Inwarded_at_Hub` / `handed_to_hub` directly, still with `next_hub` populated so
the app can name the base. `inward-at-hub` called against such a parcel answers
200 with the state that stands and `already_inwarded: true`, rather than failing.
---
## What changed, request by request
### 25 — `next_hub` on pickup-complete
`POST /miler/bookings/:bookingid/pickup-complete` now returns `next_hub` whenever
the parcel's next leg is a base, with all six fields:
```jsonc
{
"tracking_no": "DM...",
"consignment_id": 4821, // always present
"consignmentstatus": "Created",
"status": "Created", // alias, same value
"booking_no": "BK...",
"booking_status": "Converted_To_Consignment",
"next_action": "inward_at_hub",
"next_hub": {
"id": 1,
"name": "Coimbatore Hub",
"address": "14 Avinashi Road, Peelamedu, Coimbatore",
"pincode": "641004",
"latitude": 11.0272,
"longitude": 76.9905
}
}
```
`next_hub` is absent for a hyperlocal parcel — there is no base leg.
**Which base.** `resolveHandoverHub`, in order: the base the booking was routed
to (`nearesthubid`, nothing populates this column today — it is checked first so
that it wins the moment something does), then the collecting rider's own base
(the operational default), then the nearest **active** base to the pickup point,
then any base at all. The app never chooses; it navigates to what it is given.
The nearest-active-base step replaced a fallback that took whichever hub row came
back first from an unordered query.
### 26 — the handover mutation
```
POST /miler/consignments/:id/inward-at-hub
Idempotency-Key: <optional, same middleware as pickup-complete>
{ "hub_id": 1, "latitude": 11.0272, "longitude": 76.9905 }
```
`hubid` is accepted as an alias for `hub_id`; `lat`/`lon` for
`latitude`/`longitude`. The whole body is optional — with nothing sent, the parcel
is handed into the base it was already routed to.
```jsonc
{
"consignmentid": 4821,
"trackingno": "DM...",
"consignmentstatus": "Inwarded_at_Hub",
"inwardedat": "2026-09-02T14:22:10Z",
"hub": { "id": 1, "name": "...", "address": "...", "pincode": "...",
"latitude": 11.0272, "longitude": 76.9905 },
"next_action": "handed_to_hub",
"already_inwarded": false
}
```
It names the resulting state, per the rule request 15 exists for. Idempotent
twice over: the route carries the shared `Idempotency-Key` middleware, and a
parcel already inwarded answers 200 with `already_inwarded: true` rather than a
4xx — a retry after a dropped response confirms instead of erroring.
Side effects, all in one transaction: status and `currenthubid` set, `inwardedat`
stamped, a `consignmenthistory` row written with the rider's coordinates, the
`BookingAssignment` closed as `Completed` with `riderkms` (pickup → base gate) and
`ridercharges`, and the rider returned to `Available`. Before this, an intercity
rider's every job reported zero distance and zero value on `/miler/earnings`.
Errors: `CONSIGNMENT_NOT_FOUND` (404), `CONSIGNMENT_NOT_ASSIGNED` (403),
`HUB_REQUIRED` / `HUB_NOT_FOUND` (400/404), `INVALID_STATE` (400) for a parcel
already out for delivery or past this leg.
### 27 — `next_action` and `next_hub` on the queue read
Every row of `GET /miler/bookings` now carries both, derived from server state on
each read by the same helper pickup-complete uses — the pivot's answer and the
poll's answer cannot drift.
| consignment state | `next_action` | `next_hub` |
|---|---|---|
| no consignment yet | `pickup` | null |
| `Created` | `inward_at_hub` | the base |
| `Collected_By_Miler` | `start_delivery` | null |
| `Out_for_Delivery` | `deliver` | null |
| `Inwarded_at_Hub` | `handed_to_hub` | null |
| anything terminal | `none` | null |
`GET /miler/consignments/:consignmentid` carries the same pair, plus
`can_inward_at_hub` and `inwardedat`, so a single-parcel refresh is as
authoritative as a full poll.
### 28 — `pickup_source_type` on the booking row
On the row, never on a location master — a customer-door pickup has no location
id at all, so a type held against locations could never classify one.
```jsonc
{
"bookingid": 4821,
"pickup_source_type": "hub", // hub | customer | merchant | store
"sourceid": 1, // null for a customer door
"pickuplocationid": 1, // alias, same value
"pickup_source_name": "Coimbatore Hub",
"pickupaddress": "14 Avinashi Road, Peelamedu, Coimbatore",
"pickuppincode": "641004",
"deliverypincode": "600001"
}
```
Sent on every booking, with `"customer"` as a value rather than an omission.
`pickuplocationid` on this row is the source id — not the `pickuplocationid`
column on `pickupbookings`, which foreign-keys to `appcustomerlocations` and is a
different concept. The booking row never carried either spelling before, so
nothing is being redefined out from under a reader.
Storage is the new `pickupbookings.pickupsourcetype` column, written at creation.
Rows created before it existed are classified on read: names a base → `hub`,
names a client site → `merchant`, otherwise → `customer`. A stored value always
wins. An unrecognised type is dropped at write rather than stored, so the column
never holds a word the app has no meaning for.
### 29 — base master data
`Hub` already carried all six fields; what was missing was a route a rider token
could read. `/admin/tenants/:id/locations` is a different dataset — a client's own
sites, not bases — and `/admin/*` requires roles 1/3/4 while a rider is role 5, so
that 401 is by design, not an oversight.
```
GET /miler/bases ?status=Active (default) &applocationid=
```
Returns `{id, name, address, pincode, latitude, longitude}` per base, plus
`distance_km` and nearest-first ordering when the rider has reported a position.
`GET /admin/hubs` (console) already returns full hub rows and is unchanged.
### 30 — inbound visibility and receiving
```
GET /hub/inbound/expected on the way in, not yet handed over
POST /hub/inbound/:id/reconcile { "received": true|false, "remarks": "..." }
```
`expected` lists consignments on `Created` whose current base is this one —
rider, source and source type, customer, pickup and destination address,
destination pincode, current state, `inbound_status: "expected"`. Tenant-scoped:
partner-tenant staff see only their own client's parcels
(`scopeConsignmentsToOwnTenant`, the consignment counterpart of the existing
booking scoping).
`reconcile` is the receiving side. `received: true` inwards the parcel and is
idempotent — staff working through a pile will hit rows twice. `received: false`
is the dispute path: it does **not** quietly move the parcel backwards, it raises
an open `ConsignmentException` naming the discrepancy, so a parcel a rider swears
was handed over and staff never saw becomes a tracked item rather than an
argument nobody owns.
The exception type is `Lost` — the closest value the `consignmentexceptions`
CHECK constraint already permits. A dedicated `Handover_Not_Received` type would
need that constraint widened first (see `DEV_ONBOARDING.md` §2.5).
The pre-existing console inwarding path (`POST /hub/bookings/:id/inbound`) still
works and now stamps `inwardedat` too.
### 31 — the routing decision on booking detail
`GET /admin/bookings/:id` keeps every field it returned and adds `routing`
alongside them:
```jsonc
"routing": {
"pickup_source_type": "customer",
"pickup_source_id": null,
"pickup_source_name": "Anitha R",
"from_address": "12 Race Course Road, Coimbatore",
"from_pincode": "641018",
"to_address": "44 Mount Road, Chennai",
"destination_pincode": "600002",
"is_hyperlocal": false,
"consignment_state": "Created",
"next_action": "inward_at_hub",
"next_hub": { "id": 1, "name": "Coimbatore Hub", ... },
"inwardedat": null,
"decided": true
}
```
`decided` is false before pickup, when the routing result is a projection from
the captured from/to rather than a decision that has been taken. `is_hyperlocal`
is computed by the same helper pickup-complete uses, so the shown reason cannot
disagree with the actual routing.
---
## Route sequencing knows about the base
`internal/routing` orders a rider's active stops via the Route Optimization API.
It read `pickupbookings.deliverylatitude` for every assignment, with no idea
whether the parcel was hub-routed — so a Coimbatore → Chennai booking told the
optimizer the rider was riding 430 km to the receiver, when the real next stop is
a base a few kilometres away. One such destination in a rider's set also drags
the ordering of every genuine local stop beside it, because the solver is
optimising a journey nobody is going to make.
`dropForLeg` now decides where THIS rider's leg ends: the receiver for a
hyperlocal parcel, the base for a hub-routed one. The base comes from the same
order of preference as `resolveHandoverHub` — the booking's `nearesthubid` if
anything set it, otherwise the rider's own base — joined in by the stop query. A
hub-routed stop with no usable base coordinates is left unsequenced rather than
pointed at the receiver: one missing stop is better than a skewed route.
The final destination is not lost. It is simply not this leg — it belongs to
whoever carries the parcel out of the base.
**`internal/legs`** exists for this. The hyperlocal rule is needed by
`controllers` (which state a consignment lands in) and by `internal/routing`
(where the leg ends), and `controllers` already imports `internal/routing`, so
routing cannot import back. Rather than keep a second copy of the rule — the
shape that has bitten this codebase before — it lives in a package both import.
`controllers.haversineKM`, `isHyperlocal` and `isHyperlocalBooking` are now thin
delegates, so their existing call sites and tests are unchanged.
---
## Schema
Three additive, nullable columns, applied by `AutoMigrate` on the next deploy. No
CHECK constraint needed widening — `Created` was already permitted on
`consignments`.
| Table | Column | Why |
|---|---|---|
| `pickupbookings` | `pickupsourcetype varchar(20)` | request 28 |
| `pickupbookings` | `pickuphubid int` | base-origin pickups; distinct from `nearesthubid`, which is the base a parcel is routed **to** |
| `consignments` | `inwardedat timestamp` | the physical-receipt fact, distinct from `updatedat`, which moves on every write |
---
## Still open on this line
- **15** — `reached` persists the arrival fact (`arrivedat`, returned as
`reachedat` on the booking row) but the booking status does not move to
`Arrived_At_Pickup`. Half done; not touched by this work.
- **16** — console rendering for `Arrived_At_Pickup` and `Collected_By_Miler`.
- **14** — a failed-delivery outcome; `skip` still leaves the consignment
`Out_for_Delivery`.
- **`At_Customer` — answered.** It means **arrived at the pickup**. It is a
`milerprofiles.availabilitystatus` value, not a booking or consignment state,
so it says where the *rider* is rather than where the *parcel* is, and the only
thing that writes it is `POST /miler/bookings/:bookingid/reached`
(`BookingReachedCustomer`, `milerController.go`) — the pickup-arrival action.
Nothing sets it on a delivery leg; a rider heading to a receiver goes
`On_Delivery`. The name is misleading and predates the current lifecycle.
- **`reject` — answered.** `RejectMilerAssignment` accepts the reason **either
way**: it parses the JSON body first and falls back to `?reason=`, defaulting to
"Rejected by rider" if neither is present. The doc/deployed disagreement was
settled by accepting both, so the app can keep sending both.

View File

@@ -1,6 +1,6 @@
# Doormile Miler App — API reference
The rider-app surface only (`/miler/*`). 38 routes: 3 auth + 35 authenticated.
The rider-app surface only (`/miler/*`). 45 routes: 3 auth + 42 authenticated.
Base URL `https://api.doormile.com/api/v1`.
This supersedes "Miler App API Contract v1.0" where the two disagree — several
@@ -126,6 +126,44 @@ prefix it's hyperlocal and the consignment goes straight to `Out_for_Delivery`
in the rider's hands. Otherwise it routes via the hub. The consignment inherits
the **booking's** tenant, not the rider's.
It returns `next_action` and, when the next leg is a base, `next_hub` with all
six fields (`id, name, address, pincode, latitude, longitude`) — the app never
picks a base itself. `consignment_id` is always present.
```jsonc
{ "consignment_id": 4821, "consignmentstatus": "Created",
"next_action": "inward_at_hub",
"next_hub": { "id": 1, "name": "Coimbatore Hub",
"address": "14 Avinashi Road, Peelamedu, Coimbatore",
"pincode": "641004", "latitude": 11.0272, "longitude": 76.9905 } }
```
## The base handover
```
POST /miler/consignments/:id/inward-at-hub Idempotency-Key supported
{ "hub_id": 1, "latitude": 11.0272, "longitude": 76.9905 }
→ { consignmentstatus: "Inwarded_at_Hub", inwardedat, hub, next_action,
already_inwarded }
```
The authoritative record that a rider handed a parcel in at a base. The body is
optional (it defaults to the base the parcel is routed to); `hubid`, `lat` and
`lon` are accepted as aliases. Answers with the resulting state, never a bare
200. A parcel already inwarded answers 200 with `already_inwarded: true`.
```
GET /miler/bases ?status=Active &applocationid=
```
Base master data on a rider token — the six fields per base, plus `distance_km`
and nearest-first ordering once the rider has reported a position.
`/admin/tenants/:id/locations` is a different dataset (a client's own sites) and
is closed to role 5 by design.
**Wording:** the wire says hub, the rider app says Base. Full contract and state
transitions in [`logistics-base-handover.md`](logistics-base-handover.md).
## Delivery
| Method | Path | Body |
@@ -151,6 +189,20 @@ the **booking's** tenant, not the rider's.
| GET | `/miler/bookings` | `?status=&date=YYYY-MM-DD` |
| GET | `/miler/earnings` | `?period=daily\|weekly\|monthly&date=YYYY-MM-DD` |
Every `/miler/bookings` row carries the leg and the pickup source, rebuilt from
server state on each read, so a poll or a cold restart needs no local cache:
| Field | Values |
|---|---|
| `next_action` | `pickup`, `inward_at_hub`, `start_delivery`, `deliver`, `handed_to_hub`, `none` |
| `next_hub` | the six base fields, or null when the next leg isn't a base |
| `pickup_source_type` | `hub`, `customer`, `merchant`, `store` — always sent, `customer` is a value not an omission |
| `sourceid` / `pickuplocationid` | the base or client-site id; null for a customer door |
| `pickup_source_name` | the base/site name, or the sender's name for a door pickup |
An unrecognised `pickup_source_type` should be treated as a generic pickup — new
values may be added.
`bonuspoints` stays zero — nothing writes it yet. That's known and deliberate.
## Telemetry (Redis-backed, high frequency)
@@ -235,4 +287,9 @@ build a UI that depends on it.
decided.
2. `bonuspoints` is never written.
3. `assignments/:id/reject` and `bookings/:id/vehicle-required` have never had a
real request against them.
real request against them. (`reject` accepts its reason in the body *or* as
`?reason=`, preferring the body — both spellings are honoured.)
4. `At_Customer` on `milerprofiles.availabilitystatus` means **arrived at the
pickup** — it is written only by `POST /miler/bookings/:bookingid/reached`.
The name predates the current lifecycle; a rider heading to a receiver is
`On_Delivery`.