Milers now choose their own PIN the first time they log in, instead of the
console assigning a shared default:
- CreateMiler always creates a rider with an empty Password (PIN field removed
from MilerCreateRequest); any client-supplied PIN is ignored, making
"riders set their own PIN" a backend invariant, not a console convention.
- LoginMiler returns `pin_set` so the app routes to enter-PIN vs set-PIN.
- New POST /miler/set-pin (SetMilerPin): self-service first PIN, allowed ONLY
when the account has none yet (409 otherwise, so it can't overwrite/take over
an active account), then logs the rider in. Self-service and throttle-only is
safe because of that guard; OTP-gate it once the SMS gateway is live.
- verify-pin and set-pin share issueMilerSession so the two success responses
can't drift.
Also switches BookingPickupComplete's timestamp to DBNow() (IST) so the
compatibility-flow inwardedat matches the reconciliation windows.
Existing riders keep their PIN and are unaffected; blanking their password to
move them onto self-set is a separate, deliberate DB step.
go build, go vet and go test ./... all pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
The miler app resolves a rider's service profile (hyperlocal vs logistics,
which decides whether the Start-delivery button renders) from the tenant NAME
in preference to the raw tenantid. Neither verify-pin nor GET /miler/profile
returned a tenant name — the field the app keys off did not exist. Add
resolveTenantName and include tenantname (+ tenantid) on both responses.
Including it on GET /miler/profile lets the app refresh tenantname at launch
without forcing a re-login after this rollout.
This is the precondition for safely enabling MILER_COLLECTED_STATE_ENABLED:
without tenantname, any tenant whose id the app hasn't mapped falls back to the
logistics profile (no Start-delivery button) and would strand collected
hyperlocal parcels.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
- GET /miler/bookings now returns reachedat + arrivallatitude/arrivallongitude
on every row, so the app reconstructs "Arrived" (Pickup_Scheduled + reachedat)
after a restart with no new status.
- reached records arrival as a FACT (timestamp + GPS) and no longer flips the
booking to Arrived_At_Pickup — the status stays Pickup_Scheduled, matching the
rider app's derive-from-reachedat model and dropping the console mapping need.
- New PATCH /miler/bookings/:id/addresses: partial pickup/delivery address,
pincode, coords, city correction before pickup-complete (INVALID_STATE after).
- pickupbookings gains nullable arrivedat/arrivallatitude/arrivallongitude
(AutoMigrate, additive).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
Miler app P0 + contract gaps found in the live audit:
- GET /miler/bookings now returns consignmentid + consignmentstatus on every
row (nullable), so the app can call deliver/skip/start-delivery straight from
the list. /miler/assignments is the active-only queue, so this is the
authoritative fix for stops that have moved onto the delivery leg.
- GET /miler/bookings now returns sequencedat per row: non-null means the
console/optimizer fixed this stop's order and the app follows step exactly;
null means no route assigned and the app may fall back to nearest-first.
- Route sequencing (internal/routing) now runs automatically after every
assignment — customer auto-assign, express auto-assign, manual assign, and
accept — via SequenceMilerStopsAsync (fire-and-forget, no-op below two active
stops). Previously only hub batch-assign sequenced, so most riders saw step=0.
- GET /admin/bookings now surfaces the live consignmentstatus alongside the
frozen booking status, so a Converted_To_Consignment booking can still show
Out_for_Delivery / Delivered instead of a generic "Active".
Two-step hyperlocal flow (Arrived_At_Pickup, Collected_By_Miler, start-delivery)
stays gated behind MILER_COLLECTED_STATE_ENABLED (default off) until the app
ships; consignmentid/status, GET /miler/consignments/:id, stable error codes and
Idempotency-Key handling are unconditional and safe on the current app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
Close the gaps the miler-app dev flagged against the deployed contract.
- GET /miler/bookings: return stoptype (pickup|delivery, from status),
step + road-optimized sequence (cumulativekms/etaminutes/cumulativeeta),
and codamount/paymentmode. List sorted by step, unsequenced last.
Lookups batched to avoid N+1.
- POST /miler/bookings/:bookingid/skip: pre-pickup skip that keeps the
booking assigned and resumable — the "route back" the consignment-only
delivery skip couldn't give a not-yet-picked-up booking.
- GET /miler/earnings: add cancelled_stops + total_stops for success rate.
- PUT /miler/profile: persist email (to appusers, 409 on unique clash) and
a new nullable milerprofiles.address column.
- POST /miler/assignments/:id/reject: accept reason from body OR ?reason=.
Notifications read-state and bonuspoints deliberately left as-is — both
need a product/business decision, not code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Assignment decided who carried a booking but never what order to run
several of them in -- the one capability jupiter had that Doormile did
not. It turns out we already own the solver: routes.workolik.com is a
live in-house Route Optimization API backed by Valhalla road-network
routing, not the paid third party I had assumed. This is a client for
it, not a solver.
internal/routing posts a rider's active stops and writes back step,
previouskms, cumulativekms and ETA onto BookingAssignment. HubBatchAssign
calls it after committing a batch, which is exactly the case it exists
for: a rider used to walk away with several bookings and no order to run
them in. GetMilerAssignments now returns sequenced stops in step order,
falling back to newest-first for anything unsequenced.
Contract discovered by probing the live service -- the OpenAPI schema
types the body as a bare object array, so the field names are not
documented anywhere. They are pickuplat/pickuplong/deliverylat/
deliverylong, NOT pickuplatitude/deliverylatitude. Sending the wrong
names does not fail: it returns HTTP 200 with every coordinate defaulted
to "0.0", no reordering and all distances zero. That trap is recorded in
a comment so the next person does not lose an afternoon to it.
Numeric fields come back inconsistently typed -- previouskms as a number,
actualkms and eta as strings, some decimal -- so they are decoded loosely
and coerced, with tests pinning the coercion. Steps for deliveryids we
did not send are discarded rather than written, so an echoed or stale id
cannot reorder another rider's work.
Sequencing is best-effort throughout and runs after assignments commit.
The optimizer is a separate service over the network; it being down must
leave bookings assigned but unordered, never undo the batch. Step 0 means
"not sequenced", not "first".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Caught by testing the previous commit against production: creating a booking
with a resolved site failed with
pickupbookings_pickuplocationid_fkey
FOREIGN KEY (pickuplocationid) REFERENCES appcustomerlocations(...)
pickuplocationid is the *customer's* saved address, a B2C concept. It never
referred to the client company's own kitchens or branches. The pre-existing
code that validated an incoming pickuplocationid against TenantLocation was
wrong on the same point and would have 500'd for any caller that used it — it
had simply never been called with a value.
Adds tenantlocationid to pickupbookings and consignments (nullable, indexed,
additive via AutoMigrate), carried across at pickup, and points the reporting
filter, the by_location breakdown and the Unattributed bucket at it.
The booking request accepts tenantlocationid, and still accepts
pickuplocationid as an alias so anything written against the earlier docs
starts working instead of failing.
Also gofmt on the two model files touched; booking.go was already failing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- POST /customer/reset-pin was unauthenticated and overwrote a customer's PIN
given only their phone number — which is the login identifier, not a secret —
so reset-pin followed by verify-pin took over any customer account. Exactly
the miler flaw fixed in fd7cf3e, on the B2C side. It now requires the account's
registered email to have been verified through the existing
send-email-otp/verify-email-otp flow; the verification is recorded in Redis
for 10 minutes and consumed on use, so one verification authorises one reset.
Accounts with no email on file are directed to support rather than left open.
- GET /customer/bookings/:id/price had no ownership check, unlike every other
customer booking route, so any signed-in customer could read the price quoted
on anyone else's booking by walking the id.
- GET /miler/consignments/userlogs/:userid took the rider from the URL and never
compared it to the caller, letting any miler read another miler's movement
history.
Verified as already correct while sweeping: miler assignment and booking-flow
handlers all scope by mileruserid/assignedmileruserid, customer booking detail
and cancel scope by appcustomerid, /internal sits behind InternalKeyAuth, and
CreateHubStaffAccount already refuses non-Doormile staff.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Security
- Express console had no tenant scoping at all: LoginAdmin hardcoded tenantid 0
into every JWT and none of the 85 admin handlers filtered by tenant, so any
client given a console login would read every other client's bookings,
customers, pricing and reports. Adds DoormileAuth.Tenantid (nil = Doormile
staff, unrestricted; set = client, scoped), emits it in the token, and scopes
reads, guards writes and pins tenantid on create.
- Miler telemetry (/miler/logs, /miler/status, /miler/consignments/logs) took
userid from the request body, letting any authenticated rider write another
rider's status and GPS trail — data the dispatch layer reasons over. Identity
now comes from the token.
- POST /miler/reset-pin was unauthenticated and overwrote a PIN given only a
phone number, so reset-pin + verify-pin took over any rider account. Now
requires admin/manager/executive auth.
Correctness
- Date ranges compared the container's UTC clock against timestamps the DB
writes as IST wall-clock (DSN sets TimeZone=Asia/Kolkata), so "today so far"
ended 5h30m in the past and silently dropped everything created after noon
IST from every report. Sets TZ in the image and adds utils.DBNow/DBToday,
which stay correct regardless of container timezone.
- CreateMiler never set Configid, so console-created riders got the column
default of 1 while LoginMiler looks up configid 1001 — every such rider was
unable to log in, reported as "no miler account found".
- Delivery wrote no consignment history row, so a tracking timeline never
showed the parcel arriving.
Features
- Delivery OTP is now real (crypto/rand, issued to the receiver, verified and
cleared on delivery) but opt-in per client via Tenant.Requiredeliveryotp,
defaulting off — friction worth it for a courier parcel, not a food order.
- Express bookings accept pickuplocationid, so the console can name a client
site (a DailyGrubs kitchen) instead of retyping its address; validated
against the tenant and carried through to the consignment.
- TenantLocation.Locationname, miler tenantid/hubid, Nagercoil (629) opened.
- PUT /miler/availability accepts both "status" and "availabilitystatus", and
/miler/location no longer drops speed/heading — both were contract
mismatches against the doc the Flutter dev was given.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hardening pass over the API surface. No route's auth requirements change.
Resilience:
- Add recover middleware. There was none, so an unhandled panic in any
handler propagated out of the process instead of becoming a 500.
- Add a centralized ErrorHandler so errors and recovered panics return the
same {success,message} envelope as the utils helpers, not Fiber's default
plain-text body. 5xx responses are logged with method and path.
Rate limiting:
- Global 300/min per IP as an abuse backstop, exempting health/readiness
probes and websocket upgrades.
- 10/min shared across every credential endpoint (customer/miler/admin/hub
login, verify-pin, reset-pin, email OTP). PINs are 4 digits, so the whole
keyspace was previously walkable in seconds. One shared limiter instance
means rotating between endpoints doesn't reset the budget.
- Add TRUSTED_PROXIES config. Limits key on c.IP(), which behind a TLS
terminator is the proxy, collapsing every client into one bucket. When set,
X-Forwarded-For is honoured only from those proxies so the header can't be
spoofed to dodge the limit. Logs a warning when unset.
Transactions:
- Check the error on all 51 previously-unchecked tx.Save/Create/Delete/
Model(...).Update/Commit calls across 6 controllers. A failed write inside
a transaction was silently ignored and the request still reported success;
an unchecked Commit could fail with the caller told everything worked.
Each site now rolls back and returns a specific message.
Pagination:
- Add utils.ParsePage/Paginated, reusing the pageno/pagesize convention
GetAdminBookings already established. Default 500, hard cap 1000.
- Apply to the previously unbounded consignments, tripsheets, exceptions,
app-users and clients endpoints. Defaults are high so existing consoles
that don't paginate keep working; the cap only stops a growing table from
being loaded wholesale. total is now a real COUNT, not len(data).
- GetClients also loaded the entire auth table to join in memory; it now
fetches only the current page's rows.
Tests (first in the repo):
- Extract the hyperlocal pincode rule out of BookingPickupComplete into
isHyperlocal so it is testable, covering the short/empty pincode fallback.
- Cover calculateVolumetricWeight and the ParsePage clamping rules.
Repo hygiene:
- Tag scratch/*.go with //go:build ignore. Each declared its own main(), so
`go build ./...` failed on redeclaration; it now passes repo-wide.
- Untrack scratch/node_modules (216 files) and ignore node_modules, test
artifacts, and the `doormile` binary `go build .` emits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bugs found during live Coimbatore testing against production:
- GetMilerAssignments returned every assignment ever made to a miler with
no status filter, so weeks-old Rejected assignments still showed up as
actionable in the app. Now filters to Assigned/Accepted only.
- AcceptMilerAssignment overwrote assignmentstatus unconditionally, letting
a stale Rejected assignment be silently reactivated (and pushing its
booking back to Pickup_Scheduled). Now 400s unless currently Assigned,
reporting the actual status.
- RejectMilerAssignment read `reason` from the query string instead of the
JSON body, contradicting the API contract and every sibling endpoint.
- BookingPickupComplete resolved the origin hub via db.First(&hub) with no
Where clause — i.e. the lowest hub ID in the table, unrelated to the
booking or miler. Now uses the miler's own MilerProfile.Hubid, falling
back to the old behaviour with a warning only when unassigned.
- No code path ever set a consignment to Out_for_Delivery, making
MilerDeliverConsignment unreachable. BookingPickupComplete now goes
straight to Out_for_Delivery when pickup and delivery pincodes share a
3-digit postal-area prefix (same-miler hyperlocal), reusing the
hubPincodePrefix convention. Cross-hub still lands at Inwarded_at_Hub.
Also allow https://app.doormile.com in CORS, and drop a stray Windows-path
log file that was committed by accident.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- pricingid null: lookupDoormilePrice now returns matched rule ID; wired to BookingServiceOption.Pricingid
- Zone rename: Interstate→Regional, OtherState→National throughout (code + DB migrated)
- Zone from pincodes: CheckPrice now accepts pickup_pincode+delivery_pincode and auto-resolves zone
- Delivery geocoding: pincodeToLatLon() maps 3-digit prefix to city coords when lat/lon are 0
- Device tokens: device_token field added to PinVerify DTOs; saved on both customer and miler login
- Assignment retry: RejectMilerAssignment now re-triggers AssignCustomerMiler/AssignCRMMiler immediately
- Provider empty B2C: defaults to Doormile when no pricing provider row matches
- City gate 422→400: StatusUnprocessableEntity corrected to StatusBadRequest
- Miler GPS 0,0: WS tracking falls back to MilerProfile DB coords when Redis key is expired
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>