updates on the api endpoints on the customer page and more
This commit is contained in:
271
docs/DEV_ONBOARDING.md
Normal file
271
docs/DEV_ONBOARDING.md
Normal 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.
|
||||
360
docs/logistics-base-handover.md
Normal file
360
docs/logistics-base-handover.md
Normal 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.
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user