660 lines
36 KiB
Markdown
660 lines
36 KiB
Markdown
# 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.
|