scratch/seed_pin_customer.go (//go:build ignore, idempotent) — one customer with
PIN 1234 already set, for testing the interim /customer/auth/verify-pin flow.
Phone stored in normalised +91 form so CxPinLogin/CxVerifyPin find it. Run
against live: appcustomerid 300.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
No SMS/OTP gateway is live yet, so customers sign in with a self-set PIN like
milers do. The OTP endpoints stay in place — the app switches back once a
gateway is plugged in.
- POST /customer/auth/login {phone} -> {registered, pin_set, name}: routes the
app to register / set-PIN / enter-PIN.
- POST /customer/auth/set-pin {phone, new_pin, name?}: first-time PIN. Creates
the account (name required) or sets the first PIN on an account with none;
refuses to overwrite an existing PIN (409); logs in on success.
- POST /customer/auth/verify-pin {phone, pin}: returning login; same generic
message for unknown phone and wrong PIN so it can't enumerate accounts.
All three reuse issueCxSession (access + refresh + customer) and the /customer
Cx* response envelope. Build + vet clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
docs/miler-auth-and-review-fixes-2026-09-16.md: what changed on main today and
the current flow — the new /miler/login pin_set + /miler/set-pin first-login
contract (app work needed), the seven fixes to the merged cx/handover commits,
and the live-DB test data (no-PIN riders, sample multi-destination booking).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
scratch/seed_test_data.go (//go:build ignore) fills a DB with what the current
customer/miler app builds need to test against:
- reference serviceable states/districts + pricing
- base/hub network + a tenant location
- 3 TEST MILERS WITH NO PIN (8000000001/2/3) so the first-login set-PIN flow can
be exercised end to end
- a sample customer (9000000001) and a multi-destination customer-app booking
(DM-SEED-CX-001: 2 destinations, 2 consignments in the rider's hands) to test
the base-handover fix
Idempotent and non-destructive: every block is create-if-absent by a natural
key; it never truncates, never overwrites a curated row, and never resets a
test miler's PIN once set. Already run against the live DB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
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
Reviewed the 10 merged customer-app/base-handover commits and fixed the
defects found:
- HIGH (money): CreateCxBooking let the request body's `estimate` set the
billed price with no server-side check; it flows into Estimatedprice →
ridercharges (miler pay + tenant bill) with no weight re-price, so
{min:1,max:1} settled a delivery at ₹1. Now the client estimate is honoured
only when it matches the server quote within 15%, else the server quote
stands.
- MED: base handover freed the rider and closed the booking-level assignment
after the FIRST parcel of a multi-destination pickup, dropping the remaining
stops and crediting one leg. Now finalized only when no consignment of the
booking is still in the rider's hands.
- MED: inwardedat/completedat were written with time.Now() (UTC) instead of
DBNow() (IST), skewing them ~5h30 vs createdat and the earnings/reconcile
windows. Fixed in the handover, inbound-scan, reconcile and pickup-complete
paths.
- MED: B2C customers got two "miler assigned" pushes on auto-assign (two token
stores) and none on manual assign. Reconciled to one cxstage.Notify on both
paths.
- LOW: ReconcileHubInbound now runs in a transaction and checks its audit
inserts (was returning 200 with a silently-missing history row); CxLogout no
longer reports signedOut when the token revoke fails; a rider-named handover
base far from their reported position is rejected instead of silently
rerouting the parcel to another city.
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
GetMilerLogs fetched miler_periodic_logs with ZRangeByScore (ascending) +
Count: limit, so ?limit=1 returned the FIRST ping of the day — an early-boot
row with GPS but empty battery/connection/accuracy — instead of the latest
fix. That is exactly the blank-telemetry the rider-detail console showed,
even though the app was sending full telemetry (verified in live Redis).
Switch to ZRevRangeByScore (newest-first) so a limit-capped window keeps the
most recent rows, then flip the slice back to chronological so the trail and
distance sum still walk consecutive fixes in ride order. ?limit=1 now returns
the latest full-telemetry row.
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
The Admin Console derives Arrived from Pickup_Scheduled + reachedat != null,
but GetAdminBookings serializes the PickupBooking struct raw and the arrival
field emitted json arrivedat, not reachedat, so the console never saw the
arrival. Align the wire name to reachedat (miler app and /reached already use
it); DB column stays arrivedat.
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
Rider proof-of-delivery / signature photos need a way to reach storage.
The legacy (jupiter) rider app shipped the DigitalOcean Spaces access/secret
key inside the Flutter build and PUT to the bucket directly. This moves the
key server-side and hands the app a short-lived presigned PUT URL instead.
- internal/storage/spaces.go: self-contained AWS SigV4 query presigner for
Spaces (S3 API) — no aws-sdk-go-v2 dependency for a single presign op.
Verified live end-to-end (presign -> PUT 200 -> CDN GET matches).
- controllers/uploadController.go: POST /miler/uploads/sign returns
{ uploadurl, url, method, headers, key, expiresin }. Same bucket/folders/
CDN (images.nearle.app) as jupiter so images share one store.
- Reads DO_SPACES_* from .env via godotenv; returns 503 UPLOAD_NOT_CONFIGURED
when unset rather than handing out URLs that 403.
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>
Adds the backend half of the ExpressDispatchAgent flow. Express orders can now
be created batch by batch (bulk create only accumulates them, unassigned), then
an operator hits one endpoint to hand the whole pending set to the agent for
tenant-scoped assignment + road sequencing. The normal B2C flow is untouched.
- POST /admin/expressbooking/dispatch: manual trigger. Console-auth, tenant-
scoped; gathers the tenant's pending unassigned express orders (or a chosen
subset) and publishes express.dispatch_requested.
- internal API for the agent: GET /internal/express/riders (tenant's available
riders), GET /internal/express/bookings, POST /internal/express/assign (writes
the agent's decided assignments with their sequence; re-checks the already-
assigned guard so the agent can't double-assign).
- booking_assignment_service.go: extracted a behavior-preserving assignMilerTx
core; AssignMilerToBooking is unchanged in behavior. assignExpressStops writes
a batch, one FCM per rider instead of one per stop.
- EXPRESS JetStream stream / express.dispatch_requested subject.
- Gated behind EXPRESS_AGENT_ENABLED (default off): deploying this changes
nothing until the agent is confirmed running and the flag is flipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The admin customer page collected an address (door no, street, suburb,
city, state, postcode, landmark, lat/lng) that the backend had nowhere to
store, so it was silently dropped on save. Add those fields flat onto
appcustomers, matching the reference console's shape, plus an applocationid
for zone scoping.
- GET /admin/customers: add ?applocationid= filter; emit firstname/lastname
split and the address fields alongside the existing joined name.
- GET /admin/customers/summary (new): stat-tile counts (total/active/blocked)
scoped like the list, so the client stops deriving them from the full page.
- PATCH /admin/customers/🆔 accept firstname/lastname directly (single name
still splits as a fallback) and persist every address field; pointer fields
so an omitted field is not confused with one cleared to empty.
Pagination and keyword search were already present. Additive, nullable
columns — AutoMigrate handles it, no data rewrite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Payloads taken from the request structs rather than from documentation,
since the two had already drifted once. Records the things that are easy
to get wrong and hard to diagnose: configid 1001 on both rider auth
calls, tenantlocationid vs pickuplocationid pointing at different tables,
step 0 meaning 'not sequenced' rather than 'first', and admin miler
endpoints keying on milerprofileid while assign-miler takes a
mileruserid.
Also states plainly why bulk creation cannot sequence stops: assignment
picks who, sequencing picks the order, and the second needs the first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client was speaking jupiter's provider dialect -- deliveryid,
pickuplat, coordinates as strings -- because that was the only endpoint
the Route Optimization API offered. rider-bike now has
/api/v1/optimization/doormile/sequence, which takes bookingid and
pickuplatitude, so the translation layer is gone.
The new endpoint validates its body; the provider one cannot, because
jupiter is live on it and tightening it would break real deliveries.
That matters here: sending the provider endpoint the wrong field names
returns HTTP 200 "Success" with every coordinate defaulted to 0.0, no
reordering and all distances zero. The Doormile endpoint rejects that
outright, and rejects 0,0 coordinates, which are inside the valid range
but are a point in the Atlantic that drags a whole route toward it.
Responses now come back properly typed, so the loose float/string
coercion is deleted rather than kept for a shape that no longer arrives.
Tests replaced to match: they run the real client against a stub server
and pin the outbound field names, the mapping back onto assignment ids,
that steps for assignments we never sent are discarded, that step 0 is
not persisted as a position, and that a failed optimise surfaces an
error instead of quietly looking like success.
Needs rider-bike deployed first; until then sequencing fails
best-effort, which leaves bookings assigned but unordered.
Co-Authored-By: Claude Opus 5 (1M context) <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>
Birock/doormile-bookings/setup_jetstream.py called update_stream with the
subject list replaced wholesale, from a hardcoded list that had drifted
behind this repo. Re-running it would have stripped booking.cancelled,
booking.outcome, booking.assignment_failed, chat.room.closed.* and --
worst -- booking.assignment_requested, which carries the assignment
retry loop rather than merely reporting on it. Bookings would have
stopped reaching riders with nothing logged, until a restart repaired
the stream via EnsureStreams. A "setup" script that causes an outage
when run is a bad thing to leave lying around.
It is now an inspector: it reports streams, subjects and consumer state
and has no add/update/delete calls at all. Verified it is the only
script anywhere that pointed at doormile-nats (66.116.226.161:4223) --
every other stream-mutating script targets jupiter's NATS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AssignCustomerMiler and AssignCRMMiler retried five times, two minutes
apart, using time.Sleep inside a bare goroutine — about ten minutes of
state held only in one pod's memory. Any restart dropped every retry in
flight, and nothing recorded it: the booking just stayed unassigned
forever with no failure event, because publishAssignmentFailed only runs
at the end of a loop that no longer existed. Deploying during a quiet
patch was enough to lose bookings this way, and it happened during
today's rollout.
Retries now run on the ASSIGNMENTS stream. Each entry point publishes
one booking.assignment_requested message; a durable consumer performs a
single attempt per delivery and NAKs with retryDelay when no miler is
available, so JetStream owns both the waiting and the delivery count.
A pod dying mid-wait costs nothing — the message is still on the server
and another replica takes it.
Behaviour is deliberately unchanged from the caller's side: same five
attempts, same two-minute spacing, same publishAssignmentFailed handoff
to the DispatchAgent. The failure event is fired explicitly on the last
delivery, since JetStream stops redelivering at MaxDeliver and would
otherwise let the booking fail silently again.
runInline keeps the old loop as a fallback for when JetStream is down.
Assignment is how a booking reaches a rider, so it must not become
dependent on the event bus: an outage should cost durability, which is
what we had before, not stop bookings being assigned at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Doormile should not be wired to jupiter's APIs at all. The ingest routes,
the legacy identity middleware and the worker-side fan-out existed to
copy jupiter's rider GPS into Doormile, which was never the goal:
Doormile needs its own JetStream in front of its own endpoints, not a
pipe from someone else's.
Removed here: /internal/miler/* ingest, LegacyMilerIdentity, and the
legacyuserid field on the miler update payload. The worker-side shadow
forward and its k8s secret are removed separately in the Kubernetes
repo; jupiter forwarding is untouched and verified still healthy.
MilerProfile.Legacyuserid is deliberately kept. Nothing reads it now,
but it records which jupiter rider each of the six migrated riders came
from, which is worth having during the cutover. Dropping a populated
column buys nothing and AutoMigrate would not drop it anyway.
Kept from that work because they are unrelated to jupiter and fix real
bugs: db.EnsureStreams (four subjects were publishing to no stream and
being dropped silently) and the tenantlocationid column.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Backfilling the jupiter->Doormile rider mapping otherwise needs a direct
UPDATE against production, since nothing else writes the column.
UpdateMiler already runs behind admin auth and findMilerForConsole's
tenant scoping, which is the right gate for an ops-only field: it is set
for migrated riders and never by the rider themselves.
Confirmed mapping, matched on phone (jupiter userid -> Doormile userid):
852->37 Sivakumar Subramani, 883->38 Rajan A, 897->39 Varun Edward,
950->40 Jayasabesh Kumar S, 1111->41 Murali P, 1114->42 Tamilazhagan K.
852 sat under a different jupiter partner (Xpress-Mdu-Main) than the
other five, which looked like it might put him out of scope. He is not:
he was migrated to tenant 14 / Nagercoil / hub 18, so a Madurai-side
partner is exactly where jupiter would carry him. jupiter's partnerid is
a rider hub, not a tenant, so it cannot be used to decide tenant
membership.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Half of this binary's js.Publish calls were bound to no stream at all.
The streams were declared only by an external Python script on another
machine (Birock/doormile-bookings/setup_jetstream.py) and had drifted
from the code: booking.cancelled, booking.outcome and
booking.assignment_failed had no stream, and CHAT declared the literal
"chat.room.closed" while chat.go publishes "chat.room.closed.<id>",
which it does not match. Every publish site is best-effort
(`if db.Js != nil` + warn-log), so those events were failing and being
dropped silently — every cancellation, delivery outcome and assignment
failure since the streams were created.
db.EnsureStreams now declares the streams at startup from a map that
sits next to the code that publishes, so the contract cannot drift
again. It only ever adds: existing streams keep their storage type,
retention, limits and every subject they already have. Nothing is
deleted. Losing the create race against a sibling replica is expected
and reconciles rather than erroring.
Alongside that, /internal/miler/* ingests rider telemetry still arriving
over the jupiter NATS chain. The forwarding worker holds no rider JWT —
the rider app is still jupiter-shaped — so LegacyMilerIdentity resolves
an identity from a header into c.Locals("userid") behind the existing
X-Internal-Key guard. That lets the routes reuse the miler handlers
unchanged instead of growing a parallel set that would drift.
Identity comes from a header, never the body: the telemetry handlers
overwrite a body-supplied userid precisely so one rider cannot write
another's GPS trail, and reading it from the body here would reopen that
from behind the internal key. MilerProfile.Legacyuserid (nullable,
indexed) maps a jupiter userid to a Doormile one.
Only fire-and-forget telemetry is exposed. Transactional actions stay
synchronous — a rider needs a real answer from pickup-complete, which a
queue in front of it cannot give.
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>
jupiter's getreportsummary took a locationid — per kitchen, per branch. That
was the one report parameter with no Doormile equivalent, and for a food
client with 23 kitchens it is the difference between one number and a usable
report.
GET /admin/reports?locationid= narrows every figure to one site
GET /admin/reports now carries a by_location block
GET /admin/locations/summary the standalone per-site table
The filter alone would have been useless: pickuplocationid was null on every
booking in the system, because the console sends a kitchen's address rather
than its id. createExpressBooking now resolves the site itself — nearest
stored location within 150m, falling back to an address match, nil when
nothing matches confidently, since a wrong attribution silently moves orders
between kitchens. An explicit pickuplocationid still wins.
Bookings that named no site are reported as their own "Unattributed" row
rather than dropped, so per-site rows add up to the summary total.
Two fixes found while in here:
- the payments join in the per-site query fanned out, counting a booking once
per payment row; payments are now pre-aggregated per booking
- by_rider was empty for every client login, which reads as "your riders did
nothing". Riders are tenant-scoped now, so a client sees its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Maps every jupiter endpoint we have replaced to its Doormile equivalent, for
the express console and the miler app only. Marks which jupiter paths were
confirmed from live network logs versus taken from the prior codebase
analysis, and states per row whether the Doormile side has been hit with a
real request or only compiles.
Includes the 11-way decomposition of PUT /deliveries/updatedelivery, the
behaviour changes that break a naive repoint, and the gaps jupiter covered
that Doormile does not yet — per-site reporting being the notable one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the five rider/tracking endpoints, the ?tenantid= staff filter and the
403-vs-404 refusal rules, and replaces the guesswork coverage note with what
was actually run against production on 2026-08-06.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
utils.Forbidden and utils.NotFound write the response and return c.JSON's
nil. Any helper that signalled refusal by returning one of them handed its
caller a nil error, so every `if err != nil { return err }` guard passed and
the handler carried straight on.
The observable result: GET /admin/milers?tenantid=14 as a DailyGrubs login
returned HTTP 403 with all 30 of the network's riders in the body. Status
line correct, payload leaked.
Three helpers were affected:
effectiveTenantID (yesterday, mine) — cross-tenant read returned the
unfiltered list under a 403
canAccessBooking (was assertBookingAccess, shipped in 6d9232f) — four
mutating booking handlers were unguarded
findMilerForConsole (was assertMilerAccess) — worse, callers went on to
dereference the nil profile
All three now return a bool and the caller writes the refusal itself, so the
control flow is visible at the call site instead of hiding in a helper.
Adds a test that pins utils.Forbidden/NotFound returning nil, so if that ever
changes the assumption breaks loudly rather than silently, plus table tests
for effectiveTenantID.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two halves of the same thing. A client login was already pinned to its own
tenant on most reads, but the roster, the B2C customer list and the dashboard
counters were not — a DailyGrubs login listed the whole network's riders.
The other half was missing entirely: Doormile's own staff had no way to look
at one client's slice. Reports accepted ?tenantid= but applied it only to the
consignment count, and bookings accepted it while milers, customers,
consignments and the dashboard ignored it.
effectiveTenantID(c) now resolves both cases in one place — the caller's own
tenant for a client login, the requested one for Doormile staff, 0 for the
whole network. A client asking for someone else's tenantid is refused rather
than silently handed their own data back under the wrong label.
Applied to: milers, customers, bookings, consignments, dashboard, reports and
the rider summary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A client login could list its own bookings but had no way to see what its
riders were actually doing. jupiter's console gave them getridersummary and
the rider/delivery logs; Doormile records all of it and exposed none of it.
New console endpoints, all tenant-scoped:
GET /admin/milers/summary roster with live state + range totals
GET /admin/milers/:id/logs GPS trail from the Redis telemetry index
GET /admin/milers/:id/activity one rider's assignments, duty and breaks
GET /admin/consignments/:id/logs event history + telemetry + proof
GET /admin/bookings/:id/track booking -> assignments -> parcel -> proof
Also closes a rider IDOR: GetMilers scoped the roster to the caller's own
fleet, but reading, editing, blocking, notifying or assigning a vehicle to a
single rider by id did not, so a client login could walk the whole network's
riders by incrementing the id. All five now go through assertMilerAccess.
And the client dashboard no longer reports milers/customers/exceptions as
zero — those have no tenant column, so they are counted through appusers,
bookings and consignments respectively.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>