Files
doormile_backend/CLAUDE.md

660 lines
36 KiB
Markdown
Raw 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 — Full Project Memory
Portable context for a fresh Claude session working on this project on any
machine or account. Covers the **whole** Doormile system, not just one slice
of it. Read fully before touching the codebase.
A note on provenance: sections marked **[verified this session]** were
confirmed by directly reading this repo's code. Sections marked **[carried
forward]** come from Suriya's own prior-session notes and have NOT been
independently re-verified against source — treat them as reported state, not
confirmed state, until checked.
---
## 1. What Doormile is
Suriya is building **Doormile**, a real commercial logistics platform for the
Indian market (Coimbatore, Hyderabad, Bengaluru, Chennai) — hub-based parcel
courier/delivery, not a prototype. Treat everything as production-grade from
the start. Stakeholders: Doormile's own ops team, partner tenants (client
logistics/travel companies), hub staff, milers (delivery riders), and end
customers.
**Suriya's standing preferences** (apply without being asked):
- Direct, honest technical assessments over diplomatic framing. If something
isn't done, say so plainly. Don't declare victory early.
- Production-grade code from the start, not "refactor later."
- **Warn before any consequential server/schema change** before making it.
- Concise answers — don't over-explain.
- Once a decision is made, don't keep re-asking for confirmation on the same
scope — proceed and report back. But *do* ask when a decision is genuinely
ambiguous or touches money/data-correctness in a way that can't be safely
guessed.
- Minimal-effort, highest-leverage fixes over big rewrites, until he
explicitly asks for the big rewrite.
- Works across multiple Claude sessions/machines simultaneously, treating
Claude as a co-architect across the full stack — this file exists so any
of those sessions can pick up full context.
---
## 2. System architecture — the whole stack
**[carried forward, substantial infra reported as built and verified in
prior sessions]**
- **Backend**: Go + Fiber, deployed on **Kubernetes**, at `api.doormile.com`.
220 registered routes **[verified 2026-09-02, exact count]** — see §7.
This is the primary booking/assignment API and the primary trigger for
miler assignment, calling the AI decision layer with a 5-second timeout
fallback so a slow AI response never blocks a booking.
- **AI dispatch layer**: a Python autonomous agent swarm (8 agents, **JARVIS**
as master orchestrator) on a dedicated server, connected to NATS
JetStream. The `DispatchAgent` operates as a NATS *watcher*, not the
assignment trigger — the Go backend triggers assignment directly (see §4);
the agent swarm reasons about it, doesn't gate it.
- **AI decision engine**: `routemate.workolik.com` — runs Claude Sonnet for
miler-assignment reasoning, scoring candidates using hub load, on-time
rate, and hub capacity. Uses pgvector-backed RAG memory with local
embeddings (`all-MiniLM-L6-v2`, 384-dim) specifically to avoid ongoing
per-call API cost. **[verified this session]**: the Go side of this is
`models.AgentDecision` (`agentdecisions` table) with a
`context_embedding vector(1536)` column and an ivfflat index (raw SQL in
`migrations/migrate.go`, not GORM AutoMigrate) — note the dimension
mismatch worth flagging (1536 in the Go model/migration vs 384 for
MiniLM); worth confirming which embedding model is actually populating
that column before trusting vector search quality.
- **Event bus**: NATS JetStream, 6 persistent streams, dedicated server.
**[verified this session]**: `db.Js` is the package-level JetStream handle;
convention across the codebase is `if db.Js != nil { ... }` and warn-log
on publish failure rather than failing the request — publishing is
best-effort, never blocking. Confirmed publish call sites include
`booking.assigned` (from `internal/assignment`) and NATS publishes inside
`AssignMilerToBooking` (`controllers/booking_assignment_service.go`).
- **Data stores**: PostgreSQL + pgvector 0.7.4, dedicated server, 37 tables
under GORM AutoMigrate **[verified this session, exact count from
`migrations/migrate.go`]**. Redis (`go-redis/v9`) for high-frequency
ephemeral state — GPS pings, live miler status/location (GEO-indexed at
`milers:locations`, used by `queryNearbyMilers` for assignment radius
search), booking cache. Durable business state always lands in Postgres;
Redis is never the system of record for anything that needs to survive a
flush.
- **Ingress/infra**: Kubernetes, Traefik, nginx.
---
## 3. The client-facing surfaces
Five distinct front doors into this system. Only the backend API surface for
each has been directly inspected this session (via `routes.go`); the actual
client codebases (Flutter, React) have not been opened in this session.
1. **Customer app (B2C)** — Flutter, being replaced: `doormile_customer_app`
(PIN auth, single-destination bookings) is retired in favour of
`doormile_cx`. Backend surface rebuilt 2026-09-05 to the Customer App v1
contract: **28 customer routes** (`customer`/`customerAuth` groups) — OTP
auth with refresh, serviceability/slots/limits, place proxy, fare estimate,
multi-destination pickups, per-order tracking, push devices. See §8.5. Auth
is a 4-digit OTP to phone or email, NOT Firebase and NOT the miler PIN flow;
the SMS gateway is still unplugged, which is the same wall §9 describes.
2. **Miler app** — Flutter, for delivery riders. Backend surface: 38 routes
**[verified this session]** (`miler`/`milerAuth` groups) — duty
start/stop, GPS pings, assignment accept/reject/cancel, delivery
complete/skip, break logs, support tickets. This session added
`ResetMilerPin`, `MilerCancelAssignment`, `MilerSkipDelivery` to close
gaps found against the old Nearle rider app (§8).
3. **Admin console** — React, converted from the old `NearlExpress`
/`doormile_express_console` codebase. Backend surface: 89 routes
**[verified this session]** (`admin`/`adminAuth` groups) — bookings,
partner management, reports, pricing, express (formerly "CRM") bookings.
This session added partner CRUD, bulk booking create/cancel, reports,
password change, miler notify (§8).
4. **Hub console** — React, separate from the admin console. Backend
surface: 31 routes **[verified this session]** (`hub`/`hubAuth` groups)
— per-hub booking queues, tripsheet building, manual/batch assignment,
hub messaging. Auth is separate from admin (`middlewares.HubStaffAuth`,
sets `c.Locals("hubid")`). This session added tenant-scoping (closed a
real cross-tenant leak) and `HubBatchAssign` (§8).
5. **CRM (field sales)** — a genuinely separate feature from "CRM bookings"
(see §8 naming note). `POST/GET /crm/clients`, `GET/PUT/DELETE
/crm/clients/:id` — 5 routes, all open/no-auth by design: field reps
register leads from the Flutter side, the web console reads without a
separate CRM login. Models: `DoormileClient`, `DoormileAuth`. **Not
deeply audited** — only the route registration was read this session.
Total: 200 routes = miler 38 + admin 89 + hub 31 + customer 19 + crm 5 +
internal 5 + redisUsers 4 + bookingCache 3 + 2 top-level pricing routes +
websocket routes. **[verified this session]**
---
## 4. AI dispatch & assignment — how a booking actually gets a miler
**[verified this session]** — read directly from
`internal/assignment/crm_assignment.go` and `booking_assignment_service.go`.
- Two entry points, same core: `AssignCustomerMiler` (B2C bookings) and
`AssignCRMMiler` (express/console-originated bookings — name predates this
session's "CRM"→"express" rename, deliberately left unrenamed since it's a
working assignment engine, not just a label). Both are fire-and-forget
goroutines called after transaction commit, retrying up to 5 times, 2
minutes apart (~10 minutes total), before giving up and publishing a
failure event (`publishAssignmentFailed`, `reasonNoMilerAvailable`) to NATS
so the failure isn't silently invisible.
- `TryAssignOnce` is the synchronous single-attempt variant — used when a
hub staff member manually triggers "auto-assign" from the console and
needs an immediate answer rather than the ~10-minute retry loop. Returns a
rich `AutoAssignResult` (assigned/escalated, miler name, distance, AI
reasoning text, candidates found) for the UI to show.
- Core flow (`tryAssign`): Redis `GEOSEARCH` on `milers:locations` (10km
radius, top 10, sorted nearest-first) → `selectMilerWithAI` (the actual
Claude Sonnet call to `routemate.workolik.com`, logs an `AgentDecision`
row with reasoning + optional embedding) → `commitAssignment` (single DB
transaction: create `BookingAssignment`, update `PickupBooking.status` to
`BookingMilerAssigned`, flip `MilerProfile.availabilitystatus`) → publish
`booking.assigned` to NATS (best-effort) → push notifications to miler and
customer.
- `HubBatchAssign` (**built this session**, `controllers/hubController.go`)
is a separate, simpler path: a greedy nearest-available-rider heuristic
(haversine distance, no AI call, no retry loop) for clearing a hub's
pending-pickup queue in one batch call, capped per rider (default 5),
reusing `AssignMilerToBooking` for the actual transactional assignment.
**Explicitly not** a replacement for `selectMilerWithAI`'s reasoning, and
**not** a multi-stop route/VRP solver — see the routing gap below.
**Logistics/routing — what exists vs what doesn't:**
- What exists: hub network (`Hub` model, `originhubid`/`currenthubid`/
`destinationhubid` on bookings/consignments for tracking a parcel's hub
path), `Tripsheet`/`TripsheetItem` for hub-to-hub batch transport, the
nearest-rider assignment logic above, `HubBatchAssign`'s greedy queue
clearer.
- What does **not** exist in Doormile yet: any multi-stop route sequencing /
vehicle-routing-problem solver. Nearle used external paid services
(`routes.workolik.com`, and `routemate.workolik.com` was itself originally
a Nearle-side concept) for that; nothing in Doormile replaces true
stop-sequencing optimization today. `HubBatchAssign` only decides *who*
gets which single booking, not *what order* a rider should run multiple
stops in. Flag this clearly if asked whether routing is "done" — it isn't,
by design, pending a real conversation about whether it's needed yet.
---
## 5. Data model reference (`models/*.go`)
**[verified this session]**
- `PickupBooking` (`pickupbookings`) — customer's pickup request, pre-hub.
Has `Tenantid *int` (**added this session**, see §8). `Bookingsource`:
`"Customer_App"` (B2C) or `"CRM_Console"` (console-created — deliberately
left as `"CRM_Console"` even though the outward API/route naming was
changed to "express", see §8).
- `BookingParcel`, `BookingServiceOption`, `BookingPayment`,
`BookingAssignment` (status: Assigned/Accepted/Rejected/Reassigned/
Completed/Cancelled, carries `AgentDecisionID *uint64` linking back to the
AI reasoning that produced it), `BookingVehicleRequirement`.
- `Consignment` (`consignments`) — the shipment once picked up. Has its own
`Tenantid int` (not nullable, pre-existing). `Attemptcount int` (used by
`MilerSkipDelivery`, added this session). `ConsignmentHistory` (event
log), `ConsignmentException` (Lost/Damaged/Misrouted/Receiver_Refused/
Missing_Contents/Undeliverable).
- `Hub`, `Vehicle`, `Tripsheet`, `TripsheetItem`, `DeliveryProof`. `Hub` is what
the rider app calls a **Base** — same row, different word (see §12).
- `AppUser` (`appusers`) — shared login table for staff/miler/admin roles
(`Roleid`: 1 admin, 3 manager, 4 rep/exec, 5 miler, 6 hub staff via a
separate `HubStaffAccount` table). `MilerProfile` — actual rider profile
(availability status, current lat/lng, rating). `MilerDutyLog`,
`MilerBreakLog`, `MilerSupportTicket`.
- `AppCustomer`/`AppCustomerLocation` — B2C app customers (separate from
legacy `Customer`/`CustomerLocation` kept for backward compat — flagged as
a future duplicate-data risk, not resolved).
- `HubStaffAccount` — `Tenantid *int`: **nil = Doormile staff (sees
everything)**, **set = partner-tenant staff (own tenant's data only)**.
Load-bearing distinction — see the tenant-leak fix in §8.
- `PartnerInfo` (`partnerinfo`) — fleet/vehicle-supplying partner companies.
**Distinct concept from `Tenant`** (`tenants` — client companies Doormile
delivers *for*). Confusingly close names, flagged, not acted on.
- `DoormileClient`/`DoormileAuth` — the real, separate CRM feature (§3.5),
not to be confused with "CRM bookings" (renamed to "express bookings" this
session specifically to end that naming collision).
- `Pricing`, `DoormilePricing`, `CompetitorBranch`, `CarrierPricing` —
pricing engine + competitive intel.
- `AgentDecision` — AI dispatch-reasoning log (§4), pgvector
`context_embedding` column.
- Redis-only ephemeral structs (`models/redis.go`): `MilerLog`,
`MilerStatus`, `ConsignmentLog`, `CachedUser` — never durable state.
---
## 6. Current environment / seeded data state
**[carried forward, not re-verified this session]**
- Database seeded with 16 hubs (4 per city across the 4 target cities), 10+
milers, 10 tenants (4 Doormile-ops-owned + 6 named partner tenants), 5 hub
staff accounts, 55+ pricing rules.
- Credentials on file from prior sessions (values not repeated here —
confirm current values rather than assuming): admin login at
`suriya@doormile.com`; hub staff accounts pattern `hub.[city]@doormile.in`
plus partner variants; miler test phone numbers + PINs.
- Auth: Firebase OTP for customer login — this cannot be scripted/bypassed
from the command line, which is why the last live E2E test stalled (§9).
---
## 7. Codebase conventions (read before writing any Go here)
**[verified this session]**
- **Response helpers** (`utils` package): `utils.OK(c, data)`,
`utils.Created(c, data)`, `utils.Message(c, "text")`,
`utils.List(c, slice, total)`, `utils.Paginated(c, slice, total, page)`,
`utils.BadRequest/NotFound/Internal/Unauthorized/Forbidden/Conflict(c,
msg)`. Always use these, never hand-roll `c.JSON`.
- **DB**: `db.DB` is the package-level `*gorm.DB`. `db.Rdb` is Redis
(`*redis.Client`, `go-redis/v9`). `db.Js` is NATS JetStream (nil-check
before publishing, warn-log on failure, never fail the request over it).
`db.Ctx` for Redis calls needing a context.
- **Auth**: `c.Locals("userid")` (int), `c.Locals("tenantid")` (int, only
set via `middlewares.AuthMiddleware` — the *requesting user's own*
tenant, not necessarily the resource's tenant; conflating these two was
exactly the bug fixed this session, §8). Hub staff auth is separate
(`middlewares.HubStaffAuth`), sets `c.Locals("hubid")` and
`c.Locals("userid")` = `hubstaffaccountid`.
- **DTOs**: `dto/*.go` (`admin.go`, `auth.go`, `booking.go`, `client.go`),
one struct per request shape.
- **Constants** (`constants/constants.go`): every status enum lives here as
typed string constants. Always use these, never inline string literals.
- **Soft delete**: only some models have `Deletedat *time.Time` (`Hub`,
`Vehicle`, `Consignment`, `Tripsheet`, `TripsheetItem`,
`ConsignmentException`, `DoormilePricing`, `CarrierPricing`). Others
(`PartnerInfo`, `Tenant`) have no soft-delete column — hard `Delete`.
Check the struct before assuming either way.
- **Assignment logic reuse**: `AssignMilerToBooking(bookingID,
milerUserID int, assignedByUserID *int)` in
`controllers/booking_assignment_service.go` is the one transactional path
for "assign this miler to this booking." Reuse it, don't reimplement —
both `HubAssignMiler`/`AdminAssignMiler` and `HubBatchAssign` call it. The
AI-driven `commitAssignment` in `internal/assignment` is a separate
transactional writer used by the auto-assignment retry path — don't
conflate the two; know which one a given call site needs.
- **Distance calc**: `haversineKM(lat1, lon1, lat2, lon2)` defined once in
`hubController.go`, used package-wide. Don't redefine it.
- **Hub tenant scoping**: `scopeBookingsToOwnTenant(c, query)` in
`hubController.go` (**added this session**) — restricts a bookings query
to the requesting hub staff's own tenant if partner-scoped, no-ops for
Doormile staff. Use on any new hub-console bookings query.
- **Go toolchain was unavailable in the sandbox that wrote this session's
changes.** Every change was verified by hand (field/column names
cross-checked against real structs, brace-balance via `grep -o "{" |
wc -l` vs `}`) at write time. **[verified separately, on Suriya's own
machine]**: `go build ./...` and `go vet ./...` were both run afterward
and passed clean (only pulled two missing indirect modules,
`tinylib/msgp` and `philhofer/fwd`). This was the first actual compiler
verification of this code — commit `c272a33`, pushed to `origin/main`.
---
## 8. The Jupiter → Doormile migration (this session's scoped work)
This section is what one specific session did: closing API/schema gaps
between the legacy Nearle ("jupiter") system and the new Doormile backend,
for the **rider app and console specifically** (hub console gap-closure was
also done incidentally while fixing the tenant leak, but wasn't the primary
target).
### 8.1 Why jupiter/Nearle was being replaced
Legacy Go+Fiber backend (`backend_jupiter`, module `nearle`), Postgres,
Redis, base URL `jupiter.nearle.app` (+ a separate write path
`queue.workolik.com` with TLS verification disabled and a hardcoded IP
pin). Consumed by a Flutter rider app and a React admin console
(`doormile_express_console` — literal ancestor of the current Doormile admin
console). Found to be structurally broken on live-DB analysis:
- `riderlogs`: 1.17M rows, 643MB, **zero indexes**, 68M lifetime UPDATEs vs
762K inserts, caused by an unindexed `UPDATE ... WHERE userid=?` rewriting
~97K rows per call — the real root cause of read timeouts previously
blamed on Redis.
- Every table had only its primary key indexed; `orders`/`deliveries` never
autovacuumed.
- `getdeliveries` returned every row **21×** (unconstrained `LEFT JOIN
tenantpricing`, `DISTINCT` over 87 columns that didn't dedupe anything).
- `createdeliveries` had a quadratic insert bug (slice declared outside a
loop kept accumulating) — confirmed live: 66,446 deliveries → 132,826
`deliveryqueues` rows (~2×) with duplicate `deliveryid`s.
- v2 endpoints wrote **only to Redis**, invisible to v1/v3 Postgres reads —
genuine split-brain, with a Redis `INCR` ID space independent of the
Postgres sequence (collision risk).
- `orders` had 75 columns (~20 never populated once across 137K rows).
`deliveries` had 92 columns, six lat/lng pairs for 3 real points, status
spread across 6 separate text+timestamp columns instead of an events
table.
- `PUT /deliveries/updatedelivery` was overloaded for **11 different real
actions** (8 rider status transitions + 3 unrelated console actions),
distinguished only by which JSON fields happened to be non-empty.
- The "exhaustive" API docs undercounted real usage by ~8 endpoints
(including the login endpoint itself and a whole `/v1/substitutions` CRUD
feature), found only by cross-checking against actual console source.
Doormile's new schema was already solving most of this structurally before
this session (Redis-only telemetry, real event-log tables, 37 normalized
tables with real FKs and typed columns, parcel/courier domain model instead
of retail/food-delivery shaped).
### 8.2 What was built this session (14 new endpoints)
| Method | Path | Handler |
|---|---|---|
| POST | `/miler/reset-pin` | `ResetMilerPin` |
| POST | `/miler/bookings/:bookingid/cancel` | `MilerCancelAssignment` |
| POST | `/miler/consignments/:id/skip` | `MilerSkipDelivery` |
| GET | `/admin/reports` | `GetAdminReports` |
| PUT | `/admin/profile/password` | `AdminChangePassword` |
| GET | `/admin/partners` | `GetPartners` |
| POST | `/admin/partners` | `CreatePartner` |
| GET | `/admin/partners/:id` | `GetPartnerDetails` |
| PUT | `/admin/partners/:id` | `UpdatePartner` |
| DELETE | `/admin/partners/:id` | `DeletePartner` |
| POST | `/admin/milers/:id/notify` | `AdminNotifyMiler` |
| POST | `/admin/expressbooking/bulk` | `AdminBulkCreateBookings` |
| POST | `/admin/bookings/bulk-cancel` | `AdminBulkCancelBookings` |
| POST | `/hub/bookings/batch-assign` | `HubBatchAssign` |
Plus two real bugs found and fixed incidentally while doing tenant-
separation work (not requested, found along the way):
1. **Data mis-attribution**: `BookingPickupComplete` set the resulting
`Consignment.Tenantid` from the *completing miler's own* tenant rather
than the booking's actual tenant — silently mis-attributed shipments for
any miler carrying parcels across tenants. Fixed to use
`booking.Tenantid` when set.
2. **Cross-tenant data leak**: `GetHubUnassignedBookings`/
`GetHubBookingsRange` had no tenant scoping — a partner tenant's hub
staff could see every other tenant's bookings at the same hub. Fixed via
`scopeBookingsToOwnTenant`.
Naming cleanup: `CreateCRMBooking`→`CreateExpressBooking`,
`/admin/crmbooking`→`/admin/expressbooking` (+ `/bulk`), because "CRM
bookings" collided with the genuinely separate CRM feature (§3.5).
Deliberately **not** renamed: the stored `Bookingsource: "CRM_Console"`
value and `internal/assignment/crm_assignment.go`'s `AssignCRMMiler` — a
working assignment engine and an already-written DB value, not just a
label; renaming those is a deeper change than was asked for.
Deliberately skipped: rider substitutions (`/v1/substitutions` in Nearle) —
Suriya's own call, looked low-traffic in the old system.
### 8.3 Verified vs not, for this migration slice
**Verified**: every new handler's field/column names checked by hand
against real structs; brace-balance confirmed after every edit; no
duplicate symbol definitions. **`go build ./...` and `go vet ./...` both
pass clean** (verified on Suriya's machine, not the sandbox that wrote the
code) — committed as `c272a33`, pushed to `origin/main`. The hand-verified
code compiled correctly the first time it hit a real toolchain.
**Not verified**:
1. No integration test has hit any of the 14 new endpoints — a clean
compile says the code is well-formed, not that it behaves correctly
against a real DB/Redis/NATS.
2. The `Tenantid` migration hasn't executed against a real DB yet — will
run automatically via `AutoMigrate` next deploy (additive, nullable,
safe).
3. **Nothing on the client side has changed.** The rider Flutter app and
`doormile_express_console` still call `jupiter.nearle.app`. API coverage
existing on Doormile does not mean traffic is using it. Not a
flip-a-URL cutover either — response shapes are completely different
(flat 87-column Nearle rows vs nested Doormile JSON) — every screen that
parses a response needs rewriting, not just repointing.
---
## 8.4 Logistics pickup-source & base-handover flow (2026-09-02)
**[verified this session]** — closes requests 25–31 on the Miler logistics line.
Full contract, state-transition tables and wire values:
[`docs/logistics-base-handover.md`](docs/logistics-base-handover.md).
**Vocabulary.** The wire says *hub*; the rider app renders it as *Base*. Never
change a wire value to match the app's wording: `inward_at_hub`,
`Inwarded_at_Hub`, `next_hub`, `pickup_source_type: "hub"` stay exactly as spelt.
**Feature flag `MILER_HUB_HANDOVER_ENABLED`** (default **off**, read per request,
same pattern as `MILER_COLLECTED_STATE_ENABLED`). On, a hub-routed parcel stops
at `Created` at pickup-complete and only reaches `Inwarded_at_Hub` when the
handover is recorded. Off (today), pickup-complete marks it `Inwarded_at_Hub`
immediately — which is what the deployed rider app expects. **Do not turn it on
until a rider build that calls `inward-at-hub` is live**, or every intercity
parcel strands on `Created` with no way to advance it. Everything else in this
work is ungated.
**New endpoints (4):**
| Method | Path | Handler |
|---|---|---|
| POST | `/miler/consignments/:id/inward-at-hub` | `MilerInwardConsignmentAtHub` |
| GET | `/miler/bases` | `MilerGetBases` |
| GET | `/hub/inbound/expected` | `GetHubInboundExpected` |
| POST | `/hub/inbound/:id/reconcile` | `ReconcileHubInbound` |
**New columns** (additive, nullable, `AutoMigrate`; no CHECK constraint needed
widening — `Created` was already permitted on `consignments`):
`pickupbookings.pickupsourcetype`, `pickupbookings.pickuphubid`,
`consignments.inwardedat`.
**Conventions added — reuse these, don't reimplement:**
- `renderBase(hub)` (`controllers/logisticsHandoverController.go`) is the ONE
shape a base is returned in — all six fields, everywhere. A test enforces the
count, because five of six leaves a rider unable to navigate.
- `nextActionForConsignment(status)` is the ONE definition of what a rider does
next. pickup-complete, the queue read and the consignment read all call it, so
a poll can never disagree with the pivot.
- `resolveHandoverHub(booking, riderHubID)` decides which base a parcel goes to.
Backend decides; the app never picks a base.
- `pickupSource(booking, customerName)` resolves type/id/name/address for any
booking row, in the miler queue, the hub dispatch board and the admin detail.
- `scopeConsignmentsToOwnTenant(c, query)` (`hubInboundController.go`) is the
consignment counterpart of `scopeBookingsToOwnTenant` — use it on any new
hub-console consignment query.
**Two pre-existing bugs fixed in passing:** a hub-routed pickup left its
`BookingAssignment` open forever, so the rider could never go off duty
(`MilerEndDuty` refuses while any assignment is Assigned/Accepted); and the
no-rider-hub fallback took whichever hub row an unordered query returned first,
now nearest-active-base by haversine.
**Not verified:** no integration test has hit the 4 new endpoints; the migration
has not run against a real DB. `go build`, `go vet` and `go test ./...` all pass.
---
## 8.5 Customer app v1 — the `/customer/*` rebuild (2026-09-05)
**[verified this session]** — implements *Doormile — Backend Requirements
(Customer App v1)* for the new `doormile_cx` Flutter client. Full contract,
decisions and the written answers to the requirement doc's open questions:
[`docs/customer-app-api.md`](docs/customer-app-api.md). Spec:
[`docs/openapi-customer.yaml`](docs/openapi-customer.yaml).
**The structural change: a customer books a PICKUP, not a shipment.** One
booking → 1..N destinations → one consignment and one tracking number per
destination, minted when the miler completes the pickup. `pickupbookings`
carried exactly one delivery address in its own columns, so there was nowhere to
put a second; `bookingdestinations` is what closes that.
**The compatibility rule that makes it safe — do not break it:** destination 0
is mirrored onto the booking's flat `delivery*` columns. The miler app, the hub
console, the routing code and the hyperlocal check all read those columns and
none of them changed. A booking with **no** destination rows (every
console/express booking, every pre-existing row) produces exactly one
consignment through the same loop, byte-for-byte as before. Single-destination
is one leg, never a special case.
**Two prior surfaces were replaced, on Suriya's call (2026-09-05).** The PIN
auth (`/customer/register|login|verify-pin|reset-pin`, plus the email-OTP pair)
and the single-destination booking create/list/detail/cancel/price and
`/customer/track/:trackingno` are gone — `doormile_customer_app` is being
retired in favour of `doormile_cx`. `controllers/otpController.go` was deleted
with them. Customer routes: 19 → 28.
**Identifier formats changed platform-wide.** `generateBookingNo()` now mints
`DM-######` and `generateTrackingNo()` mints `DMX########`, both off Postgres
sequences (`cx_booking_reference_seq`, `cx_tracking_seq`, created in
`migrations/migrate.go`). The old generators used four random bytes; both
columns are `UNIQUE` and a random short id collides long before the space runs
out. Existing rows keep their `DM-BK-`/`DM-TRK-` strings — nothing parses either
format, so the two coexist and the console just shows the new one for new work.
### Conventions added — reuse these, don't reimplement
- **`utils.CxOK` / `CxCreated` / `CxList` / `CxFail`** (`utils/response_cx.go`)
are the ONLY response helpers for `/customer/*`. Deliberately separate from
`utils.OK`/`Fail`: the customer contract always sends `message` (empty on
success) and nests the code under `error.code`, while miler/console put `code`
at the top level. Never mix them on one surface.
- **`utils.EpochMillis(t)`** (`utils/epoch.go`) is the ONLY way a timestamp
leaves `/customer/*`. This DB stores IST wall-clock digits (see `DBNow`), so
`t.UnixMilli()` is off by 5h30m — the same defect that produced "yesterday's
work shown as today" on the miler app. `utils/epoch_test.go` asserts it for
both taggings the driver can produce.
- **`internal/cxstage`** is the ONE place a customer stage is written. `Record`
takes the caller's `*gorm.DB` — a stage event must commit or roll back with
the operational write it describes. It dedupes per (booking, destination,
stage), and `Notify` fires only after commit.
- **`renderCxBooking` + `loadCxBundle`** (`controllers/cxBookingView.go`) build
the canonical booking object. Every read that returns a booking goes through
them; `loadCxBundle` is a fixed number of queries regardless of page size.
- **`cxDestinationForConsignment(id)`** resolves a consignment to its booking.
Use it instead of `WHERE consignmentid = ?` on `pickupbookings` — that column
names only the FIRST order of a multi-destination pickup (see the bugs below).
- **`cxPickupLegs(tx, booking)`** splits a booking into the journeys to create
at pickup-complete. It is what decides single-vs-fan-out; nothing downstream
needs to know which it got.
### Stage derivation (the actual work)
Nine stages, lowercase snake_case, in `constants.CxStage*`. The client parses
them verbatim and **silently falls back to `booked` on an unknown key** — never
add or rename one without a client release. A booking rolls up from its
**slowest** order once parcels split, or a customer sees "Delivered" while a
parcel is still at a hub. Nothing is backfilled: a pre-existing booking gets a
short honest history rather than an invented one.
`cxstage.Release` is the one place a stage moves **backwards** — a miler
cancelling returns the pickup to the pool rather than cancelling it, and without
walking the stage back the customer keeps seeing a rider who is not coming.
### Four pre-existing bugs fixed in passing
All the same root cause, all found because the fan-out forced every consignment
lookup to be re-read. Each would have broken multi-destination pickups outright:
1. **`MilerDeliverConsignment` could not close orders 2..N** — its ownership
check was `WHERE consignmentid = ? AND assignedmileruserid = ?` on
`pickupbookings`, so a rider delivering the second parcel of a three-stop
visit got "assigned consignment not found" and could not complete at all.
2. **`MilerStartDelivery` notified nobody for orders 2..N** — same join, so no
push and no receiver OTP.
3. **`MilerInwardConsignmentAtHub` left assignments open for orders 2..N** — the
rider could not go off duty (`MilerEndDuty` refuses on an open assignment)
and the leg's distance/earnings recorded as zero.
4. **`GET /miler/bookings` showed only the first order** — one row per booking
keyed on that same column, so the fan-out would have minted orders no rider
could see or deliver. `milerStopsForBooking` now emits one stop per order
after collection, one visit before it, and exactly one row (unchanged) for a
booking with no destination rows.
Also: **`CityGateMiddleware` was a no-op for customer bookings.** It sniffs the
body for `pickuppincode`, which the new request shape does not carry, so every
customer booking sailed past the operating-city gate. Now checked in the handler
via the exported `middlewares.PincodeInOperatingCity`.
### Blockers and gaps — state these plainly if asked
- **No SMS provider exists.** `internal/sms` is the seam (a `Sender` interface,
a logging sink, `sms.Register()`); until a gateway is plugged in, OTP codes go
to the application log and nowhere else. **This is the single blocker on real
customer sign-in** — and it is the same wall §9's E2E test hit. Staging has
`CX_STAGING_OTP` (refused when `ENV=production`), which unblocks automated
tests.
- **No integration test has hit any of these endpoints.** `go build`, `go vet`
and `go test ./...` pass; new unit tests cover the pure logic (stage rollup,
epoch conversion, phone normalisation, weight fallback). None of that proves
behaviour against a real DB/Redis/NATS.
- **The migration has not run against a real database.** Additive, so it should
be safe — but that is not the same as having run.
- **Failed delivery is invisible to the customer.** `MilerSkipDelivery` works
operationally, but there is no tenth stage key for it and an unknown key
renders as `booked`, so a failed attempt leaves the parcel showing "Out for
delivery". Needs product + a client release.
- **Latency (p95 ≤ 400ms) is unmeasured.** Reads are batched and pricing is
Redis-warmed, but that is an argument, not a measurement.
- **No retention policy** for parcel photos or PII — nothing prunes either. The
30-minute signed-URL TTL limits link lifetime, not object lifetime.
### New env vars
`GEOCODER_URL`, `GEOCODER_EMAIL` (place proxy — the app is never handed a map
key, after the legacy rider app's key had to be revoked), `MILER_CALL_PROXY`
(masked calling; empty exposes the rider's real number — **set before launch**),
`CX_STAGING_OTP`, `CX_ALLOW_STAGE_OVERRIDE`.
---
## 9. Current blockers & open work (whole-project level)
**[carried forward]**
- Last live end-to-end system test stalled on **customer JWT acquisition**
— customer login requires Firebase OTP on a real phone, can't be scripted
from the command line. Needs either a real-phone run or a load-test
workaround.
- A **load test** targeting high concurrent bookings (capacity check before
go-live) was in progress at the end of the last relevant session, not
finished.
- Go-live preparation across the 4 target cities is still ahead.
**Migration-slice-specific (§8.3)**: `go build` verification is now done
(commit `c272a33`). Still pending: integration testing of the 14 new
endpoints, running the `Tenantid` migration, and the client-app rewrites.
---
## 10. Deliberately skipped / open decisions
- **Rider substitutions** — skipped, low old-system traffic. Revisit if it
turns out to matter.
- **B2C tenant attribution** — `PickupBooking.Tenantid` stays nil for B2C
bookings; whether direct-to-consumer traffic should be attributed to one
of the 4 Doormile-ops tenants (per city) is a business decision, not
something to guess at.
- **Batch route optimization** — `HubBatchAssign` is a single-booking
nearest-rider heuristic, not a multi-stop VRP solver (§4). Untested
against real volume vs. whatever the old paid external services provided.
- **`PartnerInfo` vs `Tenant` naming confusion** — flagged, not acted on.
- **`Customer`/`CustomerLocation` (legacy) vs `AppCustomer` (new B2C)** —
two customer-shaped tables coexisting, flagged as a future duplicate-data
risk, not resolved.
- **`AgentDecision.context_embedding` dimension (1536) vs the reported
MiniLM embedding size (384)** — flagged this session (§2), not
investigated further; worth resolving before relying on vector search
quality from that column.
---
## 11. Suggested next steps, in order
1. Resolve the customer-JWT E2E test blocker (real phone or load-test
workaround) and finish the load test.
2. ~~`go build ./...` in `DoormileBackend`~~ — **done**, clean pass,
commit `c272a33` pushed to `origin/main`.
3. Stand up a test/staging DB, let `AutoMigrate` run, smoke-test the 14 new
migration-session endpoints with real requests.
4. Pick one low-risk console slice (e.g. Reports or Partner management —
net-new UI, not replacing something live) and wire it to Doormile
instead of jupiter — first real proof the cutover works end to end.
5. Only after that: rewrite the higher-traffic screens (orders/deliveries
list, rider status updates) and the rider app's request/response
handling.
6. Resolve the B2C tenant-attribution question before it's load-bearing for
real revenue reporting.
7. Confirm the `AgentDecision` embedding-dimension question.
8. Decide on substitutions and batch-optimization sophistication once real
usage data says whether they're actually needed.
9. Go-live preparation across the 4 target cities.