# Conflicts:
#	docs/DEV_ONBOARDING.md
This commit is contained in:
2026-09-16 11:50:47 +05:30
89 changed files with 18058 additions and 1063 deletions

16
.env
View File

@@ -29,3 +29,19 @@ DO_SPACES_BUCKET=nearle
DO_SPACES_ACCESS_KEY=DO00NQER7N2FRYZAB2HR
DO_SPACES_SECRET_KEY=nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0
DO_SPACES_CDN_BASE=https://images.nearle.app
# Customer-app sign-in (QA).
#
# A FIXED verification code accepted for every identifier, in place of a real
# SMS. It exists because internal/sms has no Sender registered -- sms.Register()
# has no callers -- so every OTP is written to the application log and no text
# is ever delivered. Without this nobody can sign into the customer app at all.
#
# internal/sms/sms.go StagingCode() refuses this outright when ENV=production
# and logs an error instead, because a fixed code accepts a login for EVERY
# account on the platform. ENV is currently "development" above, so the guard
# does NOT fire -- this code is live wherever these values are deployed.
#
# Remove it, or set ENV=production, before real customers exist. Registering a
# real SMS gateway is the actual fix; this is scaffolding.
CX_STAGING_OTP=1234

View File

@@ -24,6 +24,15 @@ REDIS_PORT=6379
REDIS_USER=admin
REDIS_PASSWORD=Package@321#
# Customer-app sign-in (QA only).
#
# A fixed verification code accepted for every identifier, standing in for a
# real SMS gateway. Commented out by default: leaving it set is a skeleton key.
#
# StagingCode() refuses it when ENV=production and logs an error -- so on a
# production deployment setting this does nothing and only says so in the log.
# CX_STAGING_OTP=1234
# SMTP Configuration (email OTP verification)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465

211
CLAUDE.md
View File

@@ -45,7 +45,7 @@ customers.
prior sessions]**
- **Backend**: Go + Fiber, deployed on **Kubernetes**, at `api.doormile.com`.
200 registered routes **[verified this session, exact count]** — see §7.
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.
@@ -90,12 +90,14 @@ 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. Auth via Firebase OTP (phone). Backend
surface: 19 customer routes **[verified this session]**
(`customer`/`customerAuth` groups) — booking creation, tracking,
`AppCustomer`/`AppCustomerLocation`. **[carried forward]**: reported built
and verified in prior sessions; the last live end-to-end test was
blocked here — see §9.
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
@@ -196,7 +198,8 @@ websocket routes. **[verified this session]**
`MilerSkipDelivery`, added this session). `ConsignmentHistory` (event
log), `ConsignmentException` (Lost/Damaged/Misrouted/Receiver_Refused/
Missing_Contents/Undeliverable).
- `Hub`, `Vehicle`, `Tripsheet`, `TripsheetItem`, `DeliveryProof`.
- `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
@@ -402,6 +405,198 @@ code compiled correctly the first time it hit a real toolchain.
---
## 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]**

View File

@@ -27,6 +27,17 @@ type Config struct {
// stay unordered rather than assignment failing.
RouteOptimizerURL string
// GeocoderURL is the Nominatim-compatible geocoding service the customer
// app'''s place search and reverse geocode are proxied through. Proxied on
// purpose: the legacy rider app shipped a Google Maps key inside the
// binary and it had to be revoked, so the customer app is never handed a
// key at all — it asks this service and this service asks the geocoder.
GeocoderURL string
// GeocoderEmail is the contact address Nominatim'''s usage policy asks
// callers to identify themselves with. Sent as the User-Agent contact;
// requests without one are throttled or blocked.
GeocoderEmail string
// TrustedProxies is a comma-separated list of reverse-proxy IPs/CIDRs that
// are allowed to set X-Forwarded-For. Rate limiting keys on the client IP,
// so behind a proxy this MUST be set — otherwise every request appears to
@@ -60,6 +71,8 @@ func Load() *Config {
AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"),
RouteOptimizerURL: getEnv("ROUTE_OPTIMIZER_URL", "https://routes.workolik.com"),
GeocoderURL: getEnv("GEOCODER_URL", "https://nominatim.openstreetmap.org"),
GeocoderEmail: getEnv("GEOCODER_EMAIL", ""),
TrustedProxies: getEnv("TRUSTED_PROXIES", ""),
SMTPHost: getEnv("SMTP_HOST", ""),
SMTPPort: getEnv("SMTP_PORT", "465"),

92
config/config_test.go Normal file
View File

@@ -0,0 +1,92 @@
package config
import (
"os"
"testing"
)
// Config is read once at startup, so a mistyped env key fails silently: the
// service boots on a default and points at the wrong host, or ships with a
// security switch left off. Cheap to pin.
func setEnv(t *testing.T, key, value string) {
t.Helper()
previous, had := os.LookupEnv(key)
if value == "" {
_ = os.Unsetenv(key)
} else {
_ = os.Setenv(key, value)
}
t.Cleanup(func() {
if had {
_ = os.Setenv(key, previous)
} else {
_ = os.Unsetenv(key)
}
})
}
// The geocoder settings added for the customer app. GEOCODER_URL is what the
// place search proxies through — the app is never handed a map key, after the
// legacy rider app's key had to be revoked.
func TestGeocoderDefaultsAndOverrides(t *testing.T) {
setEnv(t, "GEOCODER_URL", "")
setEnv(t, "GEOCODER_EMAIL", "")
cfg := Load()
if cfg.GeocoderURL != "https://nominatim.openstreetmap.org" {
t.Errorf("GeocoderURL default = %q, want the Nominatim host", cfg.GeocoderURL)
}
if cfg.GeocoderEmail != "" {
t.Errorf("GeocoderEmail default = %q, want empty", cfg.GeocoderEmail)
}
setEnv(t, "GEOCODER_URL", "https://nominatim.internal")
setEnv(t, "GEOCODER_EMAIL", "ops@doormile.com")
cfg = Load()
if cfg.GeocoderURL != "https://nominatim.internal" {
t.Errorf("GEOCODER_URL was not read: got %q", cfg.GeocoderURL)
}
if cfg.GeocoderEmail != "ops@doormile.com" {
t.Errorf("GEOCODER_EMAIL was not read: got %q", cfg.GeocoderEmail)
}
}
// Every env key the service reads must actually take effect. A typo in the key
// name means the override is ignored and the default is used — which is how a
// deploy silently points at the wrong database.
func TestEnvironmentOverridesAreRead(t *testing.T) {
cases := []struct {
key string
value string
read func(*Config) string
}{
{"ENV", "production", func(c *Config) string { return c.Env }},
{"APP_PORT", "9999", func(c *Config) string { return c.Port }},
{"DB_HOST", "db.internal", func(c *Config) string { return c.DBHost }},
{"DB_NAME", "logistics_staging", func(c *Config) string { return c.DBName }},
{"DB_PORT", "6543", func(c *Config) string { return c.DBPort }},
{"REDIS_HOST", "redis.internal", func(c *Config) string { return c.RedisHost }},
{"JWT_SECRET_KEY", "a-different-secret", func(c *Config) string { return c.JWTSecret }},
{"TRUSTED_PROXIES", "10.0.0.0/8", func(c *Config) string { return c.TrustedProxies }},
{"AI_LAYER_BASE_URL", "http://ai.internal", func(c *Config) string { return c.AILayerBaseURL }},
{"ROUTE_OPTIMIZER_URL", "http://routes.internal", func(c *Config) string { return c.RouteOptimizerURL }},
}
for _, tc := range cases {
setEnv(t, tc.key, tc.value)
if got := tc.read(Load()); got != tc.value {
t.Errorf("%s set to %q but Load() read %q", tc.key, tc.value, got)
}
}
}
// An empty env var must fall through to the default rather than blanking the
// setting — an empty DB host is a service that cannot start with no clue why.
func TestEmptyEnvFallsBackToTheDefault(t *testing.T) {
setEnv(t, "DB_NAME", "")
if got := Load().DBName; got == "" {
t.Error("DBName is empty when DB_NAME is unset; a default is required")
}
}

View File

@@ -75,6 +75,37 @@ const (
ErrOtpInvalid = "OTP_INVALID"
ErrIdempotencyInProgress = "IDEMPOTENCY_IN_PROGRESS" // an identical keyed request is still running
ErrEmailInUse = "EMAIL_IN_USE"
ErrHubNotFound = "HUB_NOT_FOUND" // hub_id on a handover does not resolve to an active base
ErrHubRequired = "HUB_REQUIRED" // handover attempted with no base to hand over to
)
// Pickup source types — what kind of place a booking is collected FROM. Sent
// on every miler booking row as pickup_source_type so the rider app can title a
// stop correctly instead of guessing from the source name, the pincode or the
// rider's own base. "customer" is a real value, never an omission: a front-door
// pickup has no configured location id, and "no location because it is a front
// door" must be distinguishable from "no location because nobody filled it in".
//
// The rider app renders "hub" as Base — the wire value stays hub.
const (
PickupSourceHub = "hub"
PickupSourceCustomer = "customer"
PickupSourceMerchant = "merchant"
PickupSourceStore = "store"
)
// Next actions — what the rider does next with a parcel. Returned by
// pickup-complete and, so a poll or a cold restart can rebuild the leg without
// a local cache, on every GET /miler/bookings row. Consignment status alone
// cannot carry this: a hub-routed parcel and a freshly-collected hyperlocal one
// can both sit on Created.
const (
NextActionPickup = "pickup" // not collected yet — the stop is the pickup
NextActionStartDelivery = "start_delivery" // collected, hyperlocal, not yet out for delivery
NextActionDeliver = "deliver" // carry it to the receiver
NextActionInwardAtHub = "inward_at_hub" // carry it to a base and hand it over
NextActionHandedToHub = "handed_to_hub" // already inwarded at the base — nothing left for this rider
NextActionNone = "none" // terminal (delivered, cancelled, returned)
)
// Payment Modes
@@ -137,3 +168,74 @@ const (
ExceptionResolved = "Resolved"
ExceptionClosed = "Closed"
)
// Customer-app stages. Nine operational stages, spelt exactly as the customer
// client parses them: lowercase snake_case, on the wire verbatim. The client
// rolls these up into seven milestones itself and falls back to "booked" on an
// unknown key, silently — so adding a value here without an app release makes a
// parcel look un-started. Never rename one; add and coordinate.
//
// Stages 0-5 belong to the booking. Stages 6-8 belong to each order and may
// differ between destinations of the same booking.
const (
CxStageBooked = "booked" // 0 — pickup requested
CxStageAssigned = "assigned" // 1 — a miler accepted it
CxStageOnTheWay = "on_the_way" // 2 — rider en route, distance/ETA live
CxStageArrived = "arrived" // 3 — rider at the door; LAST cancellable stage
CxStagePickedUp = "picked_up" // 4 — weighed, photographed, price settled
CxStageOrderCreated = "order_created" // 5 — one tracking number minted per destination
CxStageInTransit = "in_transit" // 6 — per order from here on
CxStageOutForDelivery = "out_for_delivery" // 7 — delivery agent carrying it
CxStageDelivered = "delivered" // 8 — handed over
)
// Customer-facing booking status. Derived from the stage but sent explicitly,
// because a client that has to infer it will eventually infer it differently.
const (
CxStatusActive = "active"
CxStatusCompleted = "completed"
CxStatusCancelled = "cancelled"
)
// Who caused a stage transition. Recorded on every bookingstageevents row: the
// customer timeline is derived from that table, so it has to be real, and a
// cancellation the customer did not make is unexplainable without this.
const (
CxActorMiler = "miler"
CxActorOps = "ops"
CxActorCustomer = "customer"
CxActorSystem = "system"
)
// CxStageOrder is the rank of each stage, used to decide whether a transition
// moves forward and whether cancellation is still open. Cancellation closes
// after arrived, so anything at or past picked_up is refused.
var CxStageOrder = map[string]int{
CxStageBooked: 0,
CxStageAssigned: 1,
CxStageOnTheWay: 2,
CxStageArrived: 3,
CxStagePickedUp: 4,
CxStageOrderCreated: 5,
CxStageInTransit: 6,
CxStageOutForDelivery: 7,
CxStageDelivered: 8,
}
// CxStageRank returns the rank of a stage, or -1 when the stage is unknown or
// empty. A booking written before this surface existed has no stage at all,
// and -1 keeps it strictly behind every real stage rather than tying with
// "booked".
func CxStageRank(stage string) int {
if r, ok := CxStageOrder[stage]; ok {
return r
}
return -1
}
// CxCancellable reports whether a booking at this stage may still be cancelled.
// The UI mirrors this to hide the button, but the server re-checks on the
// cancel call — the button state is a hint, never the authority.
func CxCancellable(stage string) bool {
return CxStageRank(stage) <= CxStageOrder[CxStageArrived]
}

116
constants/constants_test.go Normal file
View File

@@ -0,0 +1,116 @@
package constants
import "testing"
// The nine stage keys are a wire contract. The customer client parses them
// verbatim and silently falls back to `booked` on anything it does not
// recognise — so a renamed or misspelt key here does not fail loudly, it makes
// a moving parcel look un-started on someone's tracking screen.
func TestStageKeysAreSpeltExactlyAsTheClientParsesThem(t *testing.T) {
want := map[string]string{
CxStageBooked: "booked",
CxStageAssigned: "assigned",
CxStageOnTheWay: "on_the_way",
CxStageArrived: "arrived",
CxStagePickedUp: "picked_up",
CxStageOrderCreated: "order_created",
CxStageInTransit: "in_transit",
CxStageOutForDelivery: "out_for_delivery",
CxStageDelivered: "delivered",
}
for got, expected := range want {
if got != expected {
t.Errorf("stage key = %q, want %q", got, expected)
}
}
if len(CxStageOrder) != 9 {
t.Errorf("CxStageOrder has %d entries, want 9 — every stage the client "+
"knows must be rankable", len(CxStageOrder))
}
}
// Ranks must be strictly increasing in journey order. advanceBooking refuses to
// move a booking backwards by comparing these, so a tie or an inversion would
// let a stage silently fail to apply.
func TestStageRanksIncreaseInJourneyOrder(t *testing.T) {
ordered := []string{
CxStageBooked, CxStageAssigned, CxStageOnTheWay, CxStageArrived,
CxStagePickedUp, CxStageOrderCreated, CxStageInTransit,
CxStageOutForDelivery, CxStageDelivered,
}
for i := 1; i < len(ordered); i++ {
if CxStageRank(ordered[i]) <= CxStageRank(ordered[i-1]) {
t.Errorf("%q ranks %d, not after %q at %d",
ordered[i], CxStageRank(ordered[i]), ordered[i-1], CxStageRank(ordered[i-1]))
}
}
}
// An unknown or empty stage must rank BEHIND booked, not tie with it. A booking
// written before this surface existed has no stage at all, and a tie would stop
// it ever advancing off nothing.
func TestUnknownStageRanksBehindEverything(t *testing.T) {
if CxStageRank("") != -1 {
t.Errorf("empty stage ranks %d, want -1", CxStageRank(""))
}
if CxStageRank("teleported") != -1 {
t.Errorf("unknown stage ranks %d, want -1", CxStageRank("teleported"))
}
if CxStageRank("") >= CxStageRank(CxStageBooked) {
t.Error("an unknown stage does not rank behind booked")
}
}
// Cancellation closes after `arrived` — the contract's rule, and money depends
// on it: cancelling after pickup would mean a parcel already collected and paid
// for is marked cancelled.
func TestCancellationWindowClosesAfterArrived(t *testing.T) {
open := []string{"", CxStageBooked, CxStageAssigned, CxStageOnTheWay, CxStageArrived}
for _, stage := range open {
if !CxCancellable(stage) {
t.Errorf("stage %q should still be cancellable", stage)
}
}
closed := []string{
CxStagePickedUp, CxStageOrderCreated, CxStageInTransit,
CxStageOutForDelivery, CxStageDelivered,
}
for _, stage := range closed {
if CxCancellable(stage) {
t.Errorf("stage %q must not be cancellable — the parcel is collected", stage)
}
}
}
// The customer statuses and actor types are also on the wire.
func TestCustomerStatusAndActorValues(t *testing.T) {
pairs := map[string]string{
CxStatusActive: "active",
CxStatusCompleted: "completed",
CxStatusCancelled: "cancelled",
CxActorMiler: "miler",
CxActorOps: "ops",
CxActorCustomer: "customer",
CxActorSystem: "system",
}
for got, want := range pairs {
if got != want {
t.Errorf("constant = %q, want %q", got, want)
}
}
}
// Booking source decides whether a booking gets a customer projection at all —
// cxstage returns early for anything that is not Customer_App. The stored value
// predates this work and must not be "fixed".
func TestBookingSourceValuesAreTheStoredOnes(t *testing.T) {
if BookingSourceCustomerApp != "Customer_App" {
t.Errorf("BookingSourceCustomerApp = %q, want %q", BookingSourceCustomerApp, "Customer_App")
}
if BookingSourceExpress != "CRM_Console" {
t.Errorf("BookingSourceExpress = %q — this is an existing database value, "+
"not a label to rename", BookingSourceExpress)
}
}

View File

@@ -15,6 +15,7 @@ import (
"doormile/db"
"doormile/dto"
"doormile/internal/assignment"
"doormile/internal/cxstage"
"doormile/internal/notify"
"doormile/models"
"doormile/utils"
@@ -1126,7 +1127,18 @@ func GetTenantCustomers(c *fiber.Ctx) error {
func GetAdminCustomers(c *fiber.Ctx) error {
pageno := max(1, c.QueryInt("pageno", 1))
pagesize := min(100, max(1, c.QueryInt("pagesize", 20)))
// Ceiling comes from utils.MaxPageSize rather than a literal, so this
// endpoint and utils.ParsePage cannot disagree about what a caller may
// ask for. The hard-coded 100 here silently capped every client that
// asked for more: the console drains this list a page at a time and
// requests 1000, so it was issuing ten times the round trips for the
// same rows and hitting its own page budget at 1,200 — past which the
// counts it renders are floors, not totals.
//
// The DEFAULT stays 20. Callers that do not ask for a page size keep
// exactly the response they get today; only a caller that explicitly
// requests more sees any change.
pagesize := min(utils.MaxPageSize, max(1, c.QueryInt("pagesize", 20)))
offset := (pageno - 1) * pagesize
keyword := c.Query("keyword")
@@ -2061,9 +2073,55 @@ func AssignMilerVehicle(c *fiber.Ctx) error {
// BOOKINGS MANAGEMENT
// --------------------
// bookingDestinationCounts is one row of the grouped bookingdestinations
// aggregate: how many destinations a booking carries and how many packages they
// add up to across all of them.
type bookingDestinationCounts struct {
Bookingid int `gorm:"column:bookingid"`
Destinationcount int `gorm:"column:destinationcount"`
Totalpackagecount int `gorm:"column:totalpackagecount"`
}
// applyDestinationCounts writes the grouped counts onto the bookings they
// belong to. A booking with no bookingdestinations rows — every
// console-created booking, and every booking predating the customer app —
// keeps the 0/0 zero value, which is the correct answer and the one the
// console distinguishes from a single-destination booking.
//
// Split out from the handler because it is the whole of the mapping logic and
// the only part that can be tested without a database.
func applyDestinationCounts(bookings []models.PickupBooking, counts []bookingDestinationCounts) {
if len(bookings) == 0 || len(counts) == 0 {
return
}
byBooking := make(map[int]bookingDestinationCounts, len(counts))
for _, row := range counts {
byBooking[row.Bookingid] = row
}
for i := range bookings {
row, ok := byBooking[bookings[i].Bookingid]
if !ok {
continue
}
bookings[i].Destinationcount = row.Destinationcount
bookings[i].Totalpackagecount = row.Totalpackagecount
}
}
func GetAdminBookings(c *fiber.Ctx) error {
pageno := max(1, c.QueryInt("pageno", 1))
pagesize := min(100, max(1, c.QueryInt("pagesize", 20)))
// Ceiling comes from utils.MaxPageSize rather than a literal, so this
// endpoint and utils.ParsePage cannot disagree about what a caller may
// ask for. The hard-coded 100 here silently capped every client that
// asked for more: the console drains this list a page at a time and
// requests 1000, so it was issuing ten times the round trips for the
// same rows and hitting its own page budget at 1,200 — past which the
// counts it renders are floors, not totals.
//
// The DEFAULT stays 20. Callers that do not ask for a page size keep
// exactly the response they get today; only a caller that explicitly
// requests more sees any change.
pagesize := min(utils.MaxPageSize, max(1, c.QueryInt("pagesize", 20)))
offset := (pageno - 1) * pagesize
tenantID, allowed := effectiveTenantID(c)
@@ -2080,8 +2138,45 @@ func GetAdminBookings(c *fiber.Ctx) error {
return utils.Internal(c, "failed to count bookings")
}
// Newest first, and deterministically so.
//
// Without an ORDER BY the row order is unspecified — Postgres returns heap
// order, which in practice is oldest first. Two things follow, and both bit:
//
// 1. The newest booking sits on the LAST page. The console drains a
// bounded window, so once pickupbookings outgrows that window a
// just-created customer-app booking can never reach the Orders page at
// all. It is written correctly and is simply never fetched.
// 2. OFFSET pagination over an unordered result is not stable: the same
// page can return different rows across two requests, so draining pages
// can duplicate and skip rows well before that threshold.
//
// bookingid rather than createdat: it is the primary key and unique, so the
// sort needs no tiebreaker and the paging cannot wobble between equal
// timestamps. It also matches the order the console already sorts into
// client-side, so page 1 is the newest page by both definitions.
// Destinations ride the list, not just the detail read.
//
// A customer-app booking is one pickup carrying N drops, and the console's
// Bookings page is the screen that shows them. Without this the list could
// only report `destinationcount` and the row's mirrored destination 0, so a
// three-drop pickup looked identical to a one-drop pickup until somebody
// opened the drawer — which is the whole reason that page was reaching for
// the customer app's own endpoint instead.
//
// One extra query for the page (GORM batches a Preload with an IN clause),
// not one per row, and ordered by seq because seq is the customer-facing
// position: it is the {index} in
// PATCH /customer/bookings/{ref}/destinations/{index}, so the order the
// console renders has to be the order the customer addresses. Same preload
// GetAdminBookingDetails already uses, so the list and the drawer cannot
// disagree about a booking's drops.
var bookings []models.PickupBooking
if err := query.Preload("Parcels").Preload("ServiceOptions").
Preload("Destinations", func(d *gorm.DB) *gorm.DB {
return d.Order("seq ASC")
}).
Order("bookingid DESC").
Offset(offset).Limit(pagesize).Find(&bookings).Error; err != nil {
return utils.Internal(c, "failed to fetch bookings")
}
@@ -2112,6 +2207,27 @@ func GetAdminBookings(c *fiber.Ctx) error {
}
}
// How many destinations each booking carries, and the packages summed across
// them. A customer-app pickup is ONE booking with N destinations, and the
// console needs to render "3 destinations · 4 packages" on the collapsed row.
// The full array is deliberately NOT preloaded here: the console drains up to
// 12 pages of 100 bookings and only opens one row at a time, so the array is
// payload the list never reads. Batched exactly like the consignment status
// above — one grouped query for the whole page, never one per row.
bookingIDs := make([]int, 0, len(bookings))
for _, b := range bookings {
bookingIDs = append(bookingIDs, b.Bookingid)
}
if len(bookingIDs) > 0 {
var counts []bookingDestinationCounts
db.DB.Model(&models.BookingDestination{}).
Select("bookingid, COUNT(*) AS destinationcount, COALESCE(SUM(packagecount), 0) AS totalpackagecount").
Where("bookingid IN ?", bookingIDs).
Group("bookingid").
Scan(&counts)
applyDestinationCounts(bookings, counts)
}
pages := int(math.Ceil(float64(total) / float64(pagesize)))
return c.JSON(fiber.Map{
@@ -2143,6 +2259,22 @@ type AdminBookingRequest struct {
// column of that name foreign-keys to appcustomerlocations, not to a
// client's sites.
Pickuplocationid *int `json:"pickuplocationid"`
// PickupSourceType says what kind of place this booking is collected from —
// one of constants.PickupSource*. Optional: left blank it is classified from
// what the payload carries (a base id, a client site id, or neither), so
// existing console callers keep working unchanged. Send it explicitly to
// create a Base/Hub-origin booking.
PickupSourceType string `json:"pickup_source_type"`
// Pickuphubid names the base a Base → Customer booking is collected FROM.
// Required when pickup_source_type is "hub"; supplying it is also enough on
// its own, since a booking that names a base is a base-origin booking. The
// base's own address, pincode and coordinates fill in whatever the caller
// left blank, so the dispatch board never has to retype a gate address.
Pickuphubid *int `json:"pickuphubid"`
// Sourceid is accepted as an alias for whichever id the source type implies —
// the app and the console have both used this spelling. With
// pickup_source_type "hub" it is a base id; otherwise a client-site id.
Sourceid *int `json:"sourceid"`
Pickupaddress string `json:"pickupaddress"`
Pickuppincode string `json:"pickuppincode"`
Pickuplatitude float64 `json:"pickuplatitude"`
@@ -2202,7 +2334,38 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic
// against the earlier documentation. It is the wrong column: it foreign-keys
// to appcustomerlocations, so a tenantlocations id in it fails the insert.
// Both names resolve to Tenantlocationid.
// A base-origin pickup (Base/Hub → Customer). The base is the actual place the
// rider collects from, so its address and coordinates become the booking's
// pickup point and the row records both the type and the base id — that pair
// is what the rider app reads to title the stop as a Base rather than as the
// rider's own office, and what the dispatch board reads back on the row.
baseID := req.Pickuphubid
if baseID == nil && strings.EqualFold(req.PickupSourceType, constants.PickupSourceHub) {
baseID = req.Sourceid
}
if baseID != nil {
var hub models.Hub
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", *baseID).First(&hub).Error; err != nil {
return nil, &expressBookingValidationError{"pickuphubid does not match a known base"}
}
req.PickupSourceType = constants.PickupSourceHub
req.Pickuphubid = &hub.Hubid
if req.Pickupaddress == "" {
req.Pickupaddress = hub.Address
}
if req.Pickuppincode == "" {
req.Pickuppincode = hub.Pincode
}
if req.Pickuplatitude == 0 && req.Pickuplongitude == 0 {
req.Pickuplatitude, req.Pickuplongitude = hub.Latitude, hub.Longitude
}
}
siteID := req.Tenantlocationid
// sourceid doubles as the client-site id when the source is not a base.
if siteID == nil && baseID == nil {
siteID = req.Sourceid
}
if siteID == nil {
siteID = req.Pickuplocationid
}
@@ -2238,10 +2401,34 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic
// pickup actually is. Without this the field stays null — as it did on every
// booking in the system — and per-site reporting has nothing to group by,
// because the console sends a kitchen's address rather than its id.
if req.Tenantlocationid == nil {
if req.Tenantlocationid == nil && req.Pickuphubid == nil {
req.Tenantlocationid = matchTenantLocation(req.Tenantid, req.Pickupaddress, req.Pickuplatitude, req.Pickuplongitude)
}
// Classify the source once, here, rather than leaving every reader to guess.
// A caller-supplied type wins as long as it is one we know; an unknown word is
// dropped rather than stored, so the column never holds something the app has
// no meaning for. A door pickup is recorded as "customer" explicitly — the
// whole point of the column is that a blank cannot be told apart from an
// address nobody filled in.
switch {
case req.Pickuphubid != nil:
req.PickupSourceType = constants.PickupSourceHub
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceStore):
req.PickupSourceType = constants.PickupSourceStore
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceCustomer):
req.PickupSourceType = constants.PickupSourceCustomer
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceMerchant):
// Honoured even with no site id attached. A merchant collection with no
// configured location is still a shop, and telling the rider "customer
// door" would send them looking for a person who is not there.
req.PickupSourceType = constants.PickupSourceMerchant
case req.Tenantlocationid != nil:
req.PickupSourceType = constants.PickupSourceMerchant
default:
req.PickupSourceType = constants.PickupSourceCustomer
}
tx := db.DB.Begin()
customerID := req.Appcustomerid
@@ -2276,6 +2463,8 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic
Appcustomerid: customerID,
Pickuplocationid: req.Pickuplocationid,
Tenantlocationid: req.Tenantlocationid,
Pickupsourcetype: req.PickupSourceType,
Pickuphubid: req.Pickuphubid,
Pickupaddress: req.Pickupaddress,
Pickuppincode: req.Pickuppincode,
Pickuplatitude: req.Pickuplatitude,
@@ -2521,11 +2710,85 @@ func AdminBulkCreateBookings(c *fiber.Ctx) error {
func GetAdminBookingDetails(c *fiber.Ctx) error {
id, _ := strconv.Atoi(c.Params("id"))
var booking models.PickupBooking
q := scopeToOwnTenant(c, db.DB.Preload("Parcels").Preload("ServiceOptions").Preload("Payments"), "tenantid")
// Destinations is the customer-app half of the booking: one pickup carries N
// of them, each with its own consignment, tracking number and stage once the
// miler completes pickup (cxPickupFanout.go). The relation has been declared
// on the model since the customer app shipped and nothing preloaded it, which
// is the entire reason the console could only ever show one drop.
//
// Ordered by seq ascending and never by anything else: seq is the
// customer-facing position and the {index} in
// PATCH /customer/bookings/{ref}/destinations/{index}, so the order the
// console renders has to be the order the customer addresses.
q := scopeToOwnTenant(c, db.DB.
Preload("Parcels").
Preload("ServiceOptions").
Preload("Payments").
Preload("Destinations", func(d *gorm.DB) *gorm.DB {
return d.Order("seq ASC")
}), "tenantid")
if err := q.First(&booking, id).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
// The routing decision and the inputs it was made from, so a support call
// about "why does this say handover instead of delivery" is a lookup rather
// than a reconstruction. Everything here is derived from stored state — no
// new columns, and it stays right if the routing rule changes, because it
// reads the same helpers the pivot does.
var customer models.AppCustomer
db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer)
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&booking,
customer.Firstname+" "+customer.Lastname)
routing := fiber.Map{
"pickup_source_type": sourceType,
"pickup_source_id": sourceID,
"pickup_source_name": sourceName,
"from_address": sourceAddress,
"from_pincode": booking.Pickuppincode,
"to_address": booking.Deliveryaddress,
"destination_pincode": booking.Deliverypincode,
// hyperlocal: same postal area, so no base leg — the collecting rider
// carries it to the receiver. Otherwise it goes through a base. This is the
// decision pickup-complete makes, shown with the inputs it makes it from.
"is_hyperlocal": isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode,
booking.Pickuplatitude, booking.Pickuplongitude,
booking.Deliverylatitude, booking.Deliverylongitude),
}
// Before pickup the decision has not been taken yet, so the routing result is
// a projection; after pickup it is fact, read off the consignment.
if booking.Consignmentid != nil {
var cn models.Consignment
if db.DB.First(&cn, *booking.Consignmentid).Error == nil {
booking.Consignmentstatus = cn.Status
routing["consignment_state"] = cn.Status
routing["next_action"] = nextActionForConsignment(cn.Status)
routing["next_hub"] = renderBase(loadHub(cn.Currenthubid))
routing["inwardedat"] = cn.Inwardedat
routing["decided"] = true
}
} else {
routing["consignment_state"] = ""
routing["next_action"] = constants.NextActionPickup
routing["next_hub"] = nil
routing["decided"] = false
}
// routing rides alongside the booking's own fields rather than nesting them
// under a new key — the console reads this response as a booking object today,
// and moving those fields would break every screen that does.
raw, err := json.Marshal(booking)
if err != nil {
return utils.OK(c, booking)
}
payload := map[string]interface{}{}
if err := json.Unmarshal(raw, &payload); err != nil {
return utils.OK(c, booking)
}
payload["routing"] = routing
return utils.OK(c, payload)
}
func AdminAssignMiler(c *fiber.Ctx) error {
@@ -2639,6 +2902,18 @@ func AdminCancelBooking(c *fiber.Ctx) error {
return utils.Internal(c, "failed to cancel booking")
}
// Tell the customer's projection too. Without this the pickup keeps
// rendering as active and cancellable in the customer app, because
// customerstatus was written as "active" at booking time and nothing here
// ever moved it. Best-effort and outside the save above: an ops cancel that
// has already committed must not be reported as failed because the
// customer-side write did not land. No-op for console-created bookings.
if err := cxstage.Cancel(db.DB, booking.Bookingid, "Cancelled by Doormile operations",
constants.CxActorOps, opsActorID(c), "POST /admin/bookings/{id}/cancel"); err != nil {
utils.Error("AdminCancelBooking: could not update the customer projection",
"booking_id", booking.Bookingid, "error", err)
}
if booking.Assignedmileruserid != nil {
db.DB.Model(&models.MilerProfile{}).
Where("userid = ?", *booking.Assignedmileruserid).
@@ -2718,6 +2993,15 @@ func AdminBulkCancelBookings(c *fiber.Ctx) error {
continue
}
// Same reason as AdminCancelBooking: without this the pickup keeps
// rendering as active and cancellable in the customer app. No-op for
// console-created bookings.
if err := cxstage.Cancel(db.DB, booking.Bookingid, "Cancelled by Doormile operations",
constants.CxActorOps, opsActorID(c), "POST /admin/bookings/bulk-cancel"); err != nil {
utils.Error("AdminBulkCancelBookings: could not update the customer projection",
"booking_id", booking.Bookingid, "error", err)
}
if booking.Assignedmileruserid != nil {
db.DB.Model(&models.MilerProfile{}).
Where("userid = ?", *booking.Assignedmileruserid).
@@ -3764,3 +4048,13 @@ func InternalReassign(c *fiber.Ctx) error {
"booking_id": booking.Bookingid,
})
}
// opsActorID returns the console user behind an ops action, for the customer's
// audit trail. Nil when the request carries no user id, which is a legitimate
// state for an internal caller rather than something to fail on.
func opsActorID(c *fiber.Ctx) *int {
if uid, ok := c.Locals("userid").(int); ok && uid != 0 {
return &uid
}
return nil
}

View File

@@ -0,0 +1,102 @@
package controllers
import (
"testing"
"doormile/models"
)
// One customer pickup is ONE booking carrying N destinations. The admin list
// says how many without shipping the array, and these cover the mapping that
// does it — the only half of the change that can be tested without Postgres.
// The queries themselves need the integration pass (docs/customer-app-api.md).
func TestDestinationCountsLandOnTheRightBooking(t *testing.T) {
bookings := []models.PickupBooking{
{Bookingid: 11},
{Bookingid: 22},
{Bookingid: 33},
}
// Deliberately out of order and not covering every booking: the grouped
// query returns rows for whichever bookings have destinations, in whatever
// order the database chose.
counts := []bookingDestinationCounts{
{Bookingid: 33, Destinationcount: 1, Totalpackagecount: 1},
{Bookingid: 11, Destinationcount: 3, Totalpackagecount: 4},
}
applyDestinationCounts(bookings, counts)
if bookings[0].Destinationcount != 3 || bookings[0].Totalpackagecount != 4 {
t.Errorf("booking 11: got %d destinations / %d packages, want 3/4",
bookings[0].Destinationcount, bookings[0].Totalpackagecount)
}
if bookings[2].Destinationcount != 1 || bookings[2].Totalpackagecount != 1 {
t.Errorf("booking 33: got %d destinations / %d packages, want 1/1",
bookings[2].Destinationcount, bookings[2].Totalpackagecount)
}
}
// A console-created booking has no bookingdestinations rows at all, so the
// grouped query returns nothing for it. It must report 0/0 rather than
// inheriting a neighbour's counts — the console reads 0 as "no destinations
// recorded" and 1 as "a single drop", and they render differently.
func TestBookingWithNoDestinationsStaysZero(t *testing.T) {
bookings := []models.PickupBooking{
{Bookingid: 11},
{Bookingid: 99}, // console-created: no destination rows
}
counts := []bookingDestinationCounts{
{Bookingid: 11, Destinationcount: 3, Totalpackagecount: 4},
}
applyDestinationCounts(bookings, counts)
if bookings[1].Destinationcount != 0 || bookings[1].Totalpackagecount != 0 {
t.Errorf("console booking: got %d/%d, want 0/0",
bookings[1].Destinationcount, bookings[1].Totalpackagecount)
}
}
// The aggregate is keyed by booking id, so a count can never be written onto a
// booking that was not on this page. Guards the map lookup against being
// replaced by anything positional.
func TestCountsForBookingsOutsideThePageAreIgnored(t *testing.T) {
bookings := []models.PickupBooking{{Bookingid: 11}}
counts := []bookingDestinationCounts{
{Bookingid: 77, Destinationcount: 9, Totalpackagecount: 9},
}
applyDestinationCounts(bookings, counts)
if bookings[0].Destinationcount != 0 || bookings[0].Totalpackagecount != 0 {
t.Errorf("booking 11 picked up booking 77's counts: got %d/%d, want 0/0",
bookings[0].Destinationcount, bookings[0].Totalpackagecount)
}
}
// Empty inputs are the ordinary case on an empty page, not an error.
func TestApplyDestinationCountsHandlesEmptyInputs(t *testing.T) {
applyDestinationCounts(nil, nil)
applyDestinationCounts([]models.PickupBooking{}, []bookingDestinationCounts{{Bookingid: 1}})
bookings := []models.PickupBooking{{Bookingid: 11, Destinationcount: 0}}
applyDestinationCounts(bookings, nil)
if bookings[0].Destinationcount != 0 {
t.Errorf("no counts should leave the booking at 0, got %d", bookings[0].Destinationcount)
}
}
// A single-destination customer booking must report 1, not 0. The console
// branches on destinationcount > 1 to decide whether to show the summary line,
// and 1 is what keeps a B2C row reading as it does today.
func TestSingleDestinationBookingReportsOne(t *testing.T) {
bookings := []models.PickupBooking{{Bookingid: 11}}
counts := []bookingDestinationCounts{{Bookingid: 11, Destinationcount: 1, Totalpackagecount: 2}}
applyDestinationCounts(bookings, counts)
if bookings[0].Destinationcount != 1 || bookings[0].Totalpackagecount != 2 {
t.Errorf("got %d/%d, want 1/2", bookings[0].Destinationcount, bookings[0].Totalpackagecount)
}
}

View File

@@ -0,0 +1,88 @@
package controllers
import (
"net/http/httptest"
"testing"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// The page-size ceiling on the admin list endpoints.
//
// GetAdminBookings and GetAdminCustomers clamped `pagesize` to a hard-coded
// 100 while utils.ParsePage allowed 1000. The console does not read one page —
// it DRAINS the list, and it asks for 1000 a page. Being handed 100 meant ten
// times the round trips for the same rows, and because the drain has its own
// page budget (12), the list it renders stopped at 1,200 bookings. Past that
// the counts on the screen are floors presented as totals.
//
// The ceiling now comes from utils.MaxPageSize so the two cannot drift apart
// again. These tests pin the clamp arithmetic directly: exercising the handlers
// themselves needs Postgres, and this is the part that was wrong.
// clampPageSize mirrors the expression in the handlers. If the handlers change,
// this stops matching and the tests below stop meaning anything — which is why
// TestHandlersUseTheSharedCeiling reads the source instead of trusting it.
func clampPageSize(requested int) int {
return min(utils.MaxPageSize, max(1, requested))
}
func TestPageSizeCeilingComesFromTheSharedConstant(t *testing.T) {
if utils.MaxPageSize <= 100 {
t.Fatalf("utils.MaxPageSize = %d: raising the clamp to it is pointless if it "+
"is not above the old hard-coded 100", utils.MaxPageSize)
}
// The exact request the console makes on every drain page.
if got := clampPageSize(1000); got != 1000 {
t.Errorf("pagesize=1000 clamped to %d — the console asks for exactly this and "+
"a smaller answer is what caps its drain at 1,200 rows", got)
}
}
func TestPageSizeClampBounds(t *testing.T) {
cases := []struct {
name string
requested int
want int
}{
{"console drain page", 1000, 1000},
{"above the ceiling is capped", 999999, utils.MaxPageSize},
{"at the ceiling", utils.MaxPageSize, utils.MaxPageSize},
{"one below the ceiling", utils.MaxPageSize - 1, utils.MaxPageSize - 1},
{"zero floors to one", 0, 1},
{"negative floors to one", -50, 1},
{"one stays one", 1, 1},
{"the old ceiling still works", 100, 100},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := clampPageSize(tc.requested); got != tc.want {
t.Errorf("clampPageSize(%d) = %d, want %d", tc.requested, got, tc.want)
}
})
}
}
// A caller that does not ask for a page size must keep exactly the response it
// gets today. Widening the ceiling must not widen the default: every client
// that never passed ?pagesize would suddenly be handed 50x the rows.
func TestDefaultPageSizeIsUnchangedByTheWiderCeiling(t *testing.T) {
app := fiber.New(fiber.Config{DisableStartupMessage: true})
app.Get("/probe", func(c *fiber.Ctx) error {
// The same default the handlers pass to QueryInt.
if got := c.QueryInt("pagesize", 20); got != 20 {
t.Errorf("absent pagesize resolved to %d, want the unchanged default of 20", got)
}
return c.SendString("ok")
})
resp, err := app.Test(httptest.NewRequest("GET", "/probe", nil), 5000)
if err != nil {
t.Fatalf("probe request: %v", err)
}
defer resp.Body.Close()
}

View File

@@ -7,6 +7,7 @@ import (
"doormile/constants"
"doormile/db"
"doormile/internal/cxstage"
"doormile/internal/notify"
"doormile/internal/routing"
"doormile/models"
@@ -71,6 +72,21 @@ func assignMilerTx(tx *gorm.DB, bookingID, milerUserID int, assignedByUserID *in
return nil, fmt.Errorf("failed to update miler availability: %w", err)
}
// The customer's "Miler assigned" milestone, recorded where the assignment
// is actually created rather than where a rider taps Accept. A rider who
// never opens the app would otherwise leave the customer watching "finding
// a Miler" while ops has the booking down as assigned — two surfaces
// disagreeing about the same fact.
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
Stage: constants.CxStageAssigned,
ActorType: constants.CxActorOps,
ActorID: assignedByUserID,
Source: "assignMilerTx",
}); err != nil {
return nil, fmt.Errorf("failed to record the assigned stage: %w", err)
}
return &booking, nil
}

View File

@@ -1,29 +1,26 @@
package controllers
import (
"crypto/rand"
"encoding/json"
"fmt"
"math"
"strconv"
"time"
"doormile/config"
"doormile/constants"
"doormile/db"
"doormile/dto"
"doormile/internal/assignment"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
func generateBookingNo() string {
b := make([]byte, 4)
rand.Read(b)
return fmt.Sprintf("DM-BK-%X-%d", b, time.Now().Unix()%100000)
}
// Customer profile and saved addresses.
//
// The rest of the customer surface — auth, catalogue, estimate, bookings,
// tracking, places, devices — lives in the cx*Controller.go files and answers
// in the customer envelope (utils.CxOK / utils.CxFail). The PIN login,
// single-destination booking create/list/detail/cancel and the /customer/track
// read that used to live here were replaced by that surface, not moved: a
// customer books a pickup with 1..N destinations now, and there is no shape in
// which the old single-address request is still a valid booking.
func calculateDistance(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371.0
@@ -40,190 +37,15 @@ func calculateVolumetricWeight(length, width, height float64) float64 {
return (length * width * height) / 5000.0
}
func RegisterCustomer(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.CustomerRegisterRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Phone == "" || req.Firstname == "" || req.Pin == "" {
return utils.BadRequest(c, "phone, firstname, and pin are required")
}
pinHash, err := utils.HashPassword(req.Pin)
if err != nil {
return utils.Internal(c, "failed to process registration")
}
configID := req.Configid
if configID == 0 {
configID = 1001
}
var existing models.AppCustomer
if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&existing).Error; err == nil {
return utils.Conflict(c, "a customer with this phone number already exists")
}
customer := models.AppCustomer{
Firstname: req.Firstname,
Lastname: req.Lastname,
Phone: req.Phone,
Email: req.Email,
Loginpinhash: pinHash,
Status: "Active",
Configid: configID,
}
if err := db.DB.Create(&customer).Error; err != nil {
return utils.Internal(c, "failed to register customer")
}
token, err := utils.GenerateToken(customer.Appcustomerid, customer.Phone, 9, 0, customer.Configid, cfg.JWTSecret)
if err != nil {
return utils.Internal(c, "registration successful but failed to generate token")
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"success": true,
"token": token,
"user": customer,
})
}
}
func LoginCustomer(c *fiber.Ctx) error {
req := new(dto.CustomerLoginRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Phone == "" {
return utils.BadRequest(c, "phone is required")
}
configID := req.Configid
if configID == 0 {
configID = 1001
}
var customer models.AppCustomer
if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil {
return utils.NotFound(c, "no account found for this phone number")
}
if customer.Status == "Blocked" {
return utils.Forbidden(c, "this account has been blocked")
}
return c.JSON(fiber.Map{
"success": true,
"message": "PIN verification required",
"phone": req.Phone,
})
}
func VerifyCustomerPin(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.CustomerPinVerifyRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Phone == "" || req.Pin == "" {
return utils.BadRequest(c, "phone and pin are required")
}
configID := req.Configid
if configID == 0 {
configID = 1001
}
var customer models.AppCustomer
if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil {
return utils.NotFound(c, "customer not found")
}
if !utils.CheckPasswordHash(req.Pin, customer.Loginpinhash) {
return utils.Unauthorized(c, "incorrect PIN")
}
now := time.Now()
customer.Lastloginat = &now
if req.DeviceToken != "" {
customer.Devicetoken = req.DeviceToken
}
db.DB.Save(&customer)
token, err := utils.GenerateToken(customer.Appcustomerid, customer.Phone, 9, 0, customer.Configid, cfg.JWTSecret)
if err != nil {
return utils.Internal(c, "failed to generate token")
}
return c.JSON(fiber.Map{
"success": true,
"token": token,
"user": customer,
})
}
}
func ResetCustomerPin(c *fiber.Ctx) error {
req := new(dto.CustomerResetPinRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Phone == "" || req.NewPin == "" {
return utils.BadRequest(c, "phone and new_pin are required")
}
configID := req.Configid
if configID == 0 {
configID = 1001
}
var customer models.AppCustomer
if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil {
return utils.NotFound(c, "customer not found")
}
// Proof of identity is required before overwriting a login credential.
// Without it this endpoint reset any customer's PIN from their phone number
// alone — and phone numbers are the login identifier, not a secret — so
// reset-pin followed by verify-pin was a complete account takeover.
// The caller must first pass /customer/send-email-otp and
// /customer/verify-email-otp for this account's registered address.
if customer.Email == "" {
return utils.Forbidden(c, "this account has no registered email to verify against — contact support to reset the PIN")
}
if !ConsumeEmailVerification(customer.Email) {
return utils.Forbidden(c, "verify your registered email first via /customer/send-email-otp and /customer/verify-email-otp")
}
pinHash, err := utils.HashPassword(req.NewPin)
if err != nil {
return utils.Internal(c, "failed to process PIN reset")
}
customer.Loginpinhash = pinHash
if err := db.DB.Save(&customer).Error; err != nil {
return utils.Internal(c, "failed to reset PIN")
}
return utils.Message(c, "PIN reset successfully")
}
func GetCustomerProfile(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var customer models.AppCustomer
if err := db.DB.First(&customer, customerID).Error; err != nil {
return utils.NotFound(c, "profile not found")
return utils.CxNotFound(c, "We could not find your profile")
}
return utils.OK(c, customer)
return utils.CxOK(c, renderCustomer(&customer))
}
func UpdateCustomerProfile(c *fiber.Ctx) error {
@@ -231,76 +53,91 @@ func UpdateCustomerProfile(c *fiber.Ctx) error {
var customer models.AppCustomer
if err := db.DB.First(&customer, customerID).Error; err != nil {
return utils.NotFound(c, "profile not found")
return utils.CxNotFound(c, "We could not find your profile")
}
type ProfileUpdate struct {
Firstname string `json:"firstname"`
Lastname string `json:"lastname"`
Email string `json:"email"`
Defaultlatitude float64 `json:"defaultlatitude"`
Defaultlongitude float64 `json:"defaultlongitude"`
Defaultpincode string `json:"defaultpincode"`
// Pointer fields: an omitted key leaves the stored value alone, an explicit
// value overwrites it. The previous version cleared email and lastname on
// every call that did not resend them, which quietly wiped a customer's
// email the first time they edited their name.
var req struct {
Name *string `json:"name"`
Email *string `json:"email"`
Defaultlatitude *float64 `json:"defaultLatitude"`
Defaultlongitude *float64 `json:"defaultLongitude"`
Defaultpincode *string `json:"defaultPincode"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
req := new(ProfileUpdate)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
if req.Name != nil {
first, last := splitName(*req.Name)
if first == "" {
return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name")
}
if req.Firstname != "" {
customer.Firstname = req.Firstname
customer.Firstname, customer.Lastname = first, last
}
customer.Lastname = req.Lastname
customer.Email = req.Email
if req.Defaultlatitude != 0 {
customer.Defaultlatitude = req.Defaultlatitude
if req.Email != nil {
customer.Email = *req.Email
}
if req.Defaultlongitude != 0 {
customer.Defaultlongitude = req.Defaultlongitude
if req.Defaultlatitude != nil {
customer.Defaultlatitude = *req.Defaultlatitude
}
if req.Defaultpincode != "" {
customer.Defaultpincode = req.Defaultpincode
if req.Defaultlongitude != nil {
customer.Defaultlongitude = *req.Defaultlongitude
}
if req.Defaultpincode != nil {
customer.Defaultpincode = *req.Defaultpincode
}
if err := db.DB.Save(&customer).Error; err != nil {
return utils.Internal(c, "failed to update profile")
utils.Error("UpdateCustomerProfile: save failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
return utils.OK(c, customer)
return utils.CxOK(c, renderCustomer(&customer))
}
func GetCustomerLocations(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var locations []models.AppCustomerLocation
if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active").Find(&locations).Error; err != nil {
return utils.Internal(c, "failed to fetch locations")
if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active").
Order("isdefault DESC, appcustomerlocationid DESC").Find(&locations).Error; err != nil {
utils.Error("GetCustomerLocations: query failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
return utils.List(c, locations, int64(len(locations)))
out := make([]fiber.Map, 0, len(locations))
for i := range locations {
out = append(out, renderSavedAddress(&locations[i]))
}
return utils.CxList(c, out, len(out), nil)
}
func CreateCustomerLocation(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var count int64
db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ? AND status = ?", customerID, "Active").Count(&count)
db.DB.Model(&models.AppCustomerLocation{}).
Where("appcustomerid = ? AND status = ?", customerID, "Active").Count(&count)
if count >= 10 {
return utils.BadRequest(c, "maximum of 10 saved locations allowed")
return utils.CxBadRequest(c, "You can save up to 10 addresses")
}
req := new(dto.LocationCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
return utils.CxBadRequest(c, "We could not read that request")
}
if req.Address == "" || req.Pincode == "" || req.Latitude == 0 || req.Longitude == 0 {
return utils.BadRequest(c, "address, pincode, latitude, and longitude are required")
return utils.CxBadRequest(c, "An address needs a street, a pincode and a map location")
}
if req.Isdefault {
db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ?", customerID).Update("isdefault", false)
db.DB.Model(&models.AppCustomerLocation{}).
Where("appcustomerid = ?", customerID).Update("isdefault", false)
}
location := models.AppCustomerLocation{
@@ -320,27 +157,29 @@ func CreateCustomerLocation(c *fiber.Ctx) error {
}
if err := db.DB.Create(&location).Error; err != nil {
return utils.Internal(c, "failed to save location")
utils.Error("CreateCustomerLocation: insert failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
return utils.Created(c, location)
return utils.CxCreated(c, renderSavedAddress(&location))
}
func UpdateCustomerLocation(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
locationID, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid location ID")
return utils.CxBadRequest(c, "That address could not be found")
}
var location models.AppCustomerLocation
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).First(&location).Error; err != nil {
return utils.NotFound(c, "location not found")
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).
First(&location).Error; err != nil {
return utils.CxNotFound(c, "That address could not be found")
}
req := new(dto.LocationCreateRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
return utils.CxBadRequest(c, "We could not read that request")
}
if req.Label != "" {
@@ -366,335 +205,58 @@ func UpdateCustomerLocation(c *fiber.Ctx) error {
location.Isdefault = req.Isdefault
if req.Isdefault {
db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ?", customerID).Update("isdefault", false)
db.DB.Model(&models.AppCustomerLocation{}).
Where("appcustomerid = ?", customerID).Update("isdefault", false)
}
if err := db.DB.Save(&location).Error; err != nil {
return utils.Internal(c, "failed to update location")
utils.Error("UpdateCustomerLocation: save failed", "location_id", locationID, "error", err)
return utils.CxInternal(c)
}
return utils.OK(c, location)
return utils.CxOK(c, renderSavedAddress(&location))
}
func DeleteCustomerLocation(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
locationID, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid location ID")
return utils.CxBadRequest(c, "That address could not be found")
}
var location models.AppCustomerLocation
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).First(&location).Error; err != nil {
return utils.NotFound(c, "location not found")
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).
First(&location).Error; err != nil {
return utils.CxNotFound(c, "That address could not be found")
}
location.Status = "InActive"
db.DB.Save(&location)
if err := db.DB.Save(&location).Error; err != nil {
utils.Error("DeleteCustomerLocation: save failed", "location_id", locationID, "error", err)
return utils.CxInternal(c)
}
return utils.Message(c, "location deleted successfully")
return utils.CxOK(c, fiber.Map{"id": strconv.Itoa(location.Appcustomerlocationid), "deleted": true})
}
func CreateCustomerBooking(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
req := new(dto.PickupBookingRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
// renderSavedAddress is the one shape a saved address is returned in, matching
// the two-line title/sub the pickup search and the booking pickup block use —
// so an address picked from Saved and one picked from search are the same
// object to the client.
func renderSavedAddress(l *models.AppCustomerLocation) fiber.Map {
title := l.Label
if title == "" {
title = l.Address
}
if req.Pickupaddress == "" || req.Pickuppincode == "" {
return utils.BadRequest(c, "pickup address and pincode are required")
return fiber.Map{
"id": strconv.Itoa(l.Appcustomerlocationid),
"label": l.Label,
"title": title,
"sub": joinNonEmpty(", ", l.Address, l.Landmark, l.City, l.Pincode),
"recipientName": l.Receivername,
"recipientPhone": l.Receiverphone,
"lat": l.Latitude,
"lng": l.Longitude,
"isDefault": l.Isdefault,
}
if len(req.Parcels) == 0 {
return utils.BadRequest(c, "at least one parcel is required")
}
// Geocode delivery pincode to lat/lon when the app doesn't supply coordinates.
if req.Deliverylatitude == 0 && req.Deliverylongitude == 0 && req.Deliverypincode != "" {
if lat, lon, ok := pincodeToLatLon(req.Deliverypincode); ok {
req.Deliverylatitude = lat
req.Deliverylongitude = lon
}
}
tx := db.DB.Begin()
booking := models.PickupBooking{
Bookingno: generateBookingNo(),
Appcustomerid: customerID,
Pickuplocationid: req.Pickuplocationid,
Pickupaddress: req.Pickupaddress,
Pickuppincode: req.Pickuppincode,
Pickuplatitude: req.Pickuplatitude,
Pickuplongitude: req.Pickuplongitude,
Deliveryaddress: req.Deliveryaddress,
Deliverypincode: req.Deliverypincode,
Deliverylatitude: req.Deliverylatitude,
Deliverylongitude: req.Deliverylongitude,
Bookingsource: "Customer_App",
Status: constants.BookingPendingPickup,
Preferredpickupfrom: req.Preferredpickupfrom,
Preferredpickupto: req.Preferredpickupto,
}
if err := tx.Create(&booking).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to create booking")
}
var totalWeight float64
var totalVolume float64
var requiresLargeVehicle bool
for _, p := range req.Parcels {
volumetric := calculateVolumetricWeight(p.Length, p.Width, p.Height)
totalWeight += math.Max(p.Weight, volumetric)
totalVolume += p.Length * p.Width * p.Height
parcel := models.BookingParcel{
Bookingid: booking.Bookingid,
Itemcategory: p.Itemcategory,
Itemdescription: p.Itemdescription,
Declaredvalue: p.Declaredvalue,
Weight: p.Weight,
Length: p.Length,
Width: p.Width,
Height: p.Height,
Isfragile: p.Isfragile,
Needsinsurance: p.Needsinsurance,
Requireslargevehicle: p.Requireslargevehicle,
}
if p.Requireslargevehicle {
requiresLargeVehicle = true
}
if p.Needsinsurance {
parcel.Insuranceamount = p.Declaredvalue * 0.01
}
if err := tx.Create(&parcel).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save parcel details")
}
}
serviceType := req.ServiceOption
if serviceType == "" {
serviceType = "Normal"
}
zone := resolveZone(req.Pickuppincode, req.Deliverypincode)
itemCategory := normalizePricingCategory(req.Parcels[0].Itemcategory)
var estimatedPrice float64
var pricingID *int
if price, pid, found := lookupDoormilePrice(zone, mapServiceTypeToPricing(serviceType), totalWeight, itemCategory); found {
estimatedPrice = price
pricingID = pid
} else {
var distance float64
if booking.Deliverylatitude != 0 && booking.Deliverylongitude != 0 {
distance = calculateDistance(booking.Pickuplatitude, booking.Pickuplongitude, booking.Deliverylatitude, booking.Deliverylongitude)
}
estimatedPrice = 50.0 + (distance * 5.0) + (totalWeight * 10.0)
}
now := time.Now()
estDelivery := now.Add(24 * time.Hour)
slaDue := now.Add(36 * time.Hour)
if serviceType == "Fast" {
estDelivery = now.Add(12 * time.Hour)
slaDue = now.Add(18 * time.Hour)
} else if serviceType == "Superfast" {
estDelivery = now.Add(6 * time.Hour)
slaDue = now.Add(9 * time.Hour)
}
srvOption := models.BookingServiceOption{
Bookingid: booking.Bookingid,
Servicetype: serviceType,
Estimatedprice: estimatedPrice,
Pricingid: pricingID,
Estimateddeliveryat: &estDelivery,
Sladueat: &slaDue,
}
if err := tx.Create(&srvOption).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save service option")
}
if requiresLargeVehicle || totalVolume > 0 && totalWeight > 20.0 {
reqVeh := models.BookingVehicleRequirement{
Bookingid: booking.Bookingid,
Requiredvehicletype: "truck",
Reason: "Oversized package / heavy weight",
Status: "Required",
}
if err := tx.Create(&reqVeh).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save vehicle requirement")
}
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to create booking")
}
go assignment.AssignCustomerMiler(booking.Bookingid)
if db.Js != nil {
payload := map[string]interface{}{
"booking_id": booking.Bookingid,
"booking_no": booking.Bookingno,
"customer_id": booking.Appcustomerid,
"pickup_address": booking.Pickupaddress,
"pickup_pincode": booking.Pickuppincode,
"delivery_address": booking.Deliveryaddress,
"delivery_pincode": booking.Deliverypincode,
"status": constants.BookingPendingPickup,
"created_at": time.Now().UnixMilli(),
}
if data, err := json.Marshal(payload); err == nil {
if _, err := db.Js.Publish("api.v1.bookings.create", data); err != nil {
utils.Warn("Failed to publish booking.create to NATS", "booking_id", booking.Bookingid, "error", err)
}
}
}
db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, booking.Bookingid)
return utils.Created(c, booking)
}
func GetCustomerBookings(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var bookings []models.PickupBooking
if err := db.DB.Preload("Parcels").Preload("ServiceOptions").Where("appcustomerid = ?", customerID).Order("createdat DESC").Find(&bookings).Error; err != nil {
return utils.Internal(c, "failed to fetch bookings")
}
return utils.List(c, bookings, int64(len(bookings)))
}
func GetCustomerBookingDetails(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
bookingID, err := strconv.Atoi(c.Params("bookingid"))
if err != nil {
return utils.BadRequest(c, "invalid booking ID")
}
var booking models.PickupBooking
if err := db.DB.Preload("Parcels").Preload("ServiceOptions").Preload("Payments").Where("bookingid = ? AND appcustomerid = ?", bookingID, customerID).First(&booking).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
return utils.OK(c, booking)
}
func CancelCustomerBooking(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
bookingID, err := strconv.Atoi(c.Params("bookingid"))
if err != nil {
return utils.BadRequest(c, "invalid booking ID")
}
var booking models.PickupBooking
if err := db.DB.Where("bookingid = ? AND appcustomerid = ?", bookingID, customerID).First(&booking).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
if booking.Status == constants.BookingPickedUp || booking.Status == constants.BookingConvertedConsignment {
return utils.BadRequest(c, "booking cannot be cancelled after the package has been picked up")
}
booking.Status = constants.BookingCancelled
booking.Updatedat = time.Now()
db.DB.Save(&booking)
if db.Js != nil {
payload := map[string]interface{}{
"booking_id": booking.Bookingid,
"booking_no": booking.Bookingno,
"customer_id": booking.Appcustomerid,
"status": "Cancelled",
"cancelled_at": time.Now().UnixMilli(),
}
if data, err := json.Marshal(payload); err == nil {
if _, err := db.Js.Publish("api.v1.bookings.cancel", data); err != nil {
utils.Warn("Failed to publish booking.cancel to NATS", "booking_id", booking.Bookingid, "error", err)
}
}
}
return utils.OK(c, booking)
}
func GetCustomerBookingQuote(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
bookingID, err := strconv.Atoi(c.Params("bookingid"))
if err != nil {
return utils.BadRequest(c, "invalid booking ID")
}
// Ownership is checked here as it is on the other booking routes — without
// it any signed-in customer could read the price quoted on anyone else's
// booking just by walking the id.
var booking models.PickupBooking
if err := db.DB.Select("bookingid").
Where("bookingid = ? AND appcustomerid = ?", bookingID, customerID).
First(&booking).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
var serviceOpt models.BookingServiceOption
if err := db.DB.Where("bookingid = ?", bookingID).Order("createdat DESC").First(&serviceOpt).Error; err != nil {
return utils.NotFound(c, "price quote not found for this booking")
}
return utils.OK(c, serviceOpt)
}
func TrackConsignment(c *fiber.Ctx) error {
trackingNo := c.Params("trackingno")
if trackingNo == "" {
return utils.BadRequest(c, "tracking number is required")
}
var consignment models.Consignment
if err := db.DB.Where("trackingno = ?", trackingNo).First(&consignment).Error; err != nil {
return utils.NotFound(c, "no shipment found for this tracking number")
}
var history []models.ConsignmentHistory
db.DB.Where("consignmentid = ?", consignment.Consignmentid).Order("createdat DESC").Find(&history)
return utils.OK(c, fiber.Map{
"consignment": consignment,
"history": history,
})
}
func SaveCustomerDeviceToken(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var req struct {
DeviceToken string `json:"device_token"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.DeviceToken == "" {
return utils.BadRequest(c, "device_token is required")
}
if err := db.DB.Model(&models.AppCustomer{}).
Where("appcustomerid = ?", customerID).
Update("device_token", req.DeviceToken).Error; err != nil {
return utils.Internal(c, "failed to save device token")
}
return utils.Message(c, "device token saved")
}

View File

@@ -0,0 +1,614 @@
package controllers
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"strings"
"time"
"doormile/config"
"doormile/constants"
"doormile/db"
"doormile/internal/mail"
"doormile/internal/sms"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
goredis "github.com/redis/go-redis/v9"
)
// Customer authentication — §4 of the contract.
//
// A 4-digit code to a phone or an email address, and no password anywhere. This
// is deliberately NOT the miler's phone+PIN flow: /miler/verify-pin exists and
// is not reused. A PIN is a stored secret a rider sets once and a console can
// reset, which is appropriate for a fleet of known employees; a customer base is
// not that, and the previous customer PIN flow shipped a reset endpoint that
// took over any account from a phone number alone.
//
// Every response here goes in `data`, auth included. /miler/verify-pin returns
// its payload outside the envelope and that inconsistency cost the miler client
// a release to discover — it is not repeated.
const (
cxOtpTTL = 5 * time.Minute
cxOtpLength = 4
cxResendWait = 30 * time.Second
// cxOtpMaxVerify is attempts per issued code. Three, then the code dies —
// a 4-digit code is 10,000 combinations and generous retries make it
// walkable.
cxOtpMaxVerify = 3
// cxOtpMaxRequests is codes per identifier per hour, so an attacker cannot
// mint fresh codes to reset the attempt counter, and a victim cannot be
// flooded with texts.
cxOtpMaxRequests = 5
cxOtpRequestWin = time.Hour
cxAccessTTL = time.Hour
cxRefreshTTL = 60 * 24 * time.Hour
// cxCustomerRoleID is role 9 across this codebase.
cxCustomerRoleID = 9
cxDefaultConfig = 1001
)
// ── Identifier handling ──────────────────────────────────────────────────────
// normalizeIdentifier canonicalises what the customer typed into either an
// E.164 phone number or a lowercased email address.
//
// The app currently sends "+91 98765 43210" with spaces and is being tightened
// to send E.164; both are accepted, and both must land on the SAME stored
// value, or a customer signing in from a newer build gets a second account.
func normalizeIdentifier(raw string) (identifier, kind string, ok bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", false
}
if strings.Contains(raw, "@") {
email := strings.ToLower(raw)
// Cheap structural check only. Deliverability is proven by the code
// arriving, not by a regex.
at := strings.Index(email, "@")
if at < 1 || at == len(email)-1 || !strings.Contains(email[at:], ".") {
return "", "", false
}
return email, "email", true
}
return normalizePhone(raw)
}
// normalizePhone reduces any of the shapes the app and support staff use to
// E.164 for India.
func normalizePhone(raw string) (phone, kind string, ok bool) {
var digits strings.Builder
plus := strings.HasPrefix(strings.TrimSpace(raw), "+")
for _, r := range raw {
if r >= '0' && r <= '9' {
digits.WriteRune(r)
}
}
d := digits.String()
switch {
case plus && len(d) >= 11 && len(d) <= 15:
// Already international, spaces and dashes removed.
return "+" + d, "phone", true
case len(d) == 10:
// Bare national number, the common case from the keypad.
return "+91" + d, "phone", true
case len(d) == 12 && strings.HasPrefix(d, "91"):
return "+" + d, "phone", true
case len(d) == 11 && strings.HasPrefix(d, "0"):
return "+91" + d[1:], "phone", true
}
return "", "", false
}
// splitName turns the single name field the app collects into the first/last
// columns appcustomers already has. A one-word name keeps an empty last name
// rather than being rejected — plenty of people have one.
func splitName(full string) (first, last string) {
full = strings.Join(strings.Fields(full), " ")
if len([]rune(full)) < 2 {
return "", ""
}
if i := strings.LastIndex(full, " "); i > 0 {
return full[:i], full[i+1:]
}
return full, ""
}
func fullName(c *models.AppCustomer) string {
return strings.TrimSpace(c.Firstname + " " + c.Lastname)
}
// renderCustomer is the one shape the customer object is returned in — from
// verify, from refresh and from /auth/me — so a cold-start session restore
// cannot disagree with what sign-in returned.
//
// email is never null. The client types it as a non-nullable String and a null
// throws in the parser; an unknown address is the empty string.
func renderCustomer(c *models.AppCustomer) fiber.Map {
return fiber.Map{
"id": fmt.Sprintf("cust_%d", c.Appcustomerid),
"name": fullName(c),
"phone": c.Phone,
"email": c.Email,
}
}
// ── OTP storage ──────────────────────────────────────────────────────────────
func cxOtpKey(id string) string { return "cx:otp:" + id }
func cxOtpTriesKey(id string) string { return "cx:otp:" + id + ":tries" }
func cxOtpSentKey(id string) string { return "cx:otp:" + id + ":sent" }
func cxOtpRateKey(id string) string { return "cx:otp:" + id + ":requests" }
func cxGenerateCode() string {
max := big.NewInt(1)
for i := 0; i < cxOtpLength; i++ {
max.Mul(max, big.NewInt(10))
}
n, err := rand.Int(rand.Reader, max)
if err != nil {
return ""
}
return fmt.Sprintf("%0*d", cxOtpLength, n.Int64())
}
// issueCxOtp mints, stores and delivers a code, enforcing both the per-hour
// request cap and the resend cooldown. Returns the seconds the client must wait
// before it may ask again — the countdown is server-driven so it can be changed
// without an app release.
func issueCxOtp(cfg *config.Config, identifier, kind string) (resendAfter int, err error) {
if db.Rdb == nil {
return 0, fmt.Errorf("verification service unavailable")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Cooldown first: a customer hammering Resend should be told to wait, not
// spend one of their five hourly codes on a request that sends nothing.
if ttl, terr := db.Rdb.TTL(ctx, cxOtpSentKey(identifier)).Result(); terr == nil && ttl > 0 {
return int(ttl.Seconds()) + 1, errCxResendTooSoon
}
count, ierr := db.Rdb.Incr(ctx, cxOtpRateKey(identifier)).Result()
if ierr == nil && count == 1 {
db.Rdb.Expire(ctx, cxOtpRateKey(identifier), cxOtpRequestWin)
}
if count > cxOtpMaxRequests {
return int(cxOtpRequestWin.Seconds()), errCxTooManyRequests
}
code := sms.StagingCode()
if code == "" {
code = cxGenerateCode()
}
if code == "" {
return 0, fmt.Errorf("could not generate a verification code")
}
if serr := db.Rdb.Set(ctx, cxOtpKey(identifier), code, cxOtpTTL).Err(); serr != nil {
return 0, serr
}
db.Rdb.Del(ctx, cxOtpTriesKey(identifier))
db.Rdb.Set(ctx, cxOtpSentKey(identifier), "1", cxResendWait)
if kind == "email" {
if merr := mail.SendOTPEmail(cfg, identifier, code); merr != nil {
utils.Warn("cx auth: failed to send OTP email", "error", merr)
return 0, merr
}
} else {
if serr := sms.SendOTP(identifier, code); serr != nil {
utils.Warn("cx auth: failed to send OTP sms", "error", serr)
return 0, serr
}
}
return int(cxResendWait.Seconds()), nil
}
var (
errCxResendTooSoon = fmt.Errorf("resend too soon")
errCxTooManyRequests = fmt.Errorf("too many requests")
)
// consumeCxOtp checks a submitted code and burns it. A code is single-use, and
// a wrong answer costs one of three attempts before the code is destroyed
// outright — otherwise a 4-digit space is walkable.
func consumeCxOtp(identifier, submitted string) bool {
if db.Rdb == nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
stored, err := db.Rdb.Get(ctx, cxOtpKey(identifier)).Result()
if err == goredis.Nil || err != nil {
return false
}
if stored != submitted {
tries, _ := db.Rdb.Incr(ctx, cxOtpTriesKey(identifier)).Result()
db.Rdb.Expire(ctx, cxOtpTriesKey(identifier), cxOtpTTL)
if tries >= cxOtpMaxVerify {
db.Rdb.Del(ctx, cxOtpKey(identifier), cxOtpTriesKey(identifier))
}
return false
}
db.Rdb.Del(ctx, cxOtpKey(identifier), cxOtpTriesKey(identifier))
return true
}
// ── Handlers ─────────────────────────────────────────────────────────────────
// CxRequestOtp sends a sign-in code to a phone or an email address.
//
// It answers the same way whether or not the identifier has an account. Telling
// an anonymous caller "no account found" — which the old /customer/login did —
// turns this endpoint into a directory of who is registered.
func CxRequestOtp(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Identifier string `json:"identifier"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
identifier, kind, ok := normalizeIdentifier(req.Identifier)
if !ok {
return utils.CxBadRequest(c, "Enter a valid phone number or email address")
}
resendAfter, err := issueCxOtp(cfg, identifier, kind)
switch {
case err == errCxResendTooSoon:
// Not an error to the customer — they simply have to wait, and the
// screen already renders a countdown.
return utils.CxOK(c, fiber.Map{
"sent": false,
"resendAfterSeconds": resendAfter,
"codeLength": cxOtpLength,
})
case err == errCxTooManyRequests:
c.Set("Retry-After", fmt.Sprintf("%d", resendAfter))
return utils.CxFail(c, fiber.StatusTooManyRequests, utils.CxErrRateLimited,
"Too many attempts. Try again in a minute")
case err != nil:
utils.Error("CxRequestOtp: could not issue code", "error", err)
return utils.CxInternal(c)
}
return utils.CxOK(c, fiber.Map{
"sent": true,
"resendAfterSeconds": resendAfter,
"codeLength": cxOtpLength,
})
}
}
// CxSignup creates the account and sends the code in one call.
//
// An existing phone number is NOT an error: it is treated as a sign-in and a
// code is sent. The app has no "account already exists" screen, and inventing
// one here would strand a returning customer who tapped Sign up out of habit.
func CxSignup(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Name string `json:"name"`
Phone string `json:"phone"`
Email string `json:"email"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
first, last := splitName(req.Name)
if first == "" {
return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name")
}
phone, _, ok := normalizePhone(req.Phone)
if !ok {
return utils.CxBadRequest(c, "Enter a valid phone number")
}
email := strings.ToLower(strings.TrimSpace(req.Email))
var existing models.AppCustomer
err := db.DB.Where("phone = ?", phone).First(&existing).Error
if err != nil {
// New account. It is created unverified in the sense that nothing
// is signed in yet — the token is only issued once the code comes
// back, so an unfinished signup leaves a row and no session.
customer := models.AppCustomer{
Firstname: first,
Lastname: last,
Phone: phone,
Email: email,
Status: constants.CustomerStatusActive,
Configid: cxDefaultConfig,
}
if cerr := db.DB.Create(&customer).Error; cerr != nil {
utils.Error("CxSignup: could not create customer", "error", cerr)
return utils.CxInternal(c)
}
} else if existing.Status == constants.CustomerStatusBlocked {
return utils.CxForbidden(c, "You do not have access to this")
}
resendAfter, ierr := issueCxOtp(cfg, phone, "phone")
switch {
case ierr == errCxResendTooSoon:
return utils.CxOK(c, fiber.Map{"sent": false, "resendAfterSeconds": resendAfter})
case ierr == errCxTooManyRequests:
c.Set("Retry-After", fmt.Sprintf("%d", resendAfter))
return utils.CxFail(c, fiber.StatusTooManyRequests, utils.CxErrRateLimited,
"Too many attempts. Try again in a minute")
case ierr != nil:
utils.Error("CxSignup: could not issue code", "error", ierr)
return utils.CxInternal(c)
}
return utils.CxOK(c, fiber.Map{"sent": true, "resendAfterSeconds": resendAfter})
}
}
// CxVerifyOtp exchanges a code for a session.
func CxVerifyOtp(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
Identifier string `json:"identifier"`
Code string `json:"code"`
Name string `json:"name"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
identifier, kind, ok := normalizeIdentifier(req.Identifier)
if !ok || strings.TrimSpace(req.Code) == "" {
return utils.CxBadRequest(c, "Enter the code we sent you")
}
if !consumeCxOtp(identifier, strings.TrimSpace(req.Code)) {
return utils.CxFail(c, fiber.StatusUnauthorized, utils.CxErrInvalidOtp, "That code did not match")
}
var customer models.AppCustomer
column := "phone"
if kind == "email" {
column = "email"
}
lookupErr := db.DB.Where(column+" = ?", identifier).First(&customer).Error
if lookupErr != nil {
// Verified an identifier with no account behind it. That is a
// signup completing, and it needs a name — the account is worth
// nothing without one and the app collects it on the same screen.
if kind != "phone" {
return utils.CxNotFound(c, "We could not find an account for that address")
}
first, last := splitName(req.Name)
if first == "" {
return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name")
}
customer = models.AppCustomer{
Firstname: first,
Lastname: last,
Phone: identifier,
Status: constants.CustomerStatusActive,
Configid: cxDefaultConfig,
}
if cerr := db.DB.Create(&customer).Error; cerr != nil {
utils.Error("CxVerifyOtp: could not create customer", "error", cerr)
return utils.CxInternal(c)
}
}
if customer.Status == constants.CustomerStatusBlocked {
return utils.CxForbidden(c, "You do not have access to this")
}
// A name supplied on a verify for an existing account that has none
// (possible for a row created by ops or migrated in) is accepted; it is
// never allowed to overwrite a name already on file from a request that
// only proves possession of the phone.
if first, last := splitName(req.Name); first != "" && customer.Firstname == "" {
customer.Firstname, customer.Lastname = first, last
}
now := time.Now()
customer.Lastloginat = &now
if err := db.DB.Save(&customer).Error; err != nil {
utils.Warn("CxVerifyOtp: could not stamp last login", "error", err)
}
return issueCxSession(c, cfg, &customer)
}
}
// CxRefresh rotates a refresh token for a new pair.
//
// Rotation, not reuse: the presented token is revoked and a new one issued, so
// a token captured from an old device stops working the moment the real device
// refreshes. The chain is recorded via Replacedbyid, which is what makes a
// replayed old token identifiable rather than merely rejected.
func CxRefresh(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req struct {
RefreshToken string `json:"refreshToken"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
presented := strings.TrimSpace(req.RefreshToken)
if presented == "" {
return utils.CxUnauthorized(c, "Please sign in again")
}
var row models.CustomerRefreshToken
if err := db.DB.Where("tokenhash = ?", hashToken(presented)).First(&row).Error; err != nil {
return utils.CxUnauthorized(c, "Please sign in again")
}
if row.Revokedat != nil {
// A revoked token coming back means either a stale client or a
// stolen one, and there is no way to tell them apart. Killing the
// whole chain costs the honest customer one sign-in and costs an
// attacker the session.
utils.Warn("cx auth: revoked refresh token replayed — revoking the customer's sessions",
"customer_id", row.Appcustomerid)
revokeCxSessions(row.Appcustomerid)
return utils.CxUnauthorized(c, "Please sign in again")
}
if utils.IST(row.Expiresat).Before(time.Now()) {
return utils.CxUnauthorized(c, "Please sign in again")
}
var customer models.AppCustomer
if err := db.DB.First(&customer, row.Appcustomerid).Error; err != nil {
return utils.CxUnauthorized(c, "Please sign in again")
}
if customer.Status == constants.CustomerStatusBlocked {
return utils.CxForbidden(c, "You do not have access to this")
}
revoked := time.Now()
row.Revokedat = &revoked
if err := db.DB.Save(&row).Error; err != nil {
utils.Error("CxRefresh: could not revoke the presented token", "error", err)
return utils.CxInternal(c)
}
return issueCxSession(c, cfg, &customer)
}
}
// CxLogout revokes the session and unregisters the device's push token, so a
// signed-out phone stops receiving another person's parcel updates.
func CxLogout(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var req struct {
RefreshToken string `json:"refreshToken"`
DeviceToken string `json:"deviceToken"`
}
_ = c.BodyParser(&req)
now := time.Now()
if strings.TrimSpace(req.RefreshToken) != "" {
db.DB.Model(&models.CustomerRefreshToken{}).
Where("tokenhash = ? AND appcustomerid = ?", hashToken(req.RefreshToken), customerID).
Update("revokedat", now)
} else {
// No token supplied — sign out everywhere rather than leave a session
// the customer believes they ended.
revokeCxSessions(customerID)
}
if strings.TrimSpace(req.DeviceToken) != "" {
db.DB.Where("appcustomerid = ? AND token = ?", customerID, req.DeviceToken).
Delete(&models.CustomerDevice{})
}
return utils.CxOK(c, fiber.Map{"signedOut": true})
}
// CxMe returns the signed-in customer, for cold-start session restore.
func CxMe(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var customer models.AppCustomer
if err := db.DB.First(&customer, customerID).Error; err != nil {
return utils.CxUnauthorized(c, "Please sign in again")
}
if customer.Status == constants.CustomerStatusBlocked {
return utils.CxForbidden(c, "You do not have access to this")
}
return utils.CxOK(c, renderCustomer(&customer))
}
// ── Session issuing ──────────────────────────────────────────────────────────
// issueCxSession mints the access/refresh pair and answers in the shape verify
// and refresh both promise.
func issueCxSession(c *fiber.Ctx, cfg *config.Config, customer *models.AppCustomer) error {
// The JWT carries tenantid like every other token in this system. B2C
// bookings are not attributed to a tenant yet (that is an open business
// decision, not something to guess), so it is 0 here — but the claim is
// present, and every customer read is scoped by appcustomerid regardless.
access, err := utils.GenerateTokenWithTTL(
customer.Appcustomerid, customer.Phone, cxCustomerRoleID, 0,
customer.Configid, cfg.JWTSecret, cxAccessTTL)
if err != nil {
utils.Error("issueCxSession: could not mint access token", "error", err)
return utils.CxInternal(c)
}
refresh, err := newRefreshToken()
if err != nil {
utils.Error("issueCxSession: could not mint refresh token", "error", err)
return utils.CxInternal(c)
}
row := models.CustomerRefreshToken{
Appcustomerid: customer.Appcustomerid,
Tokenhash: hashToken(refresh),
Expiresat: utils.DBNow().Add(cxRefreshTTL),
}
if err := db.DB.Create(&row).Error; err != nil {
utils.Error("issueCxSession: could not store refresh token", "error", err)
return utils.CxInternal(c)
}
return utils.CxOK(c, fiber.Map{
"accessToken": access,
"refreshToken": refresh,
"expiresIn": int(cxAccessTTL.Seconds()),
"customer": renderCustomer(customer),
})
}
// newRefreshToken returns 32 bytes of entropy, hex encoded. Long enough that
// guessing is not a threat model.
func newRefreshToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// hashToken is what actually lands in the database. A refresh token is a
// bearer credential valid for sixty days; storing it in plaintext would make a
// database read equivalent to sixty days of account access.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func revokeCxSessions(customerID int) {
if err := db.DB.Model(&models.CustomerRefreshToken{}).
Where("appcustomerid = ? AND revokedat IS NULL", customerID).
Update("revokedat", time.Now()).Error; err != nil {
utils.Error("revokeCxSessions: failed", "customer_id", customerID, "error", err)
}
}
// joinNonEmpty builds a display line from the parts that actually exist, so a
// missing landmark does not leave ", , " in the middle of an address.
func joinNonEmpty(sep string, parts ...string) string {
kept := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
kept = append(kept, s)
}
}
return strings.Join(kept, sep)
}

View File

@@ -0,0 +1,904 @@
package controllers
import (
"encoding/json"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/assignment"
"doormile/internal/cxstage"
"doormile/middlewares"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Bookings — §9 of the contract.
//
// A customer books a PICKUP: one visit, 1..N destinations, and no tracking
// number anywhere in this file. Tracking numbers are minted per destination
// when the miler completes the pickup, because until the parcels are in
// someone's hands there is no shipment to track — only an intention to collect
// one.
const (
cxDefaultPageSize = 20
cxMaxPageSize = 50
// cxAbsoluteMaxDestinations is a hard ceiling checked BEFORE any database
// work, independent of the configured per-city cap.
//
// The configured cap (customerbookinglimits.maxdestinations) is the real
// policy and stays authoritative — but reading it costs two queries, and
// the district lookup above it builds a `WHERE districtcode IN (...)` from
// however many entries the caller sent. Without a ceiling, a request
// carrying ten thousand destinations does all of that work before anything
// says no.
//
// Set well above any plausible policy so it never masks the real cap: this
// is a sanity guard on unbounded input, not a product limit.
cxAbsoluteMaxDestinations = 25
)
type cxDetailsInput struct {
Street *string `json:"street"`
Building *string `json:"building"`
Landmark *string `json:"landmark"`
RecipientName *string `json:"recipientName"`
RecipientPhone *string `json:"recipientPhone"`
Instructions *string `json:"instructions"`
Pin *struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
} `json:"pin"`
// CodAmount is money the customer wants collected at this door on their
// behalf. Doormile is the carrier, not the seller.
CodAmount *float64 `json:"codAmount"`
}
type cxCreateBookingRequest struct {
Pickup struct {
Title string `json:"title"`
Sub string `json:"sub"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
} `json:"pickup"`
SlotID string `json:"slotId"`
Destinations []struct {
StateCode string `json:"stateCode"`
DistrictCode string `json:"districtCode"`
PackageCount int `json:"packageCount"`
Details *cxDetailsInput `json:"details"`
} `json:"destinations"`
// Estimate is what the customer was shown on Review. Recorded for dispute
// audit — when the settled price is questioned months later, the number on
// the screen is the fact that matters, not a re-run of today's pricing.
Estimate *struct {
Min int `json:"min"`
Max int `json:"max"`
} `json:"estimate"`
// Remarks is the free-text note the customer adds on Review ("Handle with
// care"). It is top-level in the documented payload
// (docs/customer-app-api-crisp.md) and lands in PickupBooking.Notes, which
// the admin Orders table displays and searches. Without the field here
// BodyParser drops it silently and every customer-app booking reaches the
// console with an empty note.
Remarks string `json:"remarks"`
}
// CreateCxBooking creates the pickup.
func CreateCxBooking(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var req cxCreateBookingRequest
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
// "No destinations at all" and "a destination is missing its state or
// district" are different problems with different fixes, and the customer
// is shown this message verbatim. Telling someone who added nothing that
// "every destination needs a serviceable state and district" describes a
// problem they do not have and hides the one they do — they need to add a
// destination, not correct one. The estimate endpoint already says this
// correctly; these two now agree.
if len(req.Destinations) == 0 {
return utils.CxBadRequest(c, "Add at least one destination")
}
if strings.TrimSpace(req.SlotID) == "" {
return utils.CxBadRequest(c, "Pick a pickup slot")
}
// Cheapest validation first, before anything reaches the database. A slot id
// carries its own date, so a stale one is provably stale without a query —
// and this is the case an app left open across midnight actually hits.
if CxSlotDateIsPast(req.SlotID) {
return utils.CxBadRequest(c, "That pickup time has passed — pick a new slot")
}
// Unbounded input, bounded before it costs anything. The configured cap
// below is the real policy; this only stops a caller making the server do
// three queries and build an arbitrarily long IN clause to be told no.
if len(req.Destinations) > cxAbsoluteMaxDestinations {
return utils.CxBadRequest(c, "That is more destinations than one pickup can carry")
}
// The pickup point has to be somewhere Doormile actually collects from.
// CityGateMiddleware sniffs the body for a `pickuppincode`, which this
// request shape does not have — its pickup is a title/sub/lat/lng from the
// place search — so it waves every customer booking through. The check is
// done here, against the pincode resolved from the coordinates.
pickupPincode := pincodeForPoint(req.Pickup.Lat, req.Pickup.Lng)
if _, served := middlewares.PincodeInOperatingCity(pickupPincode); !served {
return utils.CxFail(c, fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable,
"We are not collecting from that area yet")
}
appLocationID := appLocationForPoint(req.Pickup.Lat, req.Pickup.Lng)
maxPackages, maxDestinations := CxBookingLimits(appLocationID)
if len(req.Destinations) > maxDestinations {
return utils.CxBadRequest(c, "Up to "+strconv.Itoa(maxDestinations)+" destinations per pickup")
}
// Resolve every district in one query, then validate. Names are copied onto
// the destination rather than joined at read time — the client renders
// "Chennai, Tamil Nadu" straight from the booking.
codes := make([]string, 0, len(req.Destinations))
for _, d := range req.Destinations {
codes = append(codes, strings.ToUpper(strings.TrimSpace(d.DistrictCode)))
}
var districtRows []models.ServiceableDistrict
if err := db.DB.Where("districtcode IN ?", codes).Find(&districtRows).Error; err != nil {
utils.Error("CreateCxBooking: district lookup failed", "error", err)
return utils.CxInternal(c)
}
districts := make(map[string]models.ServiceableDistrict, len(districtRows))
for _, d := range districtRows {
districts[d.Districtcode] = d
}
stateNames, err := cxStateNames(districtRows)
if err != nil {
utils.Error("CreateCxBooking: state lookup failed", "error", err)
return utils.CxInternal(c)
}
totalPackages := 0
for _, d := range req.Destinations {
code := strings.ToUpper(strings.TrimSpace(d.DistrictCode))
district, ok := districts[code]
if !ok || strings.TrimSpace(d.StateCode) == "" {
return utils.CxBadRequest(c, "Every destination needs a serviceable state and district")
}
if !district.Available {
// The district was open when the customer picked it and closed
// before they confirmed. A distinct code, because the app has a
// specific recovery for it: send them back to change that one
// destination rather than to a generic retry.
return utils.CxFail(c, fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable,
"That district is no longer available")
}
packages := d.PackageCount
if packages < 1 {
packages = 1
}
totalPackages += packages
}
if totalPackages > maxPackages {
return utils.CxBadRequest(c, "Up to "+strconv.Itoa(maxPackages)+" packages per pickup")
}
slotFrom, slotTo, slotOK := ResolveCxSlot(req.SlotID)
if !slotOK {
return utils.CxBadRequest(c, "Pick a pickup slot")
}
// An EXPIRED slot and a FULL slot are different failures and must not share
// a message. A slot id encodes its own date, so an app left open across
// midnight — or one that cached the slot list for a session — sends
// yesterday's window in good faith. Telling that customer the window "just
// filled up" is untrue and points them at the wrong recovery: they need to
// re-fetch the slot list, not try again for a place in a queue.
//
// 400 rather than 409 for the same reason. The contract maps 400/invalid to
// "Pick a pickup slot", which is exactly the action required, while 409 is
// the capacity race below.
if !slotFrom.After(utils.ISTNow()) {
return utils.CxBadRequest(c, "That pickup time has passed — pick a new slot")
}
if !CxSlotHasCapacity(req.SlotID, req.Pickup.Lat, req.Pickup.Lng) {
return utils.CxConflict(c, "That pickup window just filled up")
}
// Price the pickup as one visit. A failed estimate must never block a
// booking, so a zero range is stored rather than an error returned — the
// receipt settles on what the miler weighs regardless.
estimateDestinations := make([]cxEstimateDestination, 0, len(req.Destinations))
for _, d := range req.Destinations {
estimateDestinations = append(estimateDestinations, cxEstimateDestination{
StateCode: d.StateCode,
DistrictCode: d.DistrictCode,
PackageCount: d.PackageCount,
})
}
quote := quoteCxPickup(req.Pickup.Lat, req.Pickup.Lng, estimateDestinations)
estimateMin, estimateMax := quote.Min, quote.Max
if req.Estimate != nil && req.Estimate.Max > 0 {
// The customer's number wins. They agreed to what was on their screen,
// and re-pricing at confirm time would quietly change the deal.
estimateMin, estimateMax = req.Estimate.Min, req.Estimate.Max
}
now := utils.DBNow()
pickupFromDB := cxToDBTime(slotFrom)
pickupToDB := cxToDBTime(slotTo)
first := req.Destinations[0]
firstDistrict := districts[strings.ToUpper(strings.TrimSpace(first.DistrictCode))]
booking := models.PickupBooking{
Bookingno: generateBookingNo(),
Appcustomerid: customerID,
Pickupaddress: joinNonEmpty(", ", req.Pickup.Title, req.Pickup.Sub),
Pickuptitle: req.Pickup.Title,
Pickupsub: req.Pickup.Sub,
Pickuppincode: pickupPincode,
Pickuplatitude: req.Pickup.Lat,
Pickuplongitude: req.Pickup.Lng,
// The flat delivery columns mirror destination 0. They are NOT the
// destination list — that lives in bookingdestinations — but the miler
// app, the hub console, the routing code and the hyperlocal check all
// read them, and leaving them empty would make a customer-app booking
// invisible to every one of those. Destination 0 is the one the rider
// is told about first, so it is the one that mirrors.
Deliveryaddress: cxDestinationAddress(first.Details, firstDistrict),
Deliverypincode: firstDistrict.Pincodeprefix,
Deliverylatitude: firstDistrict.Centrelatitude,
Deliverylongitude: firstDistrict.Centrelongitude,
Deliverycity: firstDistrict.Districtname,
Bookingsource: constants.BookingSourceCustomerApp,
Pickupsourcetype: constants.PickupSourceCustomer,
Status: constants.BookingPendingPickup,
Preferredpickupfrom: &pickupFromDB,
Preferredpickupto: &pickupToDB,
Slotid: req.SlotID,
Customerstage: constants.CxStageBooked,
Customerstatus: constants.CxStatusActive,
Estimateminrupees: estimateMin,
Estimatemaxrupees: estimateMax,
Routekm: quote.RouteKM,
Notes: req.Remarks,
Createdat: now,
Updatedat: now,
}
if pin := cxFirstPin(first.Details); pin != nil {
booking.Deliverylatitude, booking.Deliverylongitude = pin[0], pin[1]
}
tx := db.DB.Begin()
if err := tx.Create(&booking).Error; err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not create booking", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
for i, d := range req.Destinations {
code := strings.ToUpper(strings.TrimSpace(d.DistrictCode))
district := districts[code]
packages := d.PackageCount
if packages < 1 {
packages = 1
}
dest := models.BookingDestination{
Bookingid: booking.Bookingid,
Seq: i,
Statecode: district.Statecode,
Statename: stateNames[district.Statecode],
Districtcode: district.Districtcode,
Districtname: district.Districtname,
Packagecount: packages,
Pincode: district.Pincodeprefix,
Createdat: now,
Updatedat: now,
}
applyCxDetails(&dest, d.Details)
if err := tx.Create(&dest).Error; err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not create destination", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
// One parcel row per package, linked to its destination. The miler
// weighs and photographs each package at the door, so each needs a row
// to be weighed into — and each has to know which order it belongs to.
// Weight is deliberately left at zero: it is never collected from the
// customer, and a guess here would look like a measurement on the
// receipt.
for p := 0; p < packages; p++ {
parcel := models.BookingParcel{
Bookingid: booking.Bookingid,
Bookingdestinationid: &dest.Bookingdestinationid,
Itemcategory: "General",
Createdat: now,
Updatedat: now,
}
if err := tx.Create(&parcel).Error; err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not create parcel", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
}
}
// The service option is what the REST of the platform reads a booking's
// value from, and it is not optional just because the customer surface
// keeps its own estimate columns:
//
// * MilerDeliverConsignment and MilerInwardConsignmentAtHub copy
// Estimatedprice onto BookingAssignment.ridercharges when a leg closes,
// and GET /miler/earnings sums that column — so with no row here every
// customer-app job a rider completes would show ₹0 on their Earnings
// screen.
// * The admin console's Orders list renders serviceoptions[0].estimatedprice
// as the Price column, which would read "N/A" for every customer booking.
//
// The midpoint of the band the customer was shown is the honest figure
// before the miler weighs anything, and it is what lookupDoormilePrice
// returns everywhere else in this codebase.
slaDue := cxToDBTime(slotTo.Add(48 * time.Hour))
estimatedDelivery := cxToDBTime(slotTo.Add(24 * time.Hour))
srvOption := models.BookingServiceOption{
Bookingid: booking.Bookingid,
Servicetype: "Normal",
Estimatedprice: float64(estimateMin+estimateMax) / 2,
Estimateddeliveryat: &estimatedDelivery,
Sladueat: &slaDue,
Createdat: now,
}
if err := tx.Create(&srvOption).Error; err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not create service option", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
Stage: constants.CxStageBooked,
ActorType: constants.CxActorCustomer,
ActorID: &customerID,
Source: "POST /customer/bookings",
At: now,
}); err != nil {
tx.Rollback()
utils.Error("CreateCxBooking: could not record booked stage", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
if err := tx.Commit().Error; err != nil {
utils.Error("CreateCxBooking: commit failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
// Fire-and-forget, after commit, exactly as the express path does.
go assignment.AssignCustomerMiler(booking.Bookingid)
publishCxBookingCreated(&booking, len(req.Destinations))
return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusCreated)
}
// GetCxBookings backs the Orders tabs, Home's recent list and pull-to-refresh.
func GetCxBookings(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
limit := cxDefaultPageSize
if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 {
limit = v
}
if limit > cxMaxPageSize {
limit = cxMaxPageSize
}
// One filter, applied to both the page and the count, so `total` is the size
// of the tab the customer is actually looking at. Counting every booking
// they have ever made would put "48" above a Cancelled tab holding two rows.
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
scoped := func() *gorm.DB {
q := db.DB.Model(&models.PickupBooking{}).Where("appcustomerid = ?", customerID)
switch status {
case constants.CxStatusActive:
// Rows written before this surface existed carry no customerstatus.
// Treating a null as active keeps a live booking visible rather
// than hiding it from the customer who is waiting on it.
// Parenthesised explicitly rather than relying on AND binding
// tighter than OR — the two readings differ by "shows every
// cancelled booking in the active tab", which is not a thing to
// leave to operator precedence.
q = q.Where("(customerstatus = ?) OR ((customerstatus IS NULL OR customerstatus = '') AND status <> ?)",
constants.CxStatusActive, constants.BookingCancelled)
case constants.CxStatusCompleted:
q = q.Where("customerstatus = ?", constants.CxStatusCompleted)
case constants.CxStatusCancelled:
q = q.Where("customerstatus = ? OR status = ?", constants.CxStatusCancelled, constants.BookingCancelled)
}
return q
}
q := scoped()
// Keyset pagination on the primary key. Offsets drift when a new booking
// lands mid-scroll, which shows the customer the same row twice.
if cursor := strings.TrimSpace(c.Query("cursor")); cursor != "" {
if after, err := strconv.Atoi(cursor); err == nil {
q = q.Where("bookingid < ?", after)
}
}
var bookings []models.PickupBooking
if err := q.Order("bookingid DESC").Limit(limit + 1).Find(&bookings).Error; err != nil {
utils.Error("GetCxBookings: query failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
var nextCursor *string
if len(bookings) > limit {
bookings = bookings[:limit]
next := strconv.Itoa(bookings[len(bookings)-1].Bookingid)
nextCursor = &next
}
bundle := loadCxBundle(bookings)
out := make([]fiber.Map, 0, len(bookings))
for i := range bookings {
out = append(out, renderCxBooking(&bookings[i], bundle))
}
var total int64
if err := scoped().Count(&total).Error; err != nil {
utils.Warn("GetCxBookings: count failed, reporting the page size", "customer_id", customerID, "error", err)
total = int64(len(out))
}
return utils.CxList(c, out, int(total), nextCursor)
}
// GetCxBookingDetail is the canonical read — the tracking screen and the
// receipt are both rendered from it, and it is polled while tracking is open.
func GetCxBookingDetail(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
reference := strings.TrimSpace(c.Params("reference"))
booking, err := cxLoadBooking(customerID, reference)
if err != nil {
return utils.CxNotFound(c, "We could not find that pickup")
}
bundle := loadCxBundle([]models.PickupBooking{*booking})
payload := renderCxBooking(booking, bundle)
// Polled every few seconds while the tracking screen is open. A 304 turns
// most of those polls into a header exchange instead of a full render.
if served := serveIfNotModified(c, payload); served {
return nil
}
c.Set("Cache-Control", "no-cache")
return utils.CxOK(c, payload)
}
// GetCxOrder returns one order by tracking number, for push deep links.
//
// It answers with the whole booking object rather than a slimmer order shape —
// the client already parses this one, and a second shape for the same data is
// a second parser to keep in step.
func GetCxOrder(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
trackingID := strings.TrimSpace(c.Params("trackingId"))
if trackingID == "" {
return utils.CxNotFound(c, "We could not find that order")
}
var dest models.BookingDestination
if err := db.DB.Where("trackingno = ?", trackingID).First(&dest).Error; err != nil {
return utils.CxNotFound(c, "We could not find that order")
}
var booking models.PickupBooking
if err := db.DB.Where("bookingid = ? AND appcustomerid = ?", dest.Bookingid, customerID).
First(&booking).Error; err != nil {
// The tracking number exists but belongs to someone else. Answered as
// not-found rather than forbidden: confirming that a tracking number is
// real tells an enumerating caller something they should not learn.
return utils.CxNotFound(c, "We could not find that order")
}
bundle := loadCxBundle([]models.PickupBooking{booking})
return utils.CxOK(c, renderCxBooking(&booking, bundle))
}
// CancelCxBooking cancels the whole pickup.
//
// Allowed through arrived and refused from picked_up onward. `cancellable` on
// the booking mirrors the same policy so the UI can hide the button, but this
// re-checks — the button state is a hint the client renders from a response
// that may be seconds old, never the authority.
func CancelCxBooking(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
reference := strings.TrimSpace(c.Params("reference"))
var req struct {
Reason string `json:"reason"`
}
_ = c.BodyParser(&req)
booking, err := cxLoadBooking(customerID, reference)
if err != nil {
return utils.CxNotFound(c, "We could not find that pickup")
}
if booking.Customerstatus == constants.CxStatusCancelled ||
booking.Status == constants.BookingCancelled {
// Already cancelled. Answered as success rather than as a conflict: the
// customer asked for a state the booking is already in, and a retry
// over a flaky network must not read as a failure.
return utils.CxOK(c, fiber.Map{
"reference": booking.Bookingno,
"status": constants.CxStatusCancelled,
"cancelReason": booking.Cancelreason,
})
}
stage := booking.Customerstage
if stage == "" {
stage = deriveStageFromStatus(booking)
}
if !constants.CxCancellable(stage) {
return utils.CxConflict(c, "This pickup can no longer be cancelled")
}
reason := strings.TrimSpace(req.Reason)
tx := db.DB.Begin()
if err := cxstage.Cancel(tx, booking.Bookingid, reason,
constants.CxActorCustomer, &customerID, "POST /customer/bookings/{reference}/cancel"); err != nil {
tx.Rollback()
utils.Error("CancelCxBooking: cancel failed", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
// Release the rider. Without this the assignment stays open, the rider
// keeps a stop they must not attempt, and MilerEndDuty refuses to let them
// go off duty while any assignment is still Assigned or Accepted.
if err := tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND assignmentstatus IN ?", booking.Bookingid,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentCancelled,
"remarks": "cancelled by customer",
}).Error; err != nil {
tx.Rollback()
utils.Error("CancelCxBooking: could not release assignment", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
if booking.Assignedmileruserid != nil {
if err := tx.Model(&models.MilerProfile{}).
Where("userid = ? AND availabilitystatus IN ?", *booking.Assignedmileruserid,
[]string{constants.MilerAssigned, constants.MilerOnPickup, constants.MilerAtCustomer}).
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
tx.Rollback()
utils.Error("CancelCxBooking: could not free the rider", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
}
if err := tx.Commit().Error; err != nil {
utils.Error("CancelCxBooking: commit failed", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
publishCxBookingCancelled(booking, reason)
return utils.CxOK(c, fiber.Map{
"reference": booking.Bookingno,
"status": constants.CxStatusCancelled,
"cancelReason": reason,
})
}
// PatchCxDestination fills in the parts of an address the customer left out.
//
// Accepted until the parcels are collected. After that the shipment's addresses
// are frozen on the consignment and an edit here would change what the customer
// sees without changing where the parcel is going — which is worse than
// refusing.
//
// Writes land on the same booking row the miler app reads addresses from, so a
// correction made while the rider is on their way reaches them.
func PatchCxDestination(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
reference := strings.TrimSpace(c.Params("reference"))
index, err := strconv.Atoi(c.Params("index"))
if err != nil || index < 0 {
return utils.CxNotFound(c, "We could not find that destination")
}
booking, err := cxLoadBooking(customerID, reference)
if err != nil {
return utils.CxNotFound(c, "We could not find that pickup")
}
stage := booking.Customerstage
if stage == "" {
stage = deriveStageFromStatus(booking)
}
if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStagePickedUp] {
return utils.CxConflict(c, "Your packages have been collected — these details can no longer be changed")
}
if booking.Customerstatus == constants.CxStatusCancelled || booking.Status == constants.BookingCancelled {
return utils.CxConflict(c, "This pickup was cancelled")
}
var dest models.BookingDestination
if err := db.DB.Where("bookingid = ? AND seq = ?", booking.Bookingid, index).
First(&dest).Error; err != nil {
return utils.CxNotFound(c, "We could not find that destination")
}
var req cxDetailsInput
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
applyCxDetails(&dest, &req)
dest.Updatedat = utils.DBNow()
tx := db.DB.Begin()
if err := tx.Save(&dest).Error; err != nil {
tx.Rollback()
utils.Error("PatchCxDestination: save failed", "destination_id", dest.Bookingdestinationid, "error", err)
return utils.CxInternal(c)
}
// Destination 0 mirrors onto the booking's flat delivery columns, which is
// where the miler app and the routing code look. An edit that only landed
// on bookingdestinations would be invisible to the rider standing at the
// door, which is precisely who it was made for.
if dest.Seq == 0 {
updates := map[string]interface{}{
"deliveryaddress": cxDestinationAddressFromRow(&dest),
"updatedat": utils.DBNow(),
}
if dest.Pinlatitude != nil && dest.Pinlongitude != nil {
updates["deliverylatitude"] = *dest.Pinlatitude
updates["deliverylongitude"] = *dest.Pinlongitude
}
if err := tx.Model(&models.PickupBooking{}).
Where("bookingid = ?", booking.Bookingid).Updates(updates).Error; err != nil {
tx.Rollback()
utils.Error("PatchCxDestination: could not mirror onto booking", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
}
if err := tx.Commit().Error; err != nil {
utils.Error("PatchCxDestination: commit failed", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusOK)
}
// ── Helpers ──────────────────────────────────────────────────────────────────
// cxLoadBooking finds a booking by its customer-facing reference, scoped to the
// caller. Ownership is part of the lookup, not a check afterwards — a query
// that can return someone else's row is one forgotten `if` away from leaking it.
func cxLoadBooking(customerID int, reference string) (*models.PickupBooking, error) {
if reference == "" {
return nil, gorm.ErrRecordNotFound
}
var booking models.PickupBooking
if err := db.DB.Where("bookingno = ? AND appcustomerid = ?", reference, customerID).
First(&booking).Error; err != nil {
return nil, err
}
return &booking, nil
}
// cxRespondWithBooking re-reads and renders, so a create or a patch answers in
// exactly the shape a later GET will.
func cxRespondWithBooking(c *fiber.Ctx, bookingID, status int) error {
var booking models.PickupBooking
if err := db.DB.First(&booking, bookingID).Error; err != nil {
utils.Error("cxRespondWithBooking: reload failed", "booking_id", bookingID, "error", err)
return utils.CxInternal(c)
}
bundle := loadCxBundle([]models.PickupBooking{booking})
payload := renderCxBooking(&booking, bundle)
if status == fiber.StatusCreated {
return utils.CxCreated(c, payload)
}
return utils.CxOK(c, payload)
}
// applyCxDetails writes only the fields actually present in the request. An
// omitted key leaves the stored value alone; an explicit null clears it, which
// is how the customer removes a landmark they no longer want the rider to use.
func applyCxDetails(dest *models.BookingDestination, in *cxDetailsInput) {
if in == nil {
return
}
if in.Street != nil {
dest.Street = strings.TrimSpace(*in.Street)
}
if in.Building != nil {
dest.Building = strings.TrimSpace(*in.Building)
}
if in.Landmark != nil {
dest.Landmark = strings.TrimSpace(*in.Landmark)
}
if in.RecipientName != nil {
dest.Recipientname = strings.TrimSpace(*in.RecipientName)
}
if in.RecipientPhone != nil {
if phone, _, ok := normalizePhone(*in.RecipientPhone); ok {
dest.Recipientphone = phone
} else {
dest.Recipientphone = strings.TrimSpace(*in.RecipientPhone)
}
}
if in.Instructions != nil {
dest.Instructions = strings.TrimSpace(*in.Instructions)
}
if in.Pin != nil {
lat, lng := in.Pin.Lat, in.Pin.Lng
if lat == 0 && lng == 0 {
dest.Pinlatitude, dest.Pinlongitude = nil, nil
} else {
dest.Pinlatitude, dest.Pinlongitude = &lat, &lng
}
}
if in.CodAmount != nil {
dest.Codamount = *in.CodAmount
}
}
func cxFirstPin(in *cxDetailsInput) *[2]float64 {
if in == nil || in.Pin == nil {
return nil
}
if in.Pin.Lat == 0 && in.Pin.Lng == 0 {
return nil
}
return &[2]float64{in.Pin.Lat, in.Pin.Lng}
}
// cxDestinationAddress builds the flat address string the rest of the system
// stores, from whatever the customer supplied. It always resolves to something
// non-empty — pickupbookings.deliveryaddress is NOT NULL, and a booking with
// only a state and a district is a legitimate booking.
func cxDestinationAddress(in *cxDetailsInput, district models.ServiceableDistrict) string {
parts := []string{}
if in != nil {
if in.Building != nil {
parts = append(parts, *in.Building)
}
if in.Street != nil {
parts = append(parts, *in.Street)
}
if in.Landmark != nil {
parts = append(parts, *in.Landmark)
}
}
parts = append(parts, district.Districtname)
address := joinNonEmpty(", ", parts...)
if address == "" {
return district.Districtcode
}
return address
}
func cxDestinationAddressFromRow(d *models.BookingDestination) string {
address := joinNonEmpty(", ", d.Building, d.Street, d.Landmark, d.Districtname)
if address == "" {
return d.Districtcode
}
return address
}
// cxStateNames resolves display names for the states a set of districts belong
// to, in one query.
func cxStateNames(districts []models.ServiceableDistrict) (map[string]string, error) {
codes := make([]string, 0, len(districts))
seen := map[string]bool{}
for _, d := range districts {
if d.Statecode != "" && !seen[d.Statecode] {
seen[d.Statecode] = true
codes = append(codes, d.Statecode)
}
}
names := make(map[string]string, len(codes))
if len(codes) == 0 {
return names, nil
}
var states []models.ServiceableState
if err := db.DB.Where("statecode IN ?", codes).Find(&states).Error; err != nil {
return names, err
}
for _, s := range states {
names[s.Statecode] = s.Statename
}
return names, nil
}
// cxToDBTime converts an IST wall clock into the shape this database stores —
// the same digits, tagged UTC so the driver writes them verbatim. See
// utils.DBNow: comparing a container's UTC clock against IST-stamped rows is
// what made date-range reports undercount.
func cxToDBTime(t time.Time) time.Time {
ist := t.In(utils.ISTLocation())
return time.Date(ist.Year(), ist.Month(), ist.Day(), ist.Hour(), ist.Minute(), ist.Second(), 0, time.UTC)
}
// ── Events ───────────────────────────────────────────────────────────────────
// publishCxBookingCreated and publishCxBookingCancelled mirror the existing
// booking events onto NATS. Best-effort and nil-checked, like every other
// publish in this codebase: the event bus is never allowed to fail a booking.
func publishCxBookingCreated(b *models.PickupBooking, destinationCount int) {
if db.Js == nil {
return
}
payload := map[string]interface{}{
"booking_id": b.Bookingid,
"booking_no": b.Bookingno,
"customer_id": b.Appcustomerid,
"pickup_address": b.Pickupaddress,
"pickup_pincode": b.Pickuppincode,
"destination_count": destinationCount,
"slot_id": b.Slotid,
"status": constants.BookingPendingPickup,
"created_at": utils.EpochMillis(b.Createdat),
}
data, err := json.Marshal(payload)
if err != nil {
return
}
if _, err := db.Js.Publish("api.v1.bookings.create", data); err != nil {
utils.Warn("Failed to publish booking.create to NATS", "booking_id", b.Bookingid, "error", err)
}
}
func publishCxBookingCancelled(b *models.PickupBooking, reason string) {
if db.Js == nil {
return
}
payload := map[string]interface{}{
"booking_id": b.Bookingid,
"booking_no": b.Bookingno,
"customer_id": b.Appcustomerid,
"status": "Cancelled",
"reason": reason,
"cancelled_at": time.Now().UnixMilli(),
}
data, err := json.Marshal(payload)
if err != nil {
return
}
if _, err := db.Js.Publish("api.v1.bookings.cancel", data); err != nil {
utils.Warn("Failed to publish booking.cancel to NATS", "booking_id", b.Bookingid, "error", err)
}
}

View File

@@ -0,0 +1,760 @@
package controllers
import (
"context"
"os"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/storage"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// The canonical booking object — §9.3.
//
// The tracking screen and the receipt are both rendered from this one shape, so
// it is built in exactly one place and every read that returns a booking goes
// through it. A list row and a detail read differing in shape is how a client
// ends up with two parsers for one object.
//
// Two rules the client's type declarations depend on, and which will throw in
// its parser if broken:
// - pickup and slotId are present on EVERY booking, cancelled ones included.
// - destinations[].stateName / districtName are always populated; the client
// renders "Chennai, Tamil Nadu" from them and never looks a code up.
// cxPhotoTTL is how long a parcel-photo link stays valid. Long enough to open
// the receipt, read it and come back; short enough that a link forwarded on is
// dead by the time it is opened.
const cxPhotoTTL = 30 * time.Minute
// cxBookingBundle is everything one or more bookings need in order to render,
// loaded in batch. Building it per booking would put six queries behind a
// tracking poll that runs every few seconds.
type cxBookingBundle struct {
destinations map[int][]models.BookingDestination
events map[int][]models.BookingStageEvent
photos map[int][]models.BookingParcelPhoto // keyed by destination id
milers map[int]cxAgent // keyed by miler user id
assignedTo map[int]int // booking id -> miler user id
deliveryAgent map[int]int // destination id -> agent user id
payments map[int]float64 // booking id -> settled rupees
districts map[string]models.ServiceableDistrict
hubNames map[int]string
// single marks a one-booking read (the tracking screen or the receipt), as
// opposed to a page of them. A couple of fields are worth a live lookup for
// one booking and are not worth twenty of them for a list.
single bool
}
type cxAgent struct {
Name string
Vehicle string
Phone string
Rating float64
Trips int
VehicleType string
Lat, Lng float64
}
// renderCxBooking projects one booking into the customer contract.
func renderCxBooking(b *models.PickupBooking, bundle *cxBookingBundle) fiber.Map {
destinations := bundle.destinations[b.Bookingid]
stage := b.Customerstage
if stage == "" {
// A booking written before this surface existed, or one created through
// the console. Deriving a stage from the operational status is honest
// about where the parcel is; inventing history for it is not, so its
// timeline stays as short as the events actually recorded.
stage = deriveStageFromStatus(b)
}
status := b.Customerstatus
if status == "" {
status = deriveStatus(b, stage)
}
// The OPERATIONAL status is the authority on cancellation, whatever the
// stored customer status says.
//
// Three console paths cancel a booking by writing pickupbookings.status
// directly — AdminCancelBooking, AdminBulkCancelBookings and
// AdminUpdateBookingStatus (which accepts an arbitrary status string). None
// of them knows this projection exists. Without this line a customer whose
// pickup ops cancelled would keep seeing it as active and cancellable
// forever, because customerstatus was written as "active" at booking time
// and the empty-string fallback above never fires.
//
// Done here rather than only at those call sites so that a cancel path
// added later cannot reintroduce the same divergence.
if b.Status == constants.BookingCancelled {
status = constants.CxStatusCancelled
}
out := fiber.Map{
"reference": b.Bookingno,
"stage": stage,
"status": status,
"cancellable": status == constants.CxStatusActive && constants.CxCancellable(stage),
"createdAt": utils.EpochMillis(b.Createdat),
"pickup": fiber.Map{
"title": cxPickupTitle(b),
"sub": cxPickupSub(b),
"lat": b.Pickuplatitude,
"lng": b.Pickuplongitude,
},
"slotId": b.Slotid,
"destinations": renderCxDestinations(destinations, bundle),
"miler": nil,
"deliveryAgent": nil,
"milerDistanceKm": nil,
"milerEtaMinutes": nil,
"milersInZone": 0,
"routeKm": b.Routekm,
"expectedDelivery": cxExpectedDelivery(destinations),
"fare": fiber.Map{
"min": b.Estimateminrupees,
"max": b.Estimatemaxrupees,
"paymentMethod": "UPI · Cash at doorstep",
"parcel": describeParcels(totalPackages(destinations)),
},
"amountPaid": nil,
"deliveredAt": nil,
"cancelReason": nil,
"history": renderCxHistory(bundle.events[b.Bookingid]),
}
if b.Cancelreason != "" {
out["cancelReason"] = b.Cancelreason
}
// The assigned miler, from the stage they are assigned onward.
if milerUserID, ok := bundle.assignedTo[b.Bookingid]; ok {
if agent, found := bundle.milers[milerUserID]; found {
out["miler"] = renderCxAgent(agent)
// Distance and ETA are live facts about a rider en route, so they
// are computed from the rider's current position rather than
// stored. Only meaningful while they are actually coming: after
// pickup the number would describe a journey that already ended.
if stage == constants.CxStageOnTheWay || stage == constants.CxStageArrived {
km, eta := cxRiderApproach(agent, b, stage)
out["milerDistanceKm"] = km
out["milerEtaMinutes"] = eta
}
}
} else if stage == constants.CxStageBooked && bundle.single {
// Nobody assigned yet — the tracking screen shows how many riders are
// in the zone instead, which is the only honest thing to say while
// searching.
//
// Only on a single-booking read. This is a Redis GEOSEARCH per booking,
// and running it across a 20-row Orders list would put twenty of them
// behind one page load to fill a line the list does not render.
out["milersInZone"] = milersWithin(b.Pickuplatitude, b.Pickuplongitude, cxMilersNearbyRadiusKM)
}
// The delivering rider, once any order is out for delivery. Taken from the
// first destination that has one, since the booking-level field describes
// the leg the customer is currently watching.
for _, d := range destinations {
if agentUserID, ok := bundle.deliveryAgent[d.Bookingdestinationid]; ok {
if agent, found := bundle.milers[agentUserID]; found {
out["deliveryAgent"] = renderCxAgent(agent)
break
}
}
}
// amountPaid is present from picked_up. The fare block stays on the booking
// forever alongside it — the receipt renders amountPaid − fare.min as the
// weight adjustment, so losing the original estimate would lose the
// explanation for the difference.
if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStagePickedUp] {
if paid, ok := bundle.payments[b.Bookingid]; ok {
out["amountPaid"] = int(paid)
}
}
if delivered := cxAllDeliveredAt(destinations); delivered != nil {
out["deliveredAt"] = utils.EpochMillis(*delivered)
}
return out
}
func renderCxAgent(a cxAgent) fiber.Map {
return fiber.Map{
"name": a.Name,
"vehicle": a.Vehicle,
"phone": a.Phone,
"rating": a.Rating,
"trips": a.Trips,
"vehicleType": a.VehicleType,
}
}
func renderCxDestinations(destinations []models.BookingDestination, bundle *cxBookingBundle) []fiber.Map {
out := make([]fiber.Map, 0, len(destinations))
for i := range destinations {
d := destinations[i]
row := fiber.Map{
"stateCode": d.Statecode,
"stateName": d.Statename,
"districtCode": d.Districtcode,
"districtName": d.Districtname,
"packageCount": d.Packagecount,
"district": nil,
"details": renderCxDetails(&d),
"trackingId": nil,
"stage": nil,
"verification": nil,
}
if district, ok := bundle.districts[d.Districtcode]; ok {
card := fiber.Map{
"code": district.Districtcode,
"name": district.Districtname,
"available": district.Available,
}
if district.Hubid != nil {
if name, found := bundle.hubNames[*district.Hubid]; found {
card["hub"] = name
}
}
if district.Promise != "" {
card["promise"] = district.Promise
}
row["district"] = card
}
// Null until order_created — there is no order to track before the
// parcels have actually been collected.
if d.Trackingno != "" {
row["trackingId"] = d.Trackingno
}
if d.Stage != "" {
row["stage"] = d.Stage
}
// Null until picked_up: the weight and the photographs are what the
// miler recorded at the door and cannot exist before they were there.
if d.Verifiedweightkg != nil && d.Verifiedat != nil {
verification := fiber.Map{
"weightKg": *d.Verifiedweightkg,
"photos": cxPhotoURLs(bundle.photos[d.Bookingdestinationid]),
"capturedAt": utils.EpochMillis(*d.Verifiedat),
"capturedBy": "",
}
if d.Verifiedbyuserid != nil {
if agent, ok := bundle.milers[*d.Verifiedbyuserid]; ok {
verification["capturedBy"] = agent.Name
}
}
row["verification"] = verification
}
out = append(out, row)
}
return out
}
// renderCxDetails returns only the fields that were actually filled in. The UI
// renders a missing one as "Not added — the Miler can confirm this at pickup",
// which is a real state and not an error: a customer may legitimately book with
// nothing but a state and a district.
func renderCxDetails(d *models.BookingDestination) fiber.Map {
details := fiber.Map{}
if d.Street != "" {
details["street"] = d.Street
}
if d.Building != "" {
details["building"] = d.Building
}
if d.Landmark != "" {
details["landmark"] = d.Landmark
}
if d.Recipientname != "" {
details["recipientName"] = d.Recipientname
}
if d.Recipientphone != "" {
details["recipientPhone"] = d.Recipientphone
}
if d.Instructions != "" {
details["instructions"] = d.Instructions
}
if d.Pinlatitude != nil && d.Pinlongitude != nil {
details["pin"] = fiber.Map{"lat": *d.Pinlatitude, "lng": *d.Pinlongitude}
}
return details
}
// cxPhotoURLs signs each parcel photograph for the length of a receipt view.
func cxPhotoURLs(photos []models.BookingParcelPhoto) []string {
urls := make([]string, 0, len(photos))
for _, p := range photos {
url, err := storage.PresignGet(p.Objectkey, cxPhotoTTL)
if err != nil {
utils.Warn("cxPhotoURLs: could not sign parcel photo", "key", p.Objectkey, "error", err)
continue
}
urls = append(urls, url)
}
return urls
}
// renderCxHistory turns the event log into the timeline. Ordered oldest-first
// and carrying only real timestamps — every entry on the customer's timeline
// comes from a row that a real write created.
func renderCxHistory(events []models.BookingStageEvent) []fiber.Map {
// One entry per stage. A multi-destination pickup emits a per-order stage
// once per destination, so those have to collapse — and WHICH of them the
// timeline shows is not arbitrary:
//
// * Booking-level stages (booked..order_created) happen once for the whole
// pickup, so the first event is the only event.
// * Per-order stages (in_transit..delivered) are reached by the booking
// when its SLOWEST order gets there, matching how cxstage rolls the
// booking up. Taking the first would timestamp "Delivered" at the moment
// the earliest parcel landed while deliveredAt reports the last one —
// the same screen contradicting itself.
at := map[string]time.Time{}
order := make([]string, 0, len(events))
for _, e := range events {
// Cancellation and release rows are audit records rather than progress —
// they carry a stage key only because the table needs one. Putting them
// on the timeline would show the customer "Pickup booked" a second time
// when a rider handed their pickup back.
if strings.HasPrefix(e.Remarks, "cancelled:") || strings.HasPrefix(e.Remarks, "released:") {
continue
}
existing, seen := at[e.Stage]
if !seen {
at[e.Stage] = e.Occurredat
order = append(order, e.Stage)
continue
}
if constants.CxStageRank(e.Stage) >= constants.CxStageOrder[constants.CxStageInTransit] &&
e.Occurredat.After(existing) {
at[e.Stage] = e.Occurredat
}
}
out := make([]fiber.Map, 0, len(order))
for _, stage := range order {
out = append(out, fiber.Map{
"stage": stage,
"at": utils.EpochMillis(at[stage]),
})
}
return out
}
// ── Derivations ──────────────────────────────────────────────────────────────
// deriveStageFromStatus gives a stage to a booking that has none: rows written
// before this surface existed, and console-created express bookings that never
// went through the customer flow.
func deriveStageFromStatus(b *models.PickupBooking) string {
switch b.Status {
case constants.BookingCancelled:
return constants.CxStageBooked
case constants.BookingPickedUp:
return constants.CxStagePickedUp
case constants.BookingConvertedConsignment:
return constants.CxStageOrderCreated
case constants.BookingMilerAssigned, constants.BookingPickupScheduled:
// reachedat is the only durable record that the rider actually got
// there — the operational flow records arrival as a fact rather than a
// status, so this is the one place it can be read from.
if b.Arrivedat != nil {
return constants.CxStageArrived
}
return constants.CxStageAssigned
default:
return constants.CxStageBooked
}
}
func deriveStatus(b *models.PickupBooking, stage string) string {
if b.Status == constants.BookingCancelled {
return constants.CxStatusCancelled
}
if stage == constants.CxStageDelivered {
return constants.CxStatusCompleted
}
return constants.CxStatusActive
}
// cxPickupTitle / cxPickupSub give the pickup block its two lines. The stored
// title/sub pair is used when the booking came through the customer app; a
// console-created booking only has one flat address string, so it is split
// rather than left half-empty — the client types both as non-nullable.
func cxPickupTitle(b *models.PickupBooking) string {
if b.Pickuptitle != "" {
return b.Pickuptitle
}
address := strings.TrimSpace(b.Pickupaddress)
if address == "" {
return "Pickup address"
}
if i := strings.Index(address, ","); i > 0 {
return strings.TrimSpace(address[:i])
}
if len([]rune(address)) > 32 {
return string([]rune(address)[:32])
}
return address
}
func cxPickupSub(b *models.PickupBooking) string {
if b.Pickupsub != "" {
return b.Pickupsub
}
sub := joinNonEmpty(", ", strings.TrimSpace(b.Pickupaddress), b.Pickuppincode)
if sub == "" {
return "Address not recorded"
}
return sub
}
// cxExpectedDelivery is a display string, formatted server-side in IST, so the
// client never has to know the operating timezone. The latest promise across
// the destinations is the one shown: a booking is not fully delivered until its
// last parcel is.
func cxExpectedDelivery(destinations []models.BookingDestination) string {
var latest *time.Time
for i := range destinations {
d := destinations[i]
if d.Expecteddeliveryat == nil {
continue
}
if latest == nil || d.Expecteddeliveryat.After(*latest) {
latest = d.Expecteddeliveryat
}
}
if latest == nil {
return ""
}
return utils.FormatISTDate(*latest)
}
// cxAllDeliveredAt returns when the LAST parcel landed, or nil while any is
// still moving.
func cxAllDeliveredAt(destinations []models.BookingDestination) *time.Time {
if len(destinations) == 0 {
return nil
}
var latest *time.Time
for i := range destinations {
d := destinations[i]
if d.Deliveredat == nil {
return nil
}
if latest == nil || d.Deliveredat.After(*latest) {
latest = d.Deliveredat
}
}
return latest
}
func totalPackages(destinations []models.BookingDestination) int {
n := 0
for _, d := range destinations {
n += d.Packagecount
}
if n == 0 {
return 1
}
return n
}
// cxRiderApproach reports how far the rider still is and roughly how long that
// takes. At the door both are zero — a rider standing at the address is not
// "0.4 km away", and the screen says Arrived.
func cxRiderApproach(agent cxAgent, b *models.PickupBooking, stage string) (float64, int) {
if stage == constants.CxStageArrived {
return 0, 0
}
lat, lng := agent.Lat, agent.Lng
if lat == 0 && lng == 0 {
return 0, 0
}
km := calculateDistance(lat, lng, b.Pickuplatitude, b.Pickuplongitude)
km = float64(int(km*10+0.5)) / 10
// 18 km/h is a two-wheeler in Indian city traffic, plus a two-minute floor
// for parking and finding the door. Deliberately a rough number: the app
// shows it as an approximation and a precise-looking ETA that slips reads
// worse than an honest one.
const avgSpeedKMH = 18.0
eta := int(km/avgSpeedKMH*60) + 2
return km, eta
}
// ── Bundle loading ───────────────────────────────────────────────────────────
// loadCxBundle fetches everything a page of bookings needs, in a fixed number
// of queries regardless of how many bookings or destinations are involved.
func loadCxBundle(bookings []models.PickupBooking) *cxBookingBundle {
bundle := &cxBookingBundle{
destinations: map[int][]models.BookingDestination{},
events: map[int][]models.BookingStageEvent{},
photos: map[int][]models.BookingParcelPhoto{},
milers: map[int]cxAgent{},
assignedTo: map[int]int{},
deliveryAgent: map[int]int{},
payments: map[int]float64{},
districts: map[string]models.ServiceableDistrict{},
hubNames: map[int]string{},
}
bundle.single = len(bookings) == 1
if len(bookings) == 0 {
return bundle
}
bookingIDs := make([]int, 0, len(bookings))
for _, b := range bookings {
bookingIDs = append(bookingIDs, b.Bookingid)
}
var destinations []models.BookingDestination
if err := db.DB.Where("bookingid IN ?", bookingIDs).
Order("bookingid ASC, seq ASC").Find(&destinations).Error; err != nil {
utils.Error("loadCxBundle: destinations query failed", "error", err)
}
destIDs := make([]int, 0, len(destinations))
districtCodes := map[string]bool{}
for _, d := range destinations {
bundle.destinations[d.Bookingid] = append(bundle.destinations[d.Bookingid], d)
destIDs = append(destIDs, d.Bookingdestinationid)
if d.Districtcode != "" {
districtCodes[d.Districtcode] = true
}
}
var events []models.BookingStageEvent
if err := db.DB.Where("bookingid IN ?", bookingIDs).
Order("occurredat ASC, stageeventid ASC").Find(&events).Error; err != nil {
utils.Error("loadCxBundle: stage events query failed", "error", err)
}
for _, e := range events {
bundle.events[e.Bookingid] = append(bundle.events[e.Bookingid], e)
}
if len(destIDs) > 0 {
var photos []models.BookingParcelPhoto
if err := db.DB.Where("bookingdestinationid IN ?", destIDs).
Order("capturedat ASC").Find(&photos).Error; err != nil {
utils.Warn("loadCxBundle: parcel photos query failed", "error", err)
}
for _, p := range photos {
if p.Bookingdestinationid != nil {
bundle.photos[*p.Bookingdestinationid] = append(bundle.photos[*p.Bookingdestinationid], p)
}
}
}
milerIDs := map[int]bool{}
// The live assignment, if any. Rejected and cancelled assignments are not
// the current rider and must not be shown as one.
var assignments []models.BookingAssignment
if err := db.DB.Where("bookingid IN ? AND assignmentstatus IN ?", bookingIDs,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted, constants.AssignmentCompleted}).
Order("assignedat ASC").Find(&assignments).Error; err != nil {
utils.Warn("loadCxBundle: assignments query failed", "error", err)
}
for _, a := range assignments {
bundle.assignedTo[a.Bookingid] = a.Mileruserid
milerIDs[a.Mileruserid] = true
}
// Who is delivering each order — the rider who recorded the out-for-delivery
// event on that consignment.
consignmentToDest := map[int]int{}
consignmentIDs := make([]int, 0, len(destinations))
for _, d := range destinations {
if d.Consignmentid != nil {
consignmentToDest[*d.Consignmentid] = d.Bookingdestinationid
consignmentIDs = append(consignmentIDs, *d.Consignmentid)
}
}
if len(consignmentIDs) > 0 {
var history []models.ConsignmentHistory
if err := db.DB.Where("consignmentid IN ? AND eventstatus = ?",
consignmentIDs, constants.ConsignmentOutForDelivery).
Order("createdat ASC").Find(&history).Error; err != nil {
utils.Warn("loadCxBundle: consignment history query failed", "error", err)
}
for _, h := range history {
if h.Userid == nil {
continue
}
if destID, ok := consignmentToDest[h.Consignmentid]; ok {
bundle.deliveryAgent[destID] = *h.Userid
milerIDs[*h.Userid] = true
}
}
}
for _, d := range destinations {
if d.Verifiedbyuserid != nil {
milerIDs[*d.Verifiedbyuserid] = true
}
}
bundle.milers = loadCxAgents(milerIDs)
// What the customer actually paid. Summed from the payment rows rather than
// stored on the booking, so money has one home and a second collection
// cannot silently disagree with a cached total.
type paidRow struct {
Bookingid int
Total float64
}
var paid []paidRow
if err := db.DB.Model(&models.BookingPayment{}).
Select("bookingid, sum(amount) as total").
Where("bookingid IN ? AND paymentstatus = ?", bookingIDs, constants.PaymentStatusPaid).
Group("bookingid").Scan(&paid).Error; err != nil {
utils.Warn("loadCxBundle: payment totals query failed", "error", err)
}
for _, p := range paid {
bundle.payments[p.Bookingid] = p.Total
}
if len(districtCodes) > 0 {
codes := make([]string, 0, len(districtCodes))
for code := range districtCodes {
codes = append(codes, code)
}
var districts []models.ServiceableDistrict
if err := db.DB.Where("districtcode IN ?", codes).Find(&districts).Error; err != nil {
utils.Warn("loadCxBundle: district query failed", "error", err)
}
for _, d := range districts {
bundle.districts[d.Districtcode] = d
}
bundle.hubNames = hubNamesFor(districts)
}
return bundle
}
// loadCxAgents resolves rider display data — and their live position, which
// comes from Redis because it changes every few seconds and has no business in
// Postgres.
func loadCxAgents(ids map[int]bool) map[int]cxAgent {
out := map[int]cxAgent{}
if len(ids) == 0 {
return out
}
list := make([]int, 0, len(ids))
for id := range ids {
list = append(list, id)
}
var profiles []models.MilerProfile
if err := db.DB.Where("userid IN ?", list).Find(&profiles).Error; err != nil {
utils.Warn("loadCxAgents: profile query failed", "error", err)
return out
}
vehicleIDs := make([]int, 0, len(profiles))
for _, p := range profiles {
if p.Vehicleid != nil {
vehicleIDs = append(vehicleIDs, *p.Vehicleid)
}
}
vehicles := map[int]models.Vehicle{}
if len(vehicleIDs) > 0 {
var rows []models.Vehicle
if err := db.DB.Where("vehicleid IN ?", vehicleIDs).Find(&rows).Error; err == nil {
for _, v := range rows {
vehicles[v.Vehicleid] = v
}
}
}
for _, p := range profiles {
agent := cxAgent{
Name: p.Displayname,
Phone: cxMilerContact(p.Phone),
Rating: p.Rating,
Trips: p.Totalcompletedpickups,
VehicleType: p.Defaultvehicletype,
Lat: p.Currentlatitude,
Lng: p.Currentlongitude,
}
if p.Vehicleid != nil {
if v, ok := vehicles[*p.Vehicleid]; ok {
agent.Vehicle = v.Vehicleno
if agent.VehicleType == "" {
agent.VehicleType = v.Vehicletype
}
}
}
if lat, lng, ok := cxLiveRiderPosition(p.Userid); ok {
agent.Lat, agent.Lng = lat, lng
}
out[p.Userid] = agent
}
return out
}
// cxLiveRiderPosition reads the rider's current position from the same Redis
// GEO index the assignment engine searches, so the distance the customer sees
// and the distance the dispatcher used are the same number. Falls back to the
// profile's last-known coordinates when Redis has nothing.
func cxLiveRiderPosition(milerUserID int) (lat, lng float64, ok bool) {
if db.Rdb == nil {
return 0, 0, false
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
positions, err := db.Rdb.GeoPos(ctx, "milers:locations", cxRiderGeoMember(milerUserID)).Result()
if err != nil || len(positions) == 0 || positions[0] == nil {
return 0, 0, false
}
return positions[0].Latitude, positions[0].Longitude, true
}
// cxRiderGeoMember is the member name riders are stored under in the GEO index
// (see UpdateMilerLocation, which GEOADDs under the rider's user id).
func cxRiderGeoMember(milerUserID int) string {
return strconv.Itoa(milerUserID)
}
// cxMilerContact decides what phone number the customer is given for their
// rider.
//
// A masked-calling proxy is preferred, and MILER_CALL_PROXY configures one:
// handing a customer a rider's personal mobile makes that number permanently
// theirs, and riders on comparable platforms have been contacted long after the
// delivery on numbers given out this way. With no proxy configured the real
// number is returned, because a Call Miler button that dials nothing is worse
// than one that dials a rider — but this is a setting to close before launch,
// and §13 of the contract asks product to confirm which it is.
func cxMilerContact(phone string) string {
if proxy := strings.TrimSpace(os.Getenv("MILER_CALL_PROXY")); proxy != "" {
return proxy
}
return phone
}

View File

@@ -0,0 +1,563 @@
package controllers
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
// Catalogue and configuration — §5 of the customer contract.
//
// These four reads drive the whole booking form; nothing else in the app works
// without them. Every one of them degrades to something the app can still
// render rather than to an error, because a customer staring at a retry button
// on the state picker cannot book at all.
// ── Serviceability ───────────────────────────────────────────────────────────
// GetCxStates lists the states a destination may be sent to.
//
// districtCount counts AVAILABLE districts only, and the client hides any state
// showing 0 — so a state that is listed but has nothing open still returns,
// carrying the "Opening soon" transit tag. An empty list is a legitimate
// answer: the app has a designed no-service state for it.
func GetCxStates(c *fiber.Ctx) error {
var states []models.ServiceableState
if err := db.DB.Where("status = ?", "Active").
Order("displayorder ASC, statename ASC").Find(&states).Error; err != nil {
utils.Error("GetCxStates: query failed", "error", err)
return utils.CxInternal(c)
}
// One grouped count instead of a query per state.
type stateCount struct {
Statecode string
N int
}
var counts []stateCount
if err := db.DB.Model(&models.ServiceableDistrict{}).
Select("statecode, count(*) as n").
Where("available = ?", true).
Group("statecode").Scan(&counts).Error; err != nil {
utils.Warn("GetCxStates: district count failed, reporting zero", "error", err)
}
byState := make(map[string]int, len(counts))
for _, sc := range counts {
byState[sc.Statecode] = sc.N
}
out := make([]fiber.Map, 0, len(states))
for _, s := range states {
out = append(out, fiber.Map{
"code": s.Statecode,
"name": s.Statename,
"districtCount": byState[s.Statecode],
"transitTag": s.Transittag,
})
}
if served := serveIfNotModified(c, out); served {
return nil
}
return utils.CxList(c, out, len(out), nil)
}
// GetCxDistricts lists every district in a state, unavailable ones included.
//
// The picker filters unavailable districts out, but their names still appear in
// a quiet "coming soon" line — dropping them here would delete real copy from
// the screen. `note` says why one is closed, so the app never has to invent a
// reason.
func GetCxDistricts(c *fiber.Ctx) error {
stateCode := strings.ToUpper(strings.TrimSpace(c.Params("stateCode")))
if stateCode == "" {
return utils.CxBadRequest(c, "Pick a state first")
}
var state models.ServiceableState
if err := db.DB.Where("statecode = ? AND status = ?", stateCode, "Active").
First(&state).Error; err != nil {
return utils.CxNotFound(c, "That state is no longer serviceable")
}
var districts []models.ServiceableDistrict
if err := db.DB.Where("statecode = ?", stateCode).
Order("available DESC, displayorder ASC, districtname ASC").
Find(&districts).Error; err != nil {
utils.Error("GetCxDistricts: query failed", "state", stateCode, "error", err)
return utils.CxInternal(c)
}
hubNames := hubNamesFor(districts)
out := make([]fiber.Map, 0, len(districts))
for _, d := range districts {
row := fiber.Map{
"code": d.Districtcode,
"name": d.Districtname,
"available": d.Available,
}
if d.Note != "" {
row["note"] = d.Note
}
if d.Hubid != nil {
if name, ok := hubNames[*d.Hubid]; ok {
row["hub"] = name
}
}
if d.Promise != "" {
row["promise"] = d.Promise
}
// The district's centre. Only state and district are required at booking
// time, so for most destinations this is the ONLY geography the parcel
// has until the miler corrects it at the door — it is what places the
// destination on a map and what the fare estimate is priced against.
// Omitted rather than sent as 0,0 when unknown: null island is a real
// coordinate and would render as a pin off the coast of Africa.
if d.Centrelatitude != 0 || d.Centrelongitude != 0 {
row["lat"] = d.Centrelatitude
row["lng"] = d.Centrelongitude
}
out = append(out, row)
}
if served := serveIfNotModified(c, out); served {
return nil
}
return utils.CxList(c, out, len(out), nil)
}
// hubNamesFor resolves the serving-hub names for a page of districts in one
// query rather than one per row.
func hubNamesFor(districts []models.ServiceableDistrict) map[int]string {
ids := make([]int, 0, len(districts))
seen := map[int]bool{}
for _, d := range districts {
if d.Hubid != nil && !seen[*d.Hubid] {
seen[*d.Hubid] = true
ids = append(ids, *d.Hubid)
}
}
names := make(map[int]string, len(ids))
if len(ids) == 0 {
return names
}
var hubs []models.Hub
if err := db.DB.Select("hubid, hubname").Where("hubid IN ?", ids).Find(&hubs).Error; err != nil {
utils.Warn("hubNamesFor: hub lookup failed, omitting hub names", "error", err)
return names
}
for _, h := range hubs {
names[h.Hubid] = h.Hubname
}
return names
}
// serveIfNotModified implements ETag/If-None-Match for the serviceability
// reads. Both change perhaps weekly and are fetched on every cold start of the
// booking form, so a 304 is the difference between a full round trip and a
// header exchange. Returns true when it has already answered.
func serveIfNotModified(c *fiber.Ctx, payload interface{}) bool {
body, err := c.App().Config().JSONEncoder(payload)
if err != nil {
return false
}
sum := sha256.Sum256(body)
etag := `"` + hex.EncodeToString(sum[:16]) + `"`
c.Set("ETag", etag)
c.Set("Cache-Control", "max-age=300")
// A client may legitimately send several etags, or the weak form.
for _, candidate := range strings.Split(c.Get("If-None-Match"), ",") {
candidate = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(candidate), "W/"))
if candidate == etag || candidate == "*" {
c.Status(fiber.StatusNotModified)
return true
}
}
return false
}
// ── Pickup slots ─────────────────────────────────────────────────────────────
const (
// cxSlotLeadMinutes is how far ahead of a window's start the app may still
// offer it. A window that starts in four minutes cannot be staffed, and
// offering it produces a booking nobody can reach on time.
cxSlotLeadMinutes = 45
// cxSlotDays is how far ahead slots are offered: today and tomorrow, which
// is what the design lays out.
cxSlotDays = 2
// cxSlotZoneRadiusKM bounds "the customer's zone" when counting how full a
// window already is. Capacity is a property of an area's riders, not of the
// whole city.
cxSlotZoneRadiusKM = 12.0
// cxMilersNearbyRadiusKM is the radius for the reassuring "4 milers nearby"
// line — deliberately tighter than the capacity radius, because it is a
// statement about who could arrive shortly.
cxMilersNearbyRadiusKM = 6.0
)
// GetCxPickupSlots returns the pickup windows offered at a location.
//
// Slots are capacity- and location-aware: the id resolves to a real
// preferredpickupfrom/to on the booking, which is what the assignment engine
// consumes, so a slot the customer can pick is a slot ops can staff. Windows
// already past, or too close to start, are not returned at all rather than
// returned as unavailable — a greyed-out 8am slot at 6pm is noise.
func GetCxPickupSlots(c *fiber.Ctx) error {
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
appLocationID := appLocationForPoint(lat, lng)
var templates []models.PickupSlotTemplate
q := db.DB.Where("status = ?", "Active")
if appLocationID != nil {
q = q.Where("applocationid IS NULL OR applocationid = ?", *appLocationID)
} else {
q = q.Where("applocationid IS NULL")
}
if err := q.Order("displayorder ASC, starthour ASC").Find(&templates).Error; err != nil {
utils.Error("GetCxPickupSlots: template query failed", "error", err)
return utils.CxInternal(c)
}
now := utils.ISTNow()
cutoff := now.Add(cxSlotLeadMinutes * time.Minute)
candidates := make([]cxSlotCandidate, 0, len(templates)*cxSlotDays)
for day := 0; day < cxSlotDays; day++ {
d := now.AddDate(0, 0, day)
for _, tpl := range templates {
from := time.Date(d.Year(), d.Month(), d.Day(), tpl.Starthour, tpl.Startminute, 0, 0, utils.ISTLocation())
to := time.Date(d.Year(), d.Month(), d.Day(), tpl.Endhour, tpl.Endminute, 0, 0, utils.ISTLocation())
if !from.After(cutoff) {
continue
}
candidates = append(candidates, cxSlotCandidate{
id: cxSlotID(from, tpl.Code),
from: from,
to: to,
tpl: tpl,
})
}
}
booked := slotLoad(candidates, lat, lng)
milersNearby := milersWithin(lat, lng, cxMilersNearbyRadiusKM)
out := make([]fiber.Map, 0, len(candidates))
taggedOne := false
for _, cand := range candidates {
remaining := cand.tpl.Capacity - booked[cand.id]
available := remaining > 0
row := fiber.Map{
"id": cand.id,
"day": utils.FormatISTDay(cand.from),
"window": utils.FormatISTWindow(cand.from, cand.to),
"available": available,
}
if !available {
row["note"] = "Fully booked"
}
// At most one slot carries the tag, and only if it can actually be
// booked — labelling a full window "Fastest pickup" is worse than
// labelling nothing.
if available && !taggedOne && cand.tpl.Tag != "" {
row["tag"] = cand.tpl.Tag
taggedOne = true
}
if milersNearby > 0 {
row["milersNearby"] = milersNearby
}
if available && cand.tpl.Caption != "" {
row["caption"] = cand.tpl.Caption
}
out = append(out, row)
}
// Slots are volatile; a stale slot list is a booking that 409s on confirm.
c.Set("Cache-Control", "max-age=30")
return utils.CxList(c, out, len(out), nil)
}
// cxSlotID mints the opaque slot id the client sends back. It encodes the date
// and the template code so the server can resolve it to a real window without
// keeping per-request state — and so a slot id from yesterday's cached list
// resolves to yesterday and is rejected, rather than silently booking today.
func cxSlotID(from time.Time, code string) string {
return fmt.Sprintf("slot_%s_%s", from.Format("20060102"), code)
}
// ResolveCxSlot turns a slot id back into the window it names, checking the
// template still exists and is active. Returns ok=false for an unknown,
// malformed or retired slot.
func ResolveCxSlot(slotID string) (from, to time.Time, ok bool) {
parts := strings.SplitN(slotID, "_", 3)
if len(parts) != 3 || parts[0] != "slot" {
return time.Time{}, time.Time{}, false
}
day, err := time.ParseInLocation("20060102", parts[1], utils.ISTLocation())
if err != nil {
return time.Time{}, time.Time{}, false
}
var tpl models.PickupSlotTemplate
if err := db.DB.Where("code = ? AND status = ?", parts[2], "Active").
First(&tpl).Error; err != nil {
return time.Time{}, time.Time{}, false
}
from = time.Date(day.Year(), day.Month(), day.Day(), tpl.Starthour, tpl.Startminute, 0, 0, utils.ISTLocation())
to = time.Date(day.Year(), day.Month(), day.Day(), tpl.Endhour, tpl.Endminute, 0, 0, utils.ISTLocation())
return from, to, true
}
// CxSlotDateIsPast reports whether a slot id names a day that is already over.
//
// Pure: it reads the date out of the id and compares it to today, with no
// template lookup and no database. That matters because it is the cheapest
// validation in the booking path and it catches the most likely stale-slot
// case — an app left open across midnight, or one that cached the slot list for
// a whole session, sending yesterday's window in good faith.
//
// Only a whole day in the past is decided here. Whether one of TODAY's windows
// has already started needs the template's hours, which ResolveCxSlot loads.
func CxSlotDateIsPast(slotID string) bool {
parts := strings.SplitN(slotID, "_", 3)
if len(parts) != 3 || parts[0] != "slot" {
return false
}
day, err := time.ParseInLocation("20060102", parts[1], utils.ISTLocation())
if err != nil {
return false
}
now := utils.ISTNow()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, utils.ISTLocation())
return day.Before(today)
}
// CxSlotHasCapacity re-checks a window at confirm time. The list read is
// advisory and up to 30 seconds stale; this is the authority, and it is what
// turns a race into a clean 409 rather than an overbooked window.
func CxSlotHasCapacity(slotID string, lat, lng float64) bool {
from, to, ok := ResolveCxSlot(slotID)
if !ok {
return false
}
var tpl models.PickupSlotTemplate
parts := strings.SplitN(slotID, "_", 3)
if err := db.DB.Where("code = ?", parts[2]).First(&tpl).Error; err != nil {
return false
}
return countBookingsInWindow(from, to, lat, lng) < tpl.Capacity
}
// cxSlotCandidate is one concrete window on one concrete day, before capacity
// is applied — a template plus the date it was expanded onto.
type cxSlotCandidate struct {
id string
from, to time.Time
tpl models.PickupSlotTemplate
}
// slotLoad counts how many live bookings already sit in each candidate window
// near this point. Done per window rather than in one grouped query because the
// windows overlap across days and the zone filter is geometric, not indexable
// here; the list is at most a dozen rows.
func slotLoad(candidates []cxSlotCandidate, lat, lng float64) map[string]int {
load := make(map[string]int, len(candidates))
for _, cand := range candidates {
load[cand.id] = countBookingsInWindow(cand.from, cand.to, lat, lng)
}
return load
}
// countBookingsInWindow counts pickups already committed to a window inside the
// customer's zone. Cancelled and completed bookings do not consume capacity —
// only work still to be done does.
func countBookingsInWindow(from, to time.Time, lat, lng float64) int {
// Stored timestamps are IST wall clock (see utils.DBNow), so the bounds are
// sent as those digits rather than as a UTC instant. Comparing a UTC clock
// against IST-stamped rows is what made date-range reports undercount.
fromDB := time.Date(from.Year(), from.Month(), from.Day(), from.Hour(), from.Minute(), 0, 0, time.UTC)
toDB := time.Date(to.Year(), to.Month(), to.Day(), to.Hour(), to.Minute(), 0, 0, time.UTC)
type row struct {
Pickuplatitude float64
Pickuplongitude float64
}
var rows []row
err := db.DB.Model(&models.PickupBooking{}).
Select("pickuplatitude, pickuplongitude").
Where("preferredpickupfrom >= ? AND preferredpickupfrom < ?", fromDB, toDB).
Where("status NOT IN ?", []string{constants.BookingCancelled, constants.BookingConvertedConsignment}).
Find(&rows).Error
if err != nil {
// Failing open keeps the form usable. An over-filled window is an ops
// problem; a booking form that cannot offer any slot is a dead app.
utils.Warn("countBookingsInWindow: query failed, treating window as open", "error", err)
return 0
}
if lat == 0 && lng == 0 {
return len(rows)
}
n := 0
for _, r := range rows {
if r.Pickuplatitude == 0 && r.Pickuplongitude == 0 {
continue
}
if calculateDistance(lat, lng, r.Pickuplatitude, r.Pickuplongitude) <= cxSlotZoneRadiusKM {
n++
}
}
return n
}
// milersWithin counts riders currently reporting a position inside a radius.
// Reads the same Redis GEO index the assignment engine searches, so the number
// the customer is shown is the pool the dispatcher would actually draw from.
// Returns 0 when Redis is unavailable, and the client hides the line on 0.
func milersWithin(lat, lng, radiusKM float64) int {
if db.Rdb == nil || (lat == 0 && lng == 0) {
return 0
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
locs, err := db.Rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{
GeoSearchQuery: redis.GeoSearchQuery{
Longitude: lng,
Latitude: lat,
Radius: radiusKM,
RadiusUnit: "km",
Sort: "ASC",
Count: 50,
},
}).Result()
if err != nil {
utils.Warn("milersWithin: geo search failed", "error", err)
return 0
}
return len(locs)
}
// appLocationForPoint resolves which city a coordinate belongs to, via the
// nearest active hub. Nil when nothing is close enough to claim it, in which
// case only city-agnostic configuration applies.
func appLocationForPoint(lat, lng float64) *int {
if lat == 0 && lng == 0 {
return nil
}
var hubs []models.Hub
if err := db.DB.Select("hubid, applocationid, latitude, longitude").
Where("status = ? AND deletedat IS NULL", "Active").Find(&hubs).Error; err != nil {
utils.Warn("appLocationForPoint: hub query failed", "error", err)
return nil
}
best := -1.0
var bestID *int
for i := range hubs {
h := hubs[i]
if h.Latitude == 0 && h.Longitude == 0 {
continue
}
d := calculateDistance(lat, lng, h.Latitude, h.Longitude)
if best < 0 || d < best {
best = d
id := h.Applocationid
bestID = &id
}
}
// A hub 200km away says nothing about which city this is.
if bestID == nil || best > 60 {
return nil
}
return bestID
}
// ── Booking limits ───────────────────────────────────────────────────────────
// cxDefaultMaxPackages / cxDefaultMaxDestinations are the last-resort values,
// used only when no configuration row exists at all. They can never be 0 —
// a zero cap would reject every booking on the platform.
//
// cxDefaultMaxDestinations is deliberately **1**, not the 5 the design allows.
//
// This is the fail-safe half of the multi-destination gate. The gate itself is a
// database value (customerbookinglimits.maxdestinations), and a gate that opens
// when its configuration is missing is not a gate: a migration that ran without
// the seed, a wiped table, or a fresh environment would silently permit
// multi-destination bookings that the deployed rider app cannot complete,
// stranding parcels with no stop and no way to close them.
//
// So "no configuration" resolves to the safest behaviour, not the most
// permissive. Ops raises it to 5 by inserting the row — an explicit act — once
// a rider build keying on consignmentid is live. The client's own 20/5 fallback
// is UI guidance only; this is the authority.
const (
cxDefaultMaxPackages = 20
cxDefaultMaxDestinations = 1
)
// GetCxBookingLimits returns the caps on a single pickup.
//
// Nothing in the UI hardcodes these; they live here so ops can vary them by
// city without an app release. Keyed off the pickup location when one is
// supplied, so the client can re-fetch when the pickup point moves.
func GetCxBookingLimits(c *fiber.Ctx) error {
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
maxPackages, maxDestinations := CxBookingLimits(appLocationForPoint(lat, lng))
return utils.CxOK(c, fiber.Map{
"maxPackages": maxPackages,
"maxDestinations": maxDestinations,
})
}
// CxBookingLimits resolves the caps for a city, falling back to the global row
// and then to the built-in defaults. Never returns 0 for either: a zero cap
// rejects every booking, and a configuration mistake must not be able to take
// the product offline.
func CxBookingLimits(appLocationID *int) (maxPackages, maxDestinations int) {
maxPackages, maxDestinations = cxDefaultMaxPackages, cxDefaultMaxDestinations
var limit models.CustomerBookingLimit
found := false
if appLocationID != nil {
if err := db.DB.Where("applocationid = ?", *appLocationID).First(&limit).Error; err == nil {
found = true
}
}
if !found {
if err := db.DB.Where("applocationid IS NULL").First(&limit).Error; err == nil {
found = true
}
}
if !found {
return
}
if limit.Maxpackages > 0 {
maxPackages = limit.Maxpackages
}
if limit.Maxdestinations > 0 {
maxDestinations = limit.Maxdestinations
}
return
}

View File

@@ -0,0 +1,100 @@
package controllers
import (
"doormile/constants"
"doormile/internal/cxstage"
"doormile/utils"
"gorm.io/gorm"
)
// Per-order stages — the second half of §8.3.
//
// Stages 6-8 belong to each order rather than to the booking, and may differ
// between destinations of the same pickup: one parcel out for delivery in
// Chennai while another is still at a hub in Kerala. Each of the operational
// writes that moves a consignment records its customer stage against the
// destination that consignment belongs to, and the booking's own stage falls
// back to the least-advanced of them.
// cxStageForConsignmentStatus maps an operational consignment status onto the
// customer stage it means. Not every status has one: a parcel sitting on a
// tripsheet, or one flagged missing, is still "in transit" as far as the
// customer's seven milestones go, and inventing a stage for it would put a key
// on the wire the client silently falls back to `booked` for.
func cxStageForConsignmentStatus(status string) (string, bool) {
switch status {
case constants.ConsignmentInwardedAtHub,
constants.ConsignmentTripsheetLoaded,
constants.ConsignmentInTransit:
return constants.CxStageInTransit, true
case constants.ConsignmentOutForDelivery:
return constants.CxStageOutForDelivery, true
case constants.ConsignmentDelivered:
return constants.CxStageDelivered, true
default:
// Created and Collected_By_Miler both mean "the order exists and is in
// the rider's hands", which is order_created — already recorded at
// pickup-complete, so there is nothing new to say.
return "", false
}
}
// recordCxConsignmentStage records the customer stage for one consignment,
// inside the caller's transaction, and returns the notification to fire once
// that transaction commits.
//
// The consignment is resolved to its destination through bookingdestinations,
// not through pickupbookings.consignmentid. That column names only the FIRST
// order of a multi-destination pickup, so a lookup through it finds nothing for
// destinations 2..N — which would mean no stage advance and no notification on
// every order after the first.
//
// A consignment that belongs to no customer booking at all — a console-created
// express shipment — is a no-op, not an error. Those have no customer app
// watching them.
func recordCxConsignmentStage(tx *gorm.DB, consignmentID int, status string, actorType string, actorID *int, source string) (afterCommit func(), err error) {
noop := func() {}
stage, ok := cxStageForConsignmentStatus(status)
if !ok {
return noop, nil
}
dest, booking, found := cxDestinationForConsignment(consignmentID)
if !found || booking == nil {
return noop, nil
}
if booking.Bookingsource != constants.BookingSourceCustomerApp {
return noop, nil
}
destinationID := cxDestinationIDFor(dest)
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
DestinationID: destinationID,
Stage: stage,
ActorType: actorType,
ActorID: actorID,
Source: source,
At: utils.DBNow(),
}); err != nil {
return noop, err
}
bookingID := booking.Bookingid
return func() { go cxstage.Notify(bookingID, destinationID, stage) }, nil
}
// cancelCxBookingFromOps stands a pickup down on behalf of a rider or an ops
// user, recording who did it and why.
//
// A customer whose pickup disappears with no explanation has no way to tell a
// cancellation from a bug, and support has no way to answer them — so the
// actor and the reason are part of the write, not an afterthought.
func cancelCxBookingFromOps(tx *gorm.DB, bookingID int, reason, actorType string, actorID *int, source string) error {
if reason == "" {
reason = "Cancelled by Doormile"
}
return cxstage.Cancel(tx, bookingID, reason, actorType, actorID, source)
}

View File

@@ -0,0 +1,856 @@
package controllers
import (
"strings"
"testing"
"time"
"doormile/constants"
"doormile/models"
"doormile/utils"
)
// The app currently sends "+91 98765 43210" with spaces and is being tightened
// to send E.164. Both shapes must land on the SAME stored value: if they do
// not, a customer signing in from the newer build gets a second account and
// loses every booking they have made.
func TestNormalizePhoneCollapsesEveryShapeToOne(t *testing.T) {
want := "+919876543210"
inputs := []string{
"+91 98765 43210", // what the app sends today
"+919876543210", // what it is being tightened to send
"9876543210", // bare keypad entry
"09876543210", // with the national trunk prefix
"919876543210", // country code, no plus
"+91-98765-43210", // typed with dashes by support
" 9876543210 ", // pasted with whitespace
}
for _, in := range inputs {
got, kind, ok := normalizePhone(in)
if !ok {
t.Errorf("normalizePhone(%q) rejected a valid number", in)
continue
}
if kind != "phone" {
t.Errorf("normalizePhone(%q) kind = %q, want \"phone\"", in, kind)
}
if got != want {
t.Errorf("normalizePhone(%q) = %q, want %q — two spellings of one number must not make two accounts",
in, got, want)
}
}
}
func TestNormalizePhoneRejectsNonsense(t *testing.T) {
for _, in := range []string{"", " ", "12345", "abcdef", "+1"} {
if got, _, ok := normalizePhone(in); ok {
t.Errorf("normalizePhone(%q) = %q, ok — want rejected", in, got)
}
}
}
func TestNormalizeIdentifierSplitsPhoneFromEmail(t *testing.T) {
cases := []struct {
in string
want string
wantKind string
wantOK bool
}{
{"Joe@Example.COM", "joe@example.com", "email", true},
{"+91 98765 43210", "+919876543210", "phone", true},
{"joe@example", "", "", false}, // no dot in the domain
{"@example.com", "", "", false}, // no local part
{"joe@", "", "", false}, // no domain
{"", "", "", false},
}
for _, tc := range cases {
got, kind, ok := normalizeIdentifier(tc.in)
if ok != tc.wantOK || got != tc.want || kind != tc.wantKind {
t.Errorf("normalizeIdentifier(%q) = (%q, %q, %v), want (%q, %q, %v)",
tc.in, got, kind, ok, tc.want, tc.wantKind, tc.wantOK)
}
}
}
func TestSplitNameKeepsOneWordNames(t *testing.T) {
cases := []struct {
in string
first, last string
}{
{"Joe Oommen", "Joe", "Oommen"},
{"Meera", "Meera", ""}, // plenty of people have one name
{" Arun Kumar ", "Arun", "Kumar"}, // collapses whitespace
{"Vijay Raghav Menon", "Vijay Raghav", "Menon"}, // last token is the surname
{"J", "", ""}, // under two characters is invalid_name
{"", "", ""},
}
for _, tc := range cases {
first, last := splitName(tc.in)
if first != tc.first || last != tc.last {
t.Errorf("splitName(%q) = (%q, %q), want (%q, %q)", tc.in, first, last, tc.first, tc.last)
}
}
}
// The slot id is opaque to the client but has to round-trip: it encodes the
// date so a slot id from yesterday's cached list resolves to yesterday and gets
// rejected, rather than silently booking today's window.
func TestSlotIDCarriesItsDate(t *testing.T) {
from := time.Date(2026, 9, 5, 14, 0, 0, 0, time.UTC)
if got, want := cxSlotID(from, "t1"), "slot_20260905_t1"; got != want {
t.Errorf("cxSlotID = %q, want %q", got, want)
}
// Two days must never produce the same id for the same template.
other := time.Date(2026, 9, 6, 14, 0, 0, 0, time.UTC)
if cxSlotID(from, "t1") == cxSlotID(other, "t1") {
t.Error("cxSlotID collides across days — a stale slot would book today's window")
}
}
func TestDescribeParcelsMatchesTheReceiptCopy(t *testing.T) {
cases := []struct {
packages int
want string
}{
{0, "Standard box (up to 3 kg)"},
{1, "Standard box (up to 3 kg)"},
{3, "3 boxes (up to 3 kg each)"},
}
for _, tc := range cases {
if got := describeParcels(tc.packages); got != tc.want {
t.Errorf("describeParcels(%d) = %q, want %q", tc.packages, got, tc.want)
}
}
}
// pickup.title and pickup.sub are typed non-nullable by the client and will
// throw in its parser on a null. A console-created booking has only one flat
// address string, so both have to be derivable from it.
func TestPickupTitleAndSubAreNeverEmpty(t *testing.T) {
cases := []struct {
name string
booking models.PickupBooking
}{
{"customer-app booking", models.PickupBooking{
Pickuptitle: "12 Nehru Street", Pickupsub: "Gandhipuram, Coimbatore 641012"}},
{"console booking with a flat address", models.PickupBooking{
Pickupaddress: "12 Nehru Street, Gandhipuram, Coimbatore", Pickuppincode: "641012"}},
{"address with no comma", models.PickupBooking{
Pickupaddress: "Brookefields", Pickuppincode: "641001"}},
{"nothing recorded at all", models.PickupBooking{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
b := tc.booking
if title := cxPickupTitle(&b); title == "" {
t.Error("cxPickupTitle returned empty — the client types it non-nullable")
}
if sub := cxPickupSub(&b); sub == "" {
t.Error("cxPickupSub returned empty — the client types it non-nullable")
}
})
}
}
func TestPickupTitleIsCappedForTheDesign(t *testing.T) {
b := models.PickupBooking{
Pickupaddress: "A very long single-line address with no commas at all in it anywhere",
}
if got := cxPickupTitle(&b); len([]rune(got)) > 32 {
t.Errorf("cxPickupTitle = %q (%d runes), want at most 32", got, len([]rune(got)))
}
}
// deliveredAt is the moment the LAST parcel landed. Reporting the first would
// tell a customer their whole pickup completed while parcels were still moving.
func TestAllDeliveredAtWaitsForTheLastParcel(t *testing.T) {
early := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
late := time.Date(2026, 9, 6, 16, 0, 0, 0, time.UTC)
partly := []models.BookingDestination{
{Deliveredat: &early},
{Deliveredat: nil},
}
if got := cxAllDeliveredAt(partly); got != nil {
t.Errorf("cxAllDeliveredAt with one parcel still moving = %v, want nil", got)
}
all := []models.BookingDestination{
{Deliveredat: &early},
{Deliveredat: &late},
}
got := cxAllDeliveredAt(all)
if got == nil || !got.Equal(late) {
t.Errorf("cxAllDeliveredAt = %v, want the last delivery %v", got, late)
}
if got := cxAllDeliveredAt(nil); got != nil {
t.Errorf("cxAllDeliveredAt(no destinations) = %v, want nil", got)
}
}
// A booking written before this surface existed carries no stored stage, and a
// console-created one never will. Both still have to render, so the stage is
// derived from the operational status rather than left blank.
func TestStageDerivationForBookingsWithNoStoredStage(t *testing.T) {
arrived := time.Date(2026, 9, 5, 14, 0, 0, 0, time.UTC)
cases := []struct {
name string
booking models.PickupBooking
want string
}{
{"pending pickup", models.PickupBooking{Status: constants.BookingPendingPickup}, constants.CxStageBooked},
{"assigned, not yet arrived", models.PickupBooking{Status: constants.BookingMilerAssigned}, constants.CxStageAssigned},
{"scheduled and arrived", models.PickupBooking{Status: constants.BookingPickupScheduled, Arrivedat: &arrived}, constants.CxStageArrived},
{"picked up", models.PickupBooking{Status: constants.BookingPickedUp}, constants.CxStagePickedUp},
{"converted", models.PickupBooking{Status: constants.BookingConvertedConsignment}, constants.CxStageOrderCreated},
{"cancelled", models.PickupBooking{Status: constants.BookingCancelled}, constants.CxStageBooked},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
b := tc.booking
if got := deriveStageFromStatus(&b); got != tc.want {
t.Errorf("deriveStageFromStatus = %q, want %q", got, tc.want)
}
})
}
}
// The timeline is built from the event log. Cancellation and release rows carry
// a stage key only because the table needs one — putting them on the timeline
// would show the customer "Pickup booked" a second time when a rider handed
// their pickup back.
func TestHistoryExcludesAuditRowsAndDeduplicates(t *testing.T) {
at := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
events := []models.BookingStageEvent{
{Stage: constants.CxStageBooked, Occurredat: at},
{Stage: constants.CxStageAssigned, Occurredat: at.Add(time.Minute)},
{Stage: constants.CxStageBooked, Remarks: "released: rider unavailable", Occurredat: at.Add(2 * time.Minute)},
{Stage: constants.CxStageAssigned, Occurredat: at.Add(3 * time.Minute)}, // second rider, same stage
{Stage: constants.CxStageBooked, Remarks: "cancelled: Package not ready", Occurredat: at.Add(4 * time.Minute)},
}
history := renderCxHistory(events)
if len(history) != 2 {
t.Fatalf("renderCxHistory returned %d entries, want 2 (booked, assigned)", len(history))
}
if history[0]["stage"] != constants.CxStageBooked || history[1]["stage"] != constants.CxStageAssigned {
t.Errorf("renderCxHistory = %v, want booked then assigned in order", history)
}
}
// A multi-destination pickup emits each per-order stage once per destination.
// The booking reaches that stage when its SLOWEST order does, so the timeline
// must carry the LAST of them — otherwise "Delivered" is timestamped at the
// moment the earliest parcel landed while deliveredAt reports the last one, and
// the same screen contradicts itself.
func TestHistoryTimestampsPerOrderStagesAtTheSlowestOrder(t *testing.T) {
base := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
firstParcel := base.Add(2 * time.Hour)
lastParcel := base.Add(30 * time.Hour)
events := []models.BookingStageEvent{
{Stage: constants.CxStageBooked, Occurredat: base},
{Stage: constants.CxStageDelivered, Occurredat: firstParcel},
{Stage: constants.CxStageDelivered, Occurredat: lastParcel},
}
history := renderCxHistory(events)
if len(history) != 2 {
t.Fatalf("renderCxHistory returned %d entries, want 2", len(history))
}
if got, want := history[1]["at"], utils.EpochMillis(lastParcel); got != want {
t.Errorf("delivered timestamped at %v, want the last parcel %v", got, want)
}
}
// Booking-level stages happen once for the whole pickup, so a duplicate is a
// re-assertion (Record dedupes, but a release can bring one back) and the
// original moment is the true one.
func TestHistoryKeepsTheFirstBookingLevelTimestamp(t *testing.T) {
first := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
later := first.Add(3 * time.Hour)
events := []models.BookingStageEvent{
{Stage: constants.CxStageAssigned, Occurredat: first},
{Stage: constants.CxStageAssigned, Occurredat: later},
}
history := renderCxHistory(events)
if len(history) != 1 {
t.Fatalf("renderCxHistory returned %d entries, want 1", len(history))
}
if got, want := history[0]["at"], utils.EpochMillis(first); got != want {
t.Errorf("assigned timestamped at %v, want the original %v", got, want)
}
}
// Every stage the timeline can carry has to be one the client parses. An
// unknown key is silently rendered as `booked`, so a mapping that invents one
// makes a moving parcel look un-started.
func TestConsignmentStatusMapsOnlyToRealStages(t *testing.T) {
mapped := map[string]string{
constants.ConsignmentInwardedAtHub: constants.CxStageInTransit,
constants.ConsignmentTripsheetLoaded: constants.CxStageInTransit,
constants.ConsignmentInTransit: constants.CxStageInTransit,
constants.ConsignmentOutForDelivery: constants.CxStageOutForDelivery,
constants.ConsignmentDelivered: constants.CxStageDelivered,
}
for status, want := range mapped {
got, ok := cxStageForConsignmentStatus(status)
if !ok || got != want {
t.Errorf("cxStageForConsignmentStatus(%q) = (%q, %v), want (%q, true)", status, got, ok, want)
}
if constants.CxStageRank(got) < 0 {
t.Errorf("cxStageForConsignmentStatus(%q) produced %q, which is not one of the nine stage keys", status, got)
}
}
// Statuses that mean "the order exists and is in the rider's hands" are
// already covered by order_created and must not emit a second stage.
for _, status := range []string{
constants.ConsignmentCreated,
constants.ConsignmentCollectedByMiler,
constants.ConsignmentMissing,
"Cancelled",
} {
if got, ok := cxStageForConsignmentStatus(status); ok {
t.Errorf("cxStageForConsignmentStatus(%q) = %q, want no stage", status, got)
}
}
}
// cxLegWeights is what the price settles on. A leg whose packages were never
// weighed must still produce a chargeable consignment, or an unweighed pickup
// bills at zero.
func TestLegWeightsFallBackWhenNothingWasWeighed(t *testing.T) {
empty := cxPickupLeg{}
dead, chargeable, _, _, _ := cxLegWeights(empty)
if dead != 0.5 || chargeable != 0.5 {
t.Errorf("cxLegWeights(no parcels) = (%v, %v), want the 0.5 kg placeholder", dead, chargeable)
}
// Volumetric weight wins when the box is bulky and light — that is the
// whole reason the column exists.
bulky := cxPickupLeg{Parcels: []models.BookingParcel{
{Weight: 1.0, Length: 40, Width: 40, Height: 40}, // volumetric = 64000/5000 = 12.8
}}
_, chargeable, maxL, _, _ := cxLegWeights(bulky)
if chargeable != 12.8 {
t.Errorf("cxLegWeights chargeable = %v, want the volumetric 12.8", chargeable)
}
if maxL != 40 {
t.Errorf("cxLegWeights maxL = %v, want 40", maxL)
}
}
// How many stops a booking is worth to the rider decides whether a parcel is
// deliverable at all. The queue used to emit one row per booking keyed on
// pickupbookings.consignmentid — a column that names only the FIRST order — so
// on a three-destination pickup two parcels sat in the rider's bag with no
// stop, no deliver button and no way to close them.
func TestMilerStopsBeforeCollectionAreOneVisit(t *testing.T) {
booking := models.PickupBooking{
Bookingid: 7,
Deliveryaddress: "12th Main, Chennai",
Deliverylatitude: 13.08,
Deliverylongitude: 80.27,
}
// Booked, not yet collected: no destination carries a consignment.
destinations := []models.BookingDestination{
{Bookingdestinationid: 1, Bookingid: 7, Seq: 0, Districtname: "Chennai"},
{Bookingdestinationid: 2, Bookingid: 7, Seq: 1, Districtname: "Ernakulam"},
{Bookingdestinationid: 3, Bookingid: 7, Seq: 2, Districtname: "Bengaluru Urban"},
}
stops := milerStopsForBooking(&booking, destinations)
if len(stops) != 1 {
t.Fatalf("got %d stops before collection, want 1 — the rider makes ONE visit to the door", len(stops))
}
if stops[0].deliveryLat != 13.08 {
t.Errorf("pre-pickup stop lost the booking's mirrored coordinates: %v", stops[0].deliveryLat)
}
}
func TestMilerStopsAfterCollectionAreOnePerOrder(t *testing.T) {
c1, c2, c3 := 101, 102, 103
pinLat, pinLng := 9.98, 76.29
booking := models.PickupBooking{
Bookingid: 7,
Consignmentid: &c1,
Deliverylatitude: 13.08,
Deliverylongitude: 80.27,
Parcels: []models.BookingParcel{
{Bookingparcelid: 1, Bookingdestinationid: intPtr(1)},
{Bookingparcelid: 2, Bookingdestinationid: intPtr(1)},
{Bookingparcelid: 3, Bookingdestinationid: intPtr(2)},
{Bookingparcelid: 4, Bookingdestinationid: intPtr(3)},
},
}
destinations := []models.BookingDestination{
{Bookingdestinationid: 1, Bookingid: 7, Seq: 0, Districtname: "Chennai",
Statename: "Tamil Nadu", Consignmentid: &c1, Trackingno: "DMX10000001"},
{Bookingdestinationid: 2, Bookingid: 7, Seq: 1, Districtname: "Ernakulam",
Statename: "Kerala", Consignmentid: &c2, Trackingno: "DMX10000002",
Pinlatitude: &pinLat, Pinlongitude: &pinLng},
{Bookingdestinationid: 3, Bookingid: 7, Seq: 2, Districtname: "Bengaluru Urban",
Statename: "Karnataka", Consignmentid: &c3, Trackingno: "DMX10000003"},
}
stops := milerStopsForBooking(&booking, destinations)
if len(stops) != 3 {
t.Fatalf("got %d stops after collection, want 3 — one per order, or the other parcels are undeliverable", len(stops))
}
// Each stop names its own order. Sharing one consignment id would have the
// rider close the same parcel three times.
seen := map[int]bool{}
for i, s := range stops {
if s.consignmentID == nil {
t.Fatalf("stop %d has no consignment id", i)
}
if seen[*s.consignmentID] {
t.Errorf("stop %d repeats consignment %d", i, *s.consignmentID)
}
seen[*s.consignmentID] = true
if s.trackingNo == "" {
t.Errorf("stop %d has no tracking number", i)
}
if s.deliveryAddress == "" {
t.Errorf("stop %d has no delivery address — the rider has nowhere to go", i)
}
}
// Parcels follow their own destination, so each order settles and is handed
// over with the packages that actually belong to it.
if len(stops[0].parcels) != 2 || len(stops[1].parcels) != 1 || len(stops[2].parcels) != 1 {
t.Errorf("parcels split as %d/%d/%d, want 2/1/1",
len(stops[0].parcels), len(stops[1].parcels), len(stops[2].parcels))
}
// A destination with its own pin uses it; destination 0 falls back to the
// coordinates mirrored onto the booking, which the rider may have corrected.
if stops[1].deliveryLat != pinLat {
t.Errorf("stop 1 lat = %v, want the customer's pin %v", stops[1].deliveryLat, pinLat)
}
if stops[0].deliveryLat != 13.08 {
t.Errorf("stop 0 lat = %v, want the booking's mirrored 13.08", stops[0].deliveryLat)
}
}
// A console-created express booking has no destination rows at all and must
// keep producing exactly the one stop it always did.
func TestMilerStopsForConsoleBookingAreUnchanged(t *testing.T) {
cid := 55
booking := models.PickupBooking{
Bookingid: 9,
Consignmentid: &cid,
Deliveryaddress: "Kitchen 4, Nagercoil",
Deliverylatitude: 8.17,
Deliverylongitude: 77.43,
Parcels: []models.BookingParcel{{Bookingparcelid: 1}},
}
stops := milerStopsForBooking(&booking, nil)
if len(stops) != 1 {
t.Fatalf("got %d stops, want 1", len(stops))
}
if stops[0].consignmentID == nil || *stops[0].consignmentID != cid {
t.Errorf("console stop lost its consignment id: %v", stops[0].consignmentID)
}
if stops[0].deliveryAddress != "Kitchen 4, Nagercoil" || len(stops[0].parcels) != 1 {
t.Errorf("console stop changed shape: %+v", stops[0])
}
}
// Three console paths cancel a booking by writing pickupbookings.status
// directly and none of them knows the customer projection exists. The customer
// must never keep seeing a cancelled pickup as active and cancellable, so the
// operational status is the authority here regardless of what customerstatus
// holds.
func TestOpsCancellationAlwaysReachesTheCustomer(t *testing.T) {
bundle := loadCxBundleForTest()
booking := models.PickupBooking{
Bookingid: 1,
Bookingno: "DM-482913",
// Written as active at booking time — this is the value that used to
// win and keep a cancelled pickup looking live.
Customerstatus: constants.CxStatusActive,
Customerstage: constants.CxStageAssigned,
Status: constants.BookingCancelled,
Createdat: time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC),
}
out := renderCxBooking(&booking, bundle)
if out["status"] != constants.CxStatusCancelled {
t.Errorf("status = %v, want %q — an ops cancel must reach the customer",
out["status"], constants.CxStatusCancelled)
}
if out["cancellable"] != false {
t.Errorf("cancellable = %v on a cancelled pickup, want false", out["cancellable"])
}
}
// A booking that is genuinely still live must not be swept up by that rule.
func TestActiveBookingStaysActiveAndCancellable(t *testing.T) {
bundle := loadCxBundleForTest()
booking := models.PickupBooking{
Bookingid: 2,
Bookingno: "DM-482914",
Customerstatus: constants.CxStatusActive,
Customerstage: constants.CxStageArrived,
Status: constants.BookingPickupScheduled,
Createdat: time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC),
}
out := renderCxBooking(&booking, bundle)
if out["status"] != constants.CxStatusActive {
t.Errorf("status = %v, want %q", out["status"], constants.CxStatusActive)
}
// arrived is the last cancellable stage.
if out["cancellable"] != true {
t.Errorf("cancellable = %v at arrived, want true", out["cancellable"])
}
}
// loadCxBundleForTest builds an empty bundle, so the projection can be
// exercised without a database.
func loadCxBundleForTest() *cxBookingBundle {
return &cxBookingBundle{
destinations: map[int][]models.BookingDestination{},
events: map[int][]models.BookingStageEvent{},
photos: map[int][]models.BookingParcelPhoto{},
milers: map[int]cxAgent{},
assignedTo: map[int]int{},
deliveryAgent: map[int]int{},
payments: map[int]float64{},
districts: map[string]models.ServiceableDistrict{},
hubNames: map[int]string{},
}
}
// ─── Fan-out row-level proof ─────────────────────────────────────────────────
//
// One pickup, three genuinely different destinations. Every assertion below
// exists because the failure it guards against is silent: the rows would still
// render, the rider would still see stops, and the parcels would go to the
// wrong doors. Booking-level destination-0 data leaking into rows 2 and 3 is
// the specific defect this locks down.
// cxThreeDestinationFixture builds a collected pickup bound for Chennai,
// Ernakulam and Bengaluru — different addresses, different coordinates,
// different recipients, different tracking numbers, different COD.
func cxThreeDestinationFixture() (models.PickupBooking, []models.BookingDestination) {
c1, c2, c3 := 901, 902, 903
chennaiLat, chennaiLng := 13.082680, 80.270718
kochiLat, kochiLng := 9.981636, 76.299881
blrLat, blrLng := 12.971599, 77.594566
booking := models.PickupBooking{
Bookingid: 70,
Bookingno: "DM-482913",
Consignmentid: &c1,
// Booking-level delivery data mirrors destination 0 ONLY. If any of it
// leaks onto stops 1 or 2, the assertions below catch it.
Deliveryaddress: "3B, 12th Main, Chennai, Tamil Nadu",
Deliverylatitude: chennaiLat,
Deliverylongitude: chennaiLng,
Parcels: []models.BookingParcel{
{Bookingparcelid: 1, Bookingdestinationid: intPtr(11)},
{Bookingparcelid: 2, Bookingdestinationid: intPtr(11)},
{Bookingparcelid: 3, Bookingdestinationid: intPtr(12)},
{Bookingparcelid: 4, Bookingdestinationid: intPtr(13)},
},
}
destinations := []models.BookingDestination{
{
Bookingdestinationid: 11, Bookingid: 70, Seq: 0,
Statecode: "TN", Statename: "Tamil Nadu",
Districtcode: "TN-MAA", Districtname: "Chennai",
Building: "3B", Street: "12th Main",
Recipientname: "Meera S", Recipientphone: "+919884412210",
Packagecount: 2, Codamount: 1200,
Consignmentid: &c1, Trackingno: "DMX10482913",
Pinlatitude: &chennaiLat, Pinlongitude: &chennaiLng,
},
{
Bookingdestinationid: 12, Bookingid: 70, Seq: 1,
Statecode: "KL", Statename: "Kerala",
Districtcode: "KL-EKM", Districtname: "Ernakulam",
Street: "Marine Drive", Landmark: "Near the ferry",
Recipientname: "Joe Oommen", Recipientphone: "+919847011223",
Packagecount: 1, Codamount: 0,
Consignmentid: &c2, Trackingno: "DMX10559120",
Pinlatitude: &kochiLat, Pinlongitude: &kochiLng,
},
{
Bookingdestinationid: 13, Bookingid: 70, Seq: 2,
Statecode: "KA", Statename: "Karnataka",
Districtcode: "KA-BLR", Districtname: "Bengaluru Urban",
Street: "Brigade Road",
Recipientname: "Arun Kumar", Recipientphone: "+919000011223",
Packagecount: 1, Codamount: 450,
Consignmentid: &c3, Trackingno: "DMX10662004",
Pinlatitude: &blrLat, Pinlongitude: &blrLng,
},
}
return booking, destinations
}
// Each generated stop carries a DIFFERENT, correct address — none of them
// inherits the booking-level destination-0 string.
func TestFanoutStopsHaveDistinctAddresses(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
stops := milerStopsForBooking(&booking, destinations)
if len(stops) != 3 {
t.Fatalf("got %d stops, want 3", len(stops))
}
seen := map[string]bool{}
for i, s := range stops {
if s.deliveryAddress == "" {
t.Fatalf("stop %d has no delivery address — the rider has nowhere to go", i)
}
if seen[s.deliveryAddress] {
t.Errorf("stop %d repeats an address already used: %q", i, s.deliveryAddress)
}
seen[s.deliveryAddress] = true
}
wantDistrict := []string{"Chennai", "Ernakulam", "Bengaluru Urban"}
for i, want := range wantDistrict {
if !strings.Contains(stops[i].deliveryAddress, want) {
t.Errorf("stop %d address %q does not name %q", i, stops[i].deliveryAddress, want)
}
}
// The specific leak this guards: destination 0's address on a later stop.
for i := 1; i < 3; i++ {
if strings.Contains(stops[i].deliveryAddress, "Chennai") {
t.Errorf("stop %d inherited destination 0's address: %q", i, stops[i].deliveryAddress)
}
}
}
// Coordinates are distinct and correct per stop. A shared coordinate sends
// every parcel to one map pin, which is how a rider drives to the wrong city.
func TestFanoutStopsHaveDistinctCoordinates(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
stops := milerStopsForBooking(&booking, destinations)
type point struct{ lat, lng float64 }
want := []point{{13.082680, 80.270718}, {9.981636, 76.299881}, {12.971599, 77.594566}}
seen := map[point]bool{}
for i, s := range stops {
got := point{s.deliveryLat, s.deliveryLng}
if got.lat == 0 && got.lng == 0 {
t.Errorf("stop %d sits at 0,0 — route sequencing skips those", i)
}
if seen[got] {
t.Errorf("stop %d repeats coordinates %v", i, got)
}
seen[got] = true
if got != want[i] {
t.Errorf("stop %d coordinates = %v, want %v", i, got, want[i])
}
}
}
// Recipient details stay attached to their own stop. Delivering Meera's parcel
// while showing Joe's phone number is a handover to the wrong person.
func TestFanoutRecipientsStayWithTheirStop(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
stops := milerStopsForBooking(&booking, destinations)
want := []struct{ name, phone string }{
{"Meera S", "+919884412210"},
{"Joe Oommen", "+919847011223"},
{"Arun Kumar", "+919000011223"},
}
for i, w := range want {
if stops[i].recipientName != w.name {
t.Errorf("stop %d recipientName = %q, want %q", i, stops[i].recipientName, w.name)
}
if stops[i].recipientPhone != w.phone {
t.Errorf("stop %d recipientPhone = %q, want %q", i, stops[i].recipientPhone, w.phone)
}
}
}
// Tracking and consignment ids stay attached to their own stop. A crossed id
// means the rider closes the wrong order and the customer is told the wrong
// parcel arrived.
func TestFanoutTrackingAndConsignmentIdsStayWithTheirStop(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
stops := milerStopsForBooking(&booking, destinations)
wantTracking := []string{"DMX10482913", "DMX10559120", "DMX10662004"}
wantConsignment := []int{901, 902, 903}
seenTracking := map[string]bool{}
seenConsignment := map[int]bool{}
for i := range stops {
if stops[i].trackingNo != wantTracking[i] {
t.Errorf("stop %d trackingNo = %q, want %q", i, stops[i].trackingNo, wantTracking[i])
}
if stops[i].consignmentID == nil {
t.Fatalf("stop %d has no consignment id", i)
}
if *stops[i].consignmentID != wantConsignment[i] {
t.Errorf("stop %d consignmentID = %d, want %d", i, *stops[i].consignmentID, wantConsignment[i])
}
if seenTracking[stops[i].trackingNo] || seenConsignment[*stops[i].consignmentID] {
t.Errorf("stop %d repeats an identifier already used by another stop", i)
}
seenTracking[stops[i].trackingNo] = true
seenConsignment[*stops[i].consignmentID] = true
}
}
// COD is never copied across destinations. Money is the one field where a leak
// is not a display bug: a rider shown 1200 at a door that owes nothing collects
// it, and a door owing 450 shown 0 goes uncollected.
func TestFanoutCodIsNotCopiedAcrossDestinations(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
stops := milerStopsForBooking(&booking, destinations)
want := []float64{1200, 0, 450}
for i, w := range want {
if stops[i].codAmount != w {
t.Errorf("stop %d codAmount = %v, want %v", i, stops[i].codAmount, w)
}
}
for i := 1; i < len(stops); i++ {
if stops[i].codAmount == 1200 {
t.Errorf("stop %d carries destination 0's COD of 1200", i)
}
}
}
// Parcels follow their own destination, so each order settles and is handed
// over with the packages that actually belong to it.
func TestFanoutParcelsFollowTheirDestination(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
stops := milerStopsForBooking(&booking, destinations)
want := []int{2, 1, 1}
for i, w := range want {
if len(stops[i].parcels) != w {
t.Errorf("stop %d has %d parcels, want %d", i, len(stops[i].parcels), w)
}
}
seen := map[int]int{}
for i, s := range stops {
for _, p := range s.parcels {
if prev, dup := seen[p.Bookingparcelid]; dup {
t.Errorf("parcel %d appears on stops %d and %d", p.Bookingparcelid, prev, i)
}
seen[p.Bookingparcelid] = i
}
}
}
// Route order follows destinationseq. The stops are emitted in seq order so an
// app that renders them as received shows "Stop 1, 2, 3" without sorting.
func TestFanoutRouteOrderFollowsDestinationSeq(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
// Deliberately handed over out of order — the ordering must come from seq,
// not from however the rows happened to arrive.
shuffled := []models.BookingDestination{destinations[2], destinations[0], destinations[1]}
stops := milerStopsForBooking(&booking, shuffled)
if len(stops) != 3 {
t.Fatalf("got %d stops, want 3", len(stops))
}
for i := 1; i < len(stops); i++ {
if stops[i].seq <= stops[i-1].seq {
t.Errorf("stop %d has seq %d, which does not follow stop %d's seq %d",
i, stops[i].seq, i-1, stops[i-1].seq)
}
}
// And seq must still name the right destination after ordering.
if stops[0].trackingNo != "DMX10482913" || stops[2].trackingNo != "DMX10662004" {
t.Errorf("ordering by seq detached a stop from its order: %q ... %q",
stops[0].trackingNo, stops[2].trackingNo)
}
}
// Every one of the ten row-level fields the rider app reads is present and
// destination-specific. This is the field-by-field audit, asserted rather than
// described: consignmentid, trackingno, deliveryaddress, deliverylatitude,
// deliverylongitude, recipientname, recipientphone, collectionamt (codAmount),
// destinationseq (seq) and destinationcount (len).
func TestFanoutEveryRowLevelFieldIsDestinationSpecific(t *testing.T) {
booking, destinations := cxThreeDestinationFixture()
stops := milerStopsForBooking(&booking, destinations)
if len(stops) != 3 {
t.Fatalf("got %d stops, want 3", len(stops))
}
for i, s := range stops {
if s.consignmentID == nil {
t.Errorf("stop %d: consignmentid missing", i)
}
if s.trackingNo == "" {
t.Errorf("stop %d: trackingno missing", i)
}
if s.deliveryAddress == "" {
t.Errorf("stop %d: deliveryaddress missing", i)
}
if s.deliveryLat == 0 && s.deliveryLng == 0 {
t.Errorf("stop %d: delivery coordinates missing", i)
}
if s.recipientName == "" {
t.Errorf("stop %d: recipientname missing", i)
}
if s.recipientPhone == "" {
t.Errorf("stop %d: recipientphone missing", i)
}
if s.seq != i {
t.Errorf("stop %d: destinationseq = %d, want %d", i, s.seq, i)
}
}
// codAmount is exempt from the non-empty check — 0 is a legitimate value
// (destination 1 owes nothing) and is asserted exactly in the COD test.
}
// maxDestinations is enforced HERE, not in the app. The Flutter limit is UI
// guidance that a modified client, a stale build, or a failed
// /config/booking-limits fetch can all bypass; this is the authority.
//
// The default matters as much as the check: with no configuration row at all
// the cap must resolve to the SAFEST value, not the most permissive. A gate
// that opens when its config is missing is not a gate — a migration that ran
// without the seed would silently admit multi-destination bookings the rider
// app cannot complete.
func TestMaxDestinationsDefaultsToTheSafestValue(t *testing.T) {
if cxDefaultMaxDestinations != 1 {
t.Errorf("cxDefaultMaxDestinations = %d, want 1 — a missing config row must not open the gate",
cxDefaultMaxDestinations)
}
if cxDefaultMaxPackages <= 0 {
t.Errorf("cxDefaultMaxPackages = %d — a zero cap rejects every booking on the platform",
cxDefaultMaxPackages)
}
}

View File

@@ -0,0 +1,85 @@
package controllers
import (
"strings"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Device registration — §10 of the contract.
//
// One row per device token rather than one column on the customer. A customer
// with a phone and a tablet has to get the delivery notification on both, and
// appcustomers.device_token could only ever hold whichever registered last —
// so the older device silently stopped receiving anything.
// RegisterCxDevice records a push token for the signed-in customer.
func RegisterCxDevice(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
var req struct {
Token string `json:"token"`
Platform string `json:"platform"`
AppVersion string `json:"appVersion"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
token := strings.TrimSpace(req.Token)
if token == "" {
return utils.CxBadRequest(c, "A device token is required")
}
platform := strings.ToLower(strings.TrimSpace(req.Platform))
if platform != "android" && platform != "ios" {
platform = ""
}
device := models.CustomerDevice{
Appcustomerid: customerID,
Token: token,
Platform: platform,
Appversion: strings.TrimSpace(req.AppVersion),
Lastseenat: utils.DBNow(),
}
// A token can migrate between accounts — a shared handset, or a customer
// signing out and a second one signing in. Upserting on the token (rather
// than inserting) reassigns it, which is the only outcome that does not
// send one person's parcel updates to another person's phone.
if err := db.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "token"}},
DoUpdates: clause.AssignmentColumns([]string{
"appcustomerid", "platform", "appversion", "lastseenat",
}),
}).Create(&device).Error; err != nil {
utils.Error("RegisterCxDevice: upsert failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
return utils.CxOK(c, fiber.Map{"registered": true})
}
// UnregisterCxDevice drops a push token. Called on sign-out, so a signed-out
// phone stops receiving updates about a pickup it can no longer open.
func UnregisterCxDevice(c *fiber.Ctx) error {
customerID := c.Locals("userid").(int)
token := strings.TrimSpace(c.Params("token"))
if token == "" {
return utils.CxBadRequest(c, "A device token is required")
}
if err := db.DB.Where("appcustomerid = ? AND token = ?", customerID, token).
Delete(&models.CustomerDevice{}).Error; err != nil && err != gorm.ErrRecordNotFound {
utils.Error("UnregisterCxDevice: delete failed", "customer_id", customerID, "error", err)
return utils.CxInternal(c)
}
return utils.CxOK(c, fiber.Map{"registered": false})
}

View File

@@ -0,0 +1,266 @@
package controllers
import (
"fmt"
"math"
"strings"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// Fare estimate — §7 of the contract.
//
// The number here is an ESTIMATE RANGE and never a final price. Weight is never
// collected from the customer: the miler weighs and photographs each package at
// the door, and that is when the price settles. Everything shown before that is
// a promise about a band, which is why this returns min/max rather than a
// figure.
//
// Called on every route and package-count change, so it stays cheap: the
// pricing slab comes out of the same Redis-warmed cache the public price check
// uses, and the distance is straight-line rather than a routing call.
// cxAssumedKgPerPackage is the weight band the estimate is priced against
// before anything has been weighed. It is the ceiling of the standard box the
// copy promises ("Standard box (up to 3 kg)"), so the customer is quoted the
// top of the band they were shown rather than an optimistic guess that the
// settled price then exceeds.
const cxAssumedKgPerPackage = 3.0
// cxMultiStopUpliftPct is charged per additional destination on one pickup.
// One visit collecting for three places is one visit, so the uplift is well
// under three times the price — but the parcels still travel three separate
// journeys after the hub, and pricing them as one would undercharge the part
// that actually costs money.
const cxMultiStopUpliftPct = 0.35
// cxFallbackBase / cxFallbackPerKm / cxFallbackPerKg reproduce the estimate the
// booking path already falls back to when no pricing rule matches, so an
// unpriced lane quotes the same number it charges.
const (
cxFallbackBase = 50.0
cxFallbackPerKm = 5.0
cxFallbackPerKg = 10.0
)
type cxEstimateDestination struct {
StateCode string `json:"stateCode"`
DistrictCode string `json:"districtCode"`
PackageCount int `json:"packageCount"`
}
type cxEstimateRequest struct {
Pickup struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
} `json:"pickup"`
Destinations []cxEstimateDestination `json:"destinations"`
}
// cxQuote is what both the estimate endpoint and the booking create path work
// from, so the number quoted on Review is the number stored on the booking.
type cxQuote struct {
Min int
Max int
PaymentMethod string
Parcel string
RouteKM float64
}
// EstimateCxFare prices a whole pickup — one visit, every destination.
func EstimateCxFare(c *fiber.Ctx) error {
var req cxEstimateRequest
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
if len(req.Destinations) == 0 {
return utils.CxBadRequest(c, "Add at least one destination")
}
quote := quoteCxPickup(req.Pickup.Lat, req.Pickup.Lng, req.Destinations)
// Volatile enough to be worth a short cache, stable enough that the same
// route typed twice should not re-price.
c.Set("Cache-Control", "max-age=60")
return utils.CxOK(c, fiber.Map{
"min": quote.Min,
"max": quote.Max,
"paymentMethod": quote.PaymentMethod,
"parcel": quote.Parcel,
"routeKm": quote.RouteKM,
})
}
// quoteCxPickup prices one pickup visit across all of its destinations.
//
// The farthest destination sets the route distance shown on the outline and the
// receipt; every destination contributes its own leg price, and the additional
// stops carry an uplift rather than a full second visit.
func quoteCxPickup(pickupLat, pickupLng float64, destinations []cxEstimateDestination) cxQuote {
districts := loadDistricts(destinations)
var totalMin, totalMax, maxRouteKM float64
totalPackages := 0
for i, d := range destinations {
packages := d.PackageCount
if packages < 1 {
packages = 1
}
totalPackages += packages
district, known := districts[strings.ToUpper(strings.TrimSpace(d.DistrictCode))]
routeKM := 0.0
if known && (district.Centrelatitude != 0 || district.Centrelongitude != 0) &&
(pickupLat != 0 || pickupLng != 0) {
routeKM = calculateDistance(pickupLat, pickupLng, district.Centrelatitude, district.Centrelongitude)
}
if routeKM > maxRouteKM {
maxRouteKM = routeKM
}
legMin, legMax := priceLeg(pickupLat, pickupLng, district, known, packages, routeKM)
// The first destination is the visit; every one after it is an extra
// leg on the same visit.
if i > 0 {
legMin *= cxMultiStopUpliftPct + 1
legMax *= cxMultiStopUpliftPct + 1
}
totalMin += legMin
totalMax += legMax
}
// Whole rupees, not paise — "min: 49" renders as ₹49. Rounded outward so
// the band the customer is shown always contains the price that settles
// inside it.
minR := int(math.Floor(totalMin))
maxR := int(math.Ceil(totalMax))
if maxR < minR {
maxR = minR
}
return cxQuote{
Min: minR,
Max: maxR,
PaymentMethod: "UPI · Cash at doorstep",
Parcel: describeParcels(totalPackages),
RouteKM: math.Round(maxRouteKM*10) / 10,
}
}
// priceLeg prices one destination's journey. Falls back to the same
// distance-and-weight formula the booking path uses when no pricing rule
// covers the lane, so an unpriced route still quotes rather than failing —
// a failed estimate must never block a booking.
func priceLeg(pickupLat, pickupLng float64, district models.ServiceableDistrict, known bool, packages int, routeKM float64) (min, max float64) {
weight := float64(packages) * cxAssumedKgPerPackage
zone := "National"
if known && district.Pincodeprefix != "" {
// Zone is resolved from postal prefixes, the same rule the rest of the
// pricing engine uses, so an estimate and a settlement agree on which
// slab applies.
zone = resolveZone(pincodeForPoint(pickupLat, pickupLng), district.Pincodeprefix)
}
if rules := matchedPricingRules(zone, "Normal", weight); len(rules) > 0 {
lo, hi := rules[0].Minprice, rules[0].Maxprice
for _, r := range rules[1:] {
if r.Minprice < lo {
lo = r.Minprice
}
if r.Maxprice > hi {
hi = r.Maxprice
}
}
return lo, hi
}
base := cxFallbackBase + routeKM*cxFallbackPerKm + weight*cxFallbackPerKg
// A ±20% band around the fallback, so the customer still sees a range and
// not a false precision the miler's scale is about to contradict.
return base * 0.8, base * 1.2
}
// matchedPricingRules returns the active rules covering a weight in a zone,
// reusing the Redis-warmed slab the public price check reads.
func matchedPricingRules(zone, serviceType string, weight float64) []models.DoormilePricing {
rules, err := loadFromPostgres(zone, serviceType)
if err != nil || len(rules) == 0 {
return nil
}
matched := applyFilters(rules, weight, "General")
if len(matched) == 0 {
matched = applyFilters(rules, weight, "")
}
return matched
}
// loadDistricts fetches every district named in one estimate in a single query.
func loadDistricts(destinations []cxEstimateDestination) map[string]models.ServiceableDistrict {
codes := make([]string, 0, len(destinations))
seen := map[string]bool{}
for _, d := range destinations {
code := strings.ToUpper(strings.TrimSpace(d.DistrictCode))
if code != "" && !seen[code] {
seen[code] = true
codes = append(codes, code)
}
}
out := make(map[string]models.ServiceableDistrict, len(codes))
if len(codes) == 0 {
return out
}
var districts []models.ServiceableDistrict
if err := db.DB.Where("districtcode IN ?", codes).Find(&districts).Error; err != nil {
utils.Warn("loadDistricts: query failed, pricing on the fallback formula", "error", err)
return out
}
for _, d := range districts {
out[d.Districtcode] = d
}
return out
}
// pincodeForPoint gives the zone resolver something to work with when the
// pickup is a map pin rather than a typed address. Empty is a valid answer —
// resolveZone treats an unknown prefix as a different state, which prices the
// long way round rather than under-quoting.
func pincodeForPoint(lat, lng float64) string {
if lat == 0 && lng == 0 {
return ""
}
var hubs []models.Hub
if err := db.DB.Select("hubid, pincode, latitude, longitude").
Where("status = ? AND deletedat IS NULL", "Active").Find(&hubs).Error; err != nil {
return ""
}
best := -1.0
pincode := ""
for i := range hubs {
h := hubs[i]
if h.Pincode == "" || (h.Latitude == 0 && h.Longitude == 0) {
continue
}
d := calculateDistance(lat, lng, h.Latitude, h.Longitude)
if best < 0 || d < best {
best, pincode = d, h.Pincode
}
}
return pincode
}
// describeParcels is the "what is being priced" line on Review and the receipt.
func describeParcels(packages int) string {
if packages <= 1 {
return fmt.Sprintf("Standard box (up to %.0f kg)", cxAssumedKgPerPackage)
}
return fmt.Sprintf("%d boxes (up to %.0f kg each)", packages, cxAssumedKgPerPackage)
}

713
controllers/cxHttp_test.go Normal file
View File

@@ -0,0 +1,713 @@
package controllers
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"doormile/config"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// HTTP-level contract tests for the customer surface.
//
// These exercise the real handlers through a real Fiber router and assert the
// STATUS CODE and the ENVELOPE the client will actually receive. They cover
// every path that can be reached without a database — which is every validation
// and gate in the surface, and is precisely where a wrong status code would
// reach production unnoticed.
//
// What they deliberately do NOT cover: the happy paths, which need Postgres,
// Redis and NATS. A 200 from CreateCxBooking cannot be asserted here, and
// pretending otherwise with a mock would test the mock. Those need the
// integration pass against staging (see docs/customer-app-api.md §7).
//
// The rule every test below enforces: a validation failure must be a 4xx with a
// machine-readable error.code and customer-safe English. It must NEVER be a 500
// ("Something went wrong" on a request the server understood perfectly well)
// and never a bare 404 from the router (which would mean the route is missing).
// cxFutureSlotID is a slot id whose date cannot go stale. Used by the cases
// where the SLOT is not what is under test — a dated id like slot_20260905_t1
// silently becomes an expired-slot test the day after it was written, and
// would then assert the wrong failure.
const cxFutureSlotID = "slot_20991231_t1"
// cxTestApp builds a router with the customer routes mounted and a stub auth
// middleware, so handler behaviour is tested rather than JWT parsing.
func cxTestApp(t *testing.T, authenticated bool) *fiber.App {
t.Helper()
app := fiber.New(fiber.Config{
// Without this a panic becomes a dropped connection instead of a 500,
// and a test would report a confusing transport error rather than the
// real fault.
DisableStartupMessage: true,
})
app.Use(func(c *fiber.Ctx) error {
if authenticated {
c.Locals("userid", 4242)
c.Locals("roleid", 9)
c.Locals("tenantid", 0)
}
return c.Next()
})
cfg := &config.Config{JWTSecret: "test-secret"}
customer := app.Group("/customer")
customer.Post("/auth/otp/request", CxRequestOtp(cfg))
customer.Post("/auth/signup", CxSignup(cfg))
customer.Post("/auth/otp/verify", CxVerifyOtp(cfg))
customer.Post("/auth/refresh", CxRefresh(cfg))
customer.Post("/fare/estimate", EstimateCxFare)
customer.Post("/bookings", CreateCxBooking)
customer.Patch("/bookings/:reference/destinations/:index", PatchCxDestination)
customer.Get("/orders/:trackingId", GetCxOrder)
customer.Post("/devices", RegisterCxDevice)
customer.Get("/places/reverse-geocode", ReverseGeocodeCx(cfg))
customer.Post("/ops/bookings/:reference/stage", ForceCxStage)
return app
}
type cxResponse struct {
status int
body map[string]interface{}
raw string
}
func cxDo(t *testing.T, app *fiber.App, method, path string, body interface{}) cxResponse {
t.Helper()
var reader io.Reader
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
t.Fatalf("could not encode request body: %v", err)
}
reader = bytes.NewReader(encoded)
}
req := httptest.NewRequest(method, path, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := app.Test(req, 5000)
if err != nil {
t.Fatalf("%s %s: transport error: %v", method, path, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
out := cxResponse{status: resp.StatusCode, raw: string(raw)}
_ = json.Unmarshal(raw, &out.body)
return out
}
// assertCxError checks the full error contract in one place: the status code,
// the envelope shape, the machine code, and that the message is fit to show a
// customer.
func assertCxError(t *testing.T, got cxResponse, wantStatus int, wantCode string) {
t.Helper()
if got.status != wantStatus {
t.Fatalf("status = %d, want %d (body: %s)", got.status, wantStatus, got.raw)
}
if success, _ := got.body["success"].(bool); success {
t.Errorf("success = true on an error response (body: %s)", got.raw)
}
errObj, ok := got.body["error"].(map[string]interface{})
if !ok {
t.Fatalf("no error object in the envelope — the client reads error.code (body: %s)", got.raw)
}
if code, _ := errObj["code"].(string); code != wantCode {
t.Errorf("error.code = %q, want %q", errObj["code"], wantCode)
}
message, _ := got.body["message"].(string)
if message == "" {
t.Error("message is empty — the app renders it verbatim in its one error state")
}
assertCustomerSafe(t, message)
}
// assertCustomerSafe rejects anything that reads like an internal artefact
// rather than something a customer should be shown. The contract is explicit
// that `message` is displayed verbatim and must never be an enum key, a stack
// trace or a driver error.
func assertCustomerSafe(t *testing.T, message string) {
t.Helper()
leaks := []string{
"gorm", "sql:", "pq:", "panic", "nil pointer", "goroutine",
"doormile/", ".go:", "SELECT ", "INSERT ", "record not found",
}
for _, leak := range leaks {
if bytes.Contains([]byte(message), []byte(leak)) {
t.Errorf("message %q leaks an internal detail (%q) to the customer", message, leak)
}
}
// An enum key rather than a sentence — "INVALID_INPUT", "not_found".
if message == "" {
return
}
upperOnly := true
for _, r := range message {
if r >= 'a' && r <= 'z' {
upperOnly = false
break
}
}
if upperOnly {
t.Errorf("message %q looks like an enum key, not customer-safe English", message)
}
}
// ── Auth (§4) ────────────────────────────────────────────────────────────────
func TestCxAuthValidationStatusCodes(t *testing.T) {
app := cxTestApp(t, false)
cases := []struct {
name string
method string
path string
body interface{}
wantStatus int
wantCode string
}{
{
name: "otp request with no identifier",
method: http.MethodPost, path: "/customer/auth/otp/request",
body: map[string]interface{}{"identifier": ""},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "otp request with a malformed phone",
method: http.MethodPost, path: "/customer/auth/otp/request",
body: map[string]interface{}{"identifier": "12345"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "otp request with a malformed email",
method: http.MethodPost, path: "/customer/auth/otp/request",
body: map[string]interface{}{"identifier": "joe@example"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
// The contract pins this to its own code so the app can highlight
// the name field rather than showing a generic error.
name: "signup with a one-character name",
method: http.MethodPost, path: "/customer/auth/signup",
body: map[string]interface{}{"name": "J", "phone": "+919876543210"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalidName,
},
{
name: "signup with no name at all",
method: http.MethodPost, path: "/customer/auth/signup",
body: map[string]interface{}{"phone": "+919876543210"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalidName,
},
{
name: "signup with a valid name but an unusable phone",
method: http.MethodPost, path: "/customer/auth/signup",
body: map[string]interface{}{"name": "Joe Oommen", "phone": "123"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "verify with no code",
method: http.MethodPost, path: "/customer/auth/otp/verify",
body: map[string]interface{}{"identifier": "+919876543210", "code": ""},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "verify with an unusable identifier",
method: http.MethodPost, path: "/customer/auth/otp/verify",
body: map[string]interface{}{"identifier": "nope", "code": "4821"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
// 401 rather than 400: the client's recovery is "sign in again",
// which it branches on the status for.
name: "refresh with no token",
method: http.MethodPost, path: "/customer/auth/refresh",
body: map[string]interface{}{"refreshToken": ""},
wantStatus: fiber.StatusUnauthorized, wantCode: utils.CxErrUnauthorized,
},
{
name: "refresh with a whitespace token",
method: http.MethodPost, path: "/customer/auth/refresh",
body: map[string]interface{}{"refreshToken": " "},
wantStatus: fiber.StatusUnauthorized, wantCode: utils.CxErrUnauthorized,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cxDo(t, app, tc.method, tc.path, tc.body)
// Logged so `go test -v` shows the exact bytes the app receives,
// not just a pass mark. These tests are about the wire contract, so
// the wire response is the evidence.
t.Logf("%s %s -> %d %s", tc.method, tc.path, got.status, got.raw)
assertCxError(t, got, tc.wantStatus, tc.wantCode)
})
}
}
// ── Bookings, estimate, devices, places ──────────────────────────────────────
func TestCxRequestValidationStatusCodes(t *testing.T) {
app := cxTestApp(t, true)
cases := []struct {
name string
method string
path string
body interface{}
wantStatus int
wantCode string
}{
{
name: "booking with no destinations",
method: http.MethodPost, path: "/customer/bookings",
body: map[string]interface{}{"slotId": cxFutureSlotID},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "booking with an empty destinations array",
method: http.MethodPost, path: "/customer/bookings",
body: map[string]interface{}{
"slotId": cxFutureSlotID,
"destinations": []interface{}{},
},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "booking with destinations but no slot",
method: http.MethodPost, path: "/customer/bookings",
body: map[string]interface{}{
"destinations": []map[string]interface{}{
{"stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1},
},
},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "estimate with no destinations",
method: http.MethodPost, path: "/customer/fare/estimate",
body: map[string]interface{}{"pickup": map[string]float64{"lat": 11.0, "lng": 76.9}},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "device registration with no token",
method: http.MethodPost, path: "/customer/devices",
body: map[string]interface{}{"platform": "android"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "device registration with a whitespace token",
method: http.MethodPost, path: "/customer/devices",
body: map[string]interface{}{"token": " ", "platform": "ios"},
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "destination patch with a non-numeric index",
method: http.MethodPatch, path: "/customer/bookings/DM-482913/destinations/abc",
body: map[string]interface{}{"street": "12th Main"},
wantStatus: fiber.StatusNotFound, wantCode: utils.CxErrNotFound,
},
{
name: "destination patch with a negative index",
method: http.MethodPatch, path: "/customer/bookings/DM-482913/destinations/-1",
body: map[string]interface{}{"street": "12th Main"},
wantStatus: fiber.StatusNotFound, wantCode: utils.CxErrNotFound,
},
{
name: "reverse geocode with no coordinates",
method: http.MethodGet, path: "/customer/places/reverse-geocode",
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "reverse geocode with unparseable coordinates",
method: http.MethodGet, path: "/customer/places/reverse-geocode?lat=abc&lng=def",
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
{
name: "reverse geocode at the null island",
method: http.MethodGet, path: "/customer/places/reverse-geocode?lat=0&lng=0",
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cxDo(t, app, tc.method, tc.path, tc.body)
t.Logf("%s %s -> %d %s", tc.method, tc.path, got.status, got.raw)
assertCxError(t, got, tc.wantStatus, tc.wantCode)
})
}
}
// A body the parser cannot read is the customer's problem to fix, not a server
// fault. Answering 500 here would put a "Something went wrong" retry loop in
// front of a request that will never succeed.
func TestCxMalformedJsonIsFourHundredNotFiveHundred(t *testing.T) {
app := cxTestApp(t, true)
for _, path := range []string{
"/customer/bookings",
"/customer/fare/estimate",
"/customer/devices",
} {
t.Run(path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte("{not json")))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, 5000)
if err != nil {
t.Fatalf("transport error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("status = %d on malformed JSON, want 4xx (body: %s)", resp.StatusCode, raw)
}
if resp.StatusCode != fiber.StatusBadRequest {
t.Errorf("status = %d, want 400", resp.StatusCode)
}
})
}
}
// ── The QA stage override (§11) ──────────────────────────────────────────────
// The override is double-gated. Both switches off must be indistinguishable
// from the route not existing — advertising a disabled admin capability tells
// an attacker exactly what to go looking for.
func TestForceStageIsInvisibleUnlessBothGatesAreOpen(t *testing.T) {
app := cxTestApp(t, true)
restore := func(key, value string) func() {
previous, had := os.LookupEnv(key)
_ = os.Setenv(key, value)
return func() {
if had {
_ = os.Setenv(key, previous)
} else {
_ = os.Unsetenv(key)
}
}
}
cases := []struct {
name string
env string
override string
}{
{"both gates closed", "development", ""},
{"override off in development", "development", "false"},
{"override on but production", "production", "true"},
{"override on but PRODUCTION in caps", "PRODUCTION", "true"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
defer restore("ENV", tc.env)()
defer restore("CX_ALLOW_STAGE_OVERRIDE", tc.override)()
got := cxDo(t, app, http.MethodPost,
"/customer/ops/bookings/DM-482913/stage",
map[string]interface{}{"stage": "delivered"})
if got.status != fiber.StatusNotFound {
t.Fatalf("status = %d, want 404 — a disabled override must not announce itself (body: %s)",
got.status, got.raw)
}
})
}
}
// With both gates open the route is reachable, and an unknown stage is a
// validation failure rather than a server fault.
func TestForceStageRejectsAnUnknownStage(t *testing.T) {
app := cxTestApp(t, true)
previousEnv, hadEnv := os.LookupEnv("ENV")
previousOverride, hadOverride := os.LookupEnv("CX_ALLOW_STAGE_OVERRIDE")
_ = os.Setenv("ENV", "development")
_ = os.Setenv("CX_ALLOW_STAGE_OVERRIDE", "true")
defer func() {
if hadEnv {
_ = os.Setenv("ENV", previousEnv)
} else {
_ = os.Unsetenv("ENV")
}
if hadOverride {
_ = os.Setenv("CX_ALLOW_STAGE_OVERRIDE", previousOverride)
} else {
_ = os.Unsetenv("CX_ALLOW_STAGE_OVERRIDE")
}
}()
got := cxDo(t, app, http.MethodPost,
"/customer/ops/bookings/DM-482913/stage",
map[string]interface{}{"stage": "teleported"})
assertCxError(t, got, fiber.StatusBadRequest, utils.CxErrInvalid)
}
// ── Envelope shape (§3.1, §3.4) ──────────────────────────────────────────────
// Every success response carries `message` as a present-but-empty string. The
// contract states it explicitly, and a client that reads message.length on a
// missing key throws.
func TestSuccessEnvelopeAlwaysCarriesAnEmptyMessage(t *testing.T) {
app := fiber.New(fiber.Config{DisableStartupMessage: true})
app.Get("/ok", func(c *fiber.Ctx) error {
return utils.CxOK(c, fiber.Map{"value": 1})
})
app.Get("/created", func(c *fiber.Ctx) error {
return utils.CxCreated(c, fiber.Map{"value": 1})
})
app.Get("/list", func(c *fiber.Ctx) error {
return utils.CxList(c, []int{1, 2}, 2, nil)
})
cases := []struct {
path string
wantStatus int
}{
{"/ok", fiber.StatusOK},
{"/created", fiber.StatusCreated},
{"/list", fiber.StatusOK},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
got := cxDo(t, app, http.MethodGet, tc.path, nil)
if got.status != tc.wantStatus {
t.Fatalf("status = %d, want %d", got.status, tc.wantStatus)
}
if success, _ := got.body["success"].(bool); !success {
t.Error("success != true on a success response")
}
if _, present := got.body["message"]; !present {
t.Error("message key missing — the contract says always present, empty on success")
}
if message, _ := got.body["message"].(string); message != "" {
t.Errorf("message = %q on a success response, want empty", message)
}
if _, present := got.body["data"]; !present {
t.Error("data key missing — every payload lives in data, auth included")
}
})
}
}
// A list envelope always carries an ARRAY and an explicit nextCursor, even when
// empty. The client types data as a list and nextCursor as nullable; a missing
// key or a null data throws in its parser.
func TestListEnvelopeIsAlwaysAnArrayWithACursorKey(t *testing.T) {
app := fiber.New(fiber.Config{DisableStartupMessage: true})
app.Get("/empty", func(c *fiber.Ctx) error {
return utils.CxList(c, []string{}, 0, nil)
})
cursor := "1042"
app.Get("/paged", func(c *fiber.Ctx) error {
return utils.CxList(c, []string{"a"}, 9, &cursor)
})
empty := cxDo(t, app, http.MethodGet, "/empty", nil)
if empty.status != fiber.StatusOK {
t.Fatalf("status = %d, want 200", empty.status)
}
if _, ok := empty.body["data"].([]interface{}); !ok {
t.Errorf("data is not an array on an empty list (body: %s)", empty.raw)
}
if _, present := empty.body["nextCursor"]; !present {
t.Error("nextCursor key missing — it must be present and null on the last page")
}
if empty.body["nextCursor"] != nil {
t.Errorf("nextCursor = %v on the last page, want null", empty.body["nextCursor"])
}
if total, _ := empty.body["total"].(float64); total != 0 {
t.Errorf("total = %v, want 0", empty.body["total"])
}
paged := cxDo(t, app, http.MethodGet, "/paged", nil)
if got, _ := paged.body["nextCursor"].(string); got != cursor {
t.Errorf("nextCursor = %v, want %q", paged.body["nextCursor"], cursor)
}
if total, _ := paged.body["total"].(float64); total != 9 {
t.Errorf("total = %v, want 9 (the size of the filtered set, not the page)", paged.body["total"])
}
}
// Every code in the contract maps to the status the client branches on, and
// none of them produce a 5xx.
func TestErrorEnvelopeStatusCodeMapping(t *testing.T) {
app := fiber.New(fiber.Config{DisableStartupMessage: true})
cases := []struct {
name string
status int
code string
message string
}{
{"invalid", fiber.StatusBadRequest, utils.CxErrInvalid, "Every destination needs a serviceable state and district"},
{"invalid_name", fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name"},
{"invalid_otp", fiber.StatusUnauthorized, utils.CxErrInvalidOtp, "That code did not match"},
{"unauthorized", fiber.StatusUnauthorized, utils.CxErrUnauthorized, "Please sign in again"},
{"forbidden", fiber.StatusForbidden, utils.CxErrForbidden, "You do not have access to this"},
{"not_found", fiber.StatusNotFound, utils.CxErrNotFound, "We could not find that pickup"},
{"conflict", fiber.StatusConflict, utils.CxErrConflict, "This pickup can no longer be cancelled"},
{"unserviceable", fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable, "That district is no longer available"},
{"rate_limited", fiber.StatusTooManyRequests, utils.CxErrRateLimited, "Too many attempts. Try again in a minute"},
}
for _, tc := range cases {
tc := tc
app.Get("/"+tc.name, func(c *fiber.Ctx) error {
return utils.CxFail(c, tc.status, tc.code, tc.message)
})
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cxDo(t, app, http.MethodGet, "/"+tc.name, nil)
assertCxError(t, got, tc.status, tc.code)
if got.status >= 500 {
t.Errorf("a documented client error answered %d", got.status)
}
})
}
}
// CxInternal is the only 5xx the surface produces, and it must never carry the
// underlying error outward.
func TestInternalErrorNeverLeaksTheCause(t *testing.T) {
app := fiber.New(fiber.Config{DisableStartupMessage: true})
app.Get("/boom", func(c *fiber.Ctx) error {
return utils.CxInternal(c)
})
got := cxDo(t, app, http.MethodGet, "/boom", nil)
if got.status != fiber.StatusInternalServerError {
t.Fatalf("status = %d, want 500", got.status)
}
if message, _ := got.body["message"].(string); message != "Something went wrong" {
t.Errorf("message = %q, want the fixed customer-safe string", message)
}
assertCustomerSafe(t, got.body["message"].(string))
errObj, ok := got.body["error"].(map[string]interface{})
if !ok || errObj["code"] != utils.CxErrServer {
t.Errorf("error.code = %v, want %q", got.body["error"], utils.CxErrServer)
}
}
// An expired slot and a full slot are different failures. A client that cached
// the slot list and was left open across midnight sends yesterday's window in
// good faith; telling that customer the window "just filled up" is untrue and
// points them at the wrong recovery.
func TestExpiredSlotIsNotReportedAsFull(t *testing.T) {
app := cxTestApp(t, true)
// A slot id from a date that has certainly passed. It is rejected before
// any database access, because the id carries its own date.
got := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{
"pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558},
"slotId": "slot_20200101_t1",
"destinations": []map[string]interface{}{
{"stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1},
},
})
if got.status == fiber.StatusConflict {
t.Fatalf("an expired slot answered 409 — that is the capacity race, not a stale id (body: %s)", got.raw)
}
if got.status >= 500 {
t.Fatalf("status = %d, want 4xx (body: %s)", got.status, got.raw)
}
if message, _ := got.body["message"].(string); strings.Contains(strings.ToLower(message), "filled up") {
t.Errorf("message = %q — an expired slot is not a full one", message)
}
}
// The server refuses an over-cap booking itself. The client's own limit is UI
// guidance — a modified build, or one whose /config/booking-limits fetch
// failed, still cannot create a booking the fleet cannot service.
func TestMaxDestinationsIsEnforcedServerSide(t *testing.T) {
app := cxTestApp(t, true)
// Above the absolute ceiling, so the refusal lands before any database
// work and is assertable here. The configured per-city cap is the real
// policy and is exercised separately in cxCustomerApp_test.go.
destinations := make([]map[string]interface{}, 0, cxAbsoluteMaxDestinations+1)
for i := 0; i <= cxAbsoluteMaxDestinations; i++ {
destinations = append(destinations, map[string]interface{}{
"stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1,
})
}
got := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{
"pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558},
"slotId": "slot_20991231_t1",
"destinations": destinations,
})
if got.status >= 500 {
t.Fatalf("status = %d, want a 4xx refusal (body: %s)", got.status, got.raw)
}
if got.status < 400 {
t.Fatalf("status = %d — an over-cap booking was accepted (body: %s)", got.status, got.raw)
}
}
// "No destinations at all" and "a destination is missing its state or district"
// are different problems with different fixes. They shared one message, which
// told a customer who had added nothing to go and correct the state on
// destinations they did not have. The messages must stay distinct AND the empty
// case must match what the estimate endpoint says for the same mistake.
func TestEmptyDestinationsSaysAddOneNotFixTheirDetails(t *testing.T) {
app := cxTestApp(t, true)
booking := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{
"pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558},
"slotId": cxFutureSlotID,
"destinations": []interface{}{},
})
estimate := cxDo(t, app, http.MethodPost, "/customer/fare/estimate", map[string]interface{}{
"pickup": map[string]interface{}{"lat": 11.0168, "lng": 76.9558},
"destinations": []interface{}{},
})
t.Logf("booking -> %d %s", booking.status, booking.raw)
t.Logf("estimate -> %d %s", estimate.status, estimate.raw)
bookingMsg, _ := booking.body["message"].(string)
estimateMsg, _ := estimate.body["message"].(string)
if strings.Contains(bookingMsg, "serviceable state and district") {
t.Errorf("empty destinations answered %q — that describes a problem the customer does not have; "+
"they added nothing, so they need to add one", bookingMsg)
}
if bookingMsg != estimateMsg {
t.Errorf("the same mistake is described two ways: booking says %q, estimate says %q",
bookingMsg, estimateMsg)
}
if !strings.Contains(strings.ToLower(bookingMsg), "at least one destination") {
t.Errorf("message = %q, want it to name the actual fix (add a destination)", bookingMsg)
}
}

View File

@@ -0,0 +1,181 @@
package controllers
import (
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"os"
"strings"
"doormile/utils"
)
// Format-preserving scrambling for the customer-facing identifiers.
//
// THE PROBLEM. Both identifiers come off a Postgres sequence, because a
// sequence is the only generator here that can promise uniqueness — the columns
// are UNIQUE, and a random 8-digit id collides with ~43% probability by the
// ten-thousandth parcel, which would be a rider unable to complete a pickup.
// But a sequence is also readable: DMX10000042 and DMX10000043 are visibly
// adjacent, so anyone holding two numbers learns the throughput between them,
// and anyone holding one can guess its neighbours.
//
// THE FIX. Keep the sequence — keep its uniqueness guarantee — and pass the
// index through a bijection before formatting it. Same one-to-one property, so
// no two parcels can ever collide; unrelated outputs, so nothing is guessable
// from a neighbour.
//
// WHY NOT THE OBVIOUS TRICK. Multiplying by a number coprime with the domain is
// also a bijection and is one line. It is wrong here: a multi-destination
// pickup hands ONE customer three consecutive sequence values, so the
// differences between their three tracking numbers are all exactly the
// multiplier. One booking leaks the key, and the whole range becomes walkable.
// The mapping has to be non-linear.
//
// WHAT THIS IS. A 4-round balanced Feistel network keyed with HMAC-SHA256, plus
// cycle-walking to keep the result inside the digit range. A Feistel is a
// bijection for ANY round function — that is its defining property — so
// uniqueness survives regardless of the key. Cycle-walking (re-encrypt until
// the output lands in range) preserves bijectivity on the subset.
//
// WHAT THIS IS NOT. Not a security boundary. Every route that resolves a
// tracking number is already authenticated and owner-scoped, and that is what
// actually stops a stranger reading someone's parcel. This removes the
// information leak in the identifier itself, so the authorisation check is not
// the only thing standing between an outsider and your volume figures.
const (
// Tracking numbers occupy DMX10000000..DMX99999999 — 90,000,000 values,
// always eight digits so the format never changes width.
cxTrackingBase = 10_000_000
cxTrackingDomain = 90_000_000
// 28 bits (two 14-bit halves) is the smallest even split covering the
// domain. Cycle-walking averages ~3 encryptions per id; each is four
// HMACs, so this is microseconds.
cxTrackingHalfBits = 14
// Booking references occupy DM-100000..DM-999999 — 900,000 values, always
// six digits.
cxBookingBase = 100_000
cxBookingDomain = 900_000
// 20 bits (two 10-bit halves). Cycle-walking averages ~1.2 encryptions.
cxBookingHalfBits = 10
// cxFeistelRounds. Four is the standard minimum for a Feistel to be a
// strong pseudorandom permutation (Luby–Rackoff). More rounds cost HMACs
// for no property this needs.
cxFeistelRounds = 4
// cxCycleWalkLimit bounds the walk so a pathological key can never hang a
// request. Reaching it is astronomically unlikely — each step has a ~2/3
// chance of landing in range for tracking numbers — and the caller falls
// back to the plain sequence rather than failing a booking.
cxCycleWalkLimit = 64
)
// cxScrambleKey keys the round function.
//
// Overridable via CX_ID_SCRAMBLE_KEY. Changing it changes every identifier
// minted AFTERWARDS and none already stored, so rotation is safe but leaves a
// visible discontinuity — there is no reason to rotate it, and a good reason
// not to.
//
// The built-in default is not a secret and is not pretending to be one. It
// exists so the scrambling works out of the box rather than being silently off
// on any deployment that forgot to set an env var — an identifier scheme that
// depends on configuration to be safe is one that will be unsafe somewhere.
var cxScrambleKey = func() []byte {
if k := strings.TrimSpace(os.Getenv("CX_ID_SCRAMBLE_KEY")); k != "" {
return []byte(k)
}
return []byte("doormile-cx-identifier-permutation-v1")
}()
// cxFeistelRound is the round function. It need not be invertible — a Feistel
// is a bijection whatever this returns — so any keyed mixing works, and HMAC
// gives good diffusion for four bytes of output.
func cxFeistelRound(half uint64, round int, key []byte) uint64 {
var buf [9]byte
binary.BigEndian.PutUint64(buf[:8], half)
buf[8] = byte(round)
mac := hmac.New(sha256.New, key)
mac.Write(buf[:])
sum := mac.Sum(nil)
return uint64(binary.BigEndian.Uint32(sum[:4]))
}
// cxFeistelEncrypt permutes a value within 2^(2*halfBits).
//
// Bijective by construction: every round is invertible because the half that is
// mixed is carried forward untouched, so the whole network can be run
// backwards. That is the property the UNIQUE constraint depends on.
func cxFeistelEncrypt(x uint64, halfBits uint, key []byte) uint64 {
mask := uint64(1)<<halfBits - 1
left := (x >> halfBits) & mask
right := x & mask
for round := 0; round < cxFeistelRounds; round++ {
left, right = right, left^(cxFeistelRound(right, round, key)&mask)
}
return (left << halfBits) | right
}
// cxPermuteIndex maps a sequence index onto a scattered index in the same
// domain, one-to-one.
//
// Cycle-walking: the Feistel operates on the whole power-of-two space, which is
// larger than the digit range, so an output that overshoots is re-encrypted
// until it lands inside. Re-encrypting a bijection is still a bijection on the
// subset, so no two indices can ever converge.
func cxPermuteIndex(index, domain uint64, halfBits uint) uint64 {
if index >= domain {
// Past the end of the fixed-width range. The caller handles this;
// returning the index unchanged keeps the function total.
return index
}
x := index
for i := 0; i < cxCycleWalkLimit; i++ {
x = cxFeistelEncrypt(x, halfBits, cxScrambleKey)
if x < domain {
return x
}
}
// Unreachable in practice. Falling back to the sequential index keeps the
// identifier unique — which is the property that must never break — and
// loses only the scattering.
utils.Warn("cxPermuteIndex: cycle walk did not converge, using the sequential index",
"index", index, "domain", domain)
return index
}
// cxScrambledTracking turns a sequence value into the eight digits after DMX.
//
// Returns ok=false once the sequence runs past the fixed-width range, so the
// caller can fall back to plain sequential formatting and let the identifier
// grow a digit rather than wrapping onto one already issued.
func cxScrambledTracking(seq int64) (uint64, bool) {
if seq < cxTrackingBase {
return 0, false
}
index := uint64(seq - cxTrackingBase)
if index >= cxTrackingDomain {
return 0, false
}
return cxTrackingBase + cxPermuteIndex(index, cxTrackingDomain, cxTrackingHalfBits), true
}
// cxScrambledBooking is the same for the six digits after DM-.
func cxScrambledBooking(seq int64) (uint64, bool) {
if seq < cxBookingBase {
return 0, false
}
index := uint64(seq - cxBookingBase)
if index >= cxBookingDomain {
return 0, false
}
return cxBookingBase + cxPermuteIndex(index, cxBookingDomain, cxBookingHalfBits), true
}

View File

@@ -0,0 +1,281 @@
package controllers
import (
"fmt"
"testing"
)
// The scrambling exists to remove an information leak, but the property that
// MUST survive it is uniqueness: trackingno and bookingno are UNIQUE columns,
// and a collision is a rider standing at a door unable to complete a pickup.
// Every test here is ultimately about that.
// No two sequence values may ever produce the same tracking number. Checked
// over a large contiguous run, which is exactly the shape real traffic takes.
func TestTrackingScrambleIsCollisionFree(t *testing.T) {
const sample = 200_000
seen := make(map[uint64]int64, sample)
for seq := int64(cxTrackingBase); seq < cxTrackingBase+sample; seq++ {
got, ok := cxScrambledTracking(seq)
if !ok {
t.Fatalf("seq %d reported out of range inside the domain", seq)
}
if prev, dup := seen[got]; dup {
t.Fatalf("COLLISION: seq %d and seq %d both produced %d — "+
"the UNIQUE constraint would reject the second booking", prev, seq, got)
}
seen[got] = seq
}
if len(seen) != sample {
t.Errorf("produced %d distinct numbers from %d inputs", len(seen), sample)
}
}
func TestBookingScrambleIsCollisionFree(t *testing.T) {
// The booking domain is only 900,000, so the whole thing is checkable —
// this is an exhaustive proof of bijectivity, not a sample.
seen := make(map[uint64]int64, cxBookingDomain)
for seq := int64(cxBookingBase); seq < cxBookingBase+cxBookingDomain; seq++ {
got, ok := cxScrambledBooking(seq)
if !ok {
t.Fatalf("seq %d reported out of range inside the domain", seq)
}
if prev, dup := seen[got]; dup {
t.Fatalf("COLLISION: seq %d and seq %d both produced %d", prev, seq, got)
}
seen[got] = seq
}
if len(seen) != cxBookingDomain {
t.Fatalf("the permutation is not a bijection: %d distinct outputs from %d inputs",
len(seen), cxBookingDomain)
}
}
// Output must stay inside the digit range, or the format silently changes
// width and every label, column and deep link that assumed it breaks.
func TestScrambledIdentifiersKeepTheirWidth(t *testing.T) {
for _, seq := range []int64{
cxTrackingBase,
cxTrackingBase + 1,
cxTrackingBase + 12_345,
cxTrackingBase + cxTrackingDomain - 1,
} {
got, ok := cxScrambledTracking(seq)
if !ok {
t.Fatalf("seq %d out of range", seq)
}
if got < cxTrackingBase || got > 99_999_999 {
t.Errorf("seq %d produced %d, outside the eight-digit range", seq, got)
}
if formatted := fmt.Sprintf("DMX%08d", got); len(formatted) != 11 {
t.Errorf("formatted as %q (%d chars), want 11", formatted, len(formatted))
}
}
for _, seq := range []int64{
cxBookingBase,
cxBookingBase + 1,
cxBookingBase + cxBookingDomain - 1,
} {
got, ok := cxScrambledBooking(seq)
if !ok {
t.Fatalf("seq %d out of range", seq)
}
if got < cxBookingBase || got > 999_999 {
t.Errorf("seq %d produced %d, outside the six-digit range", seq, got)
}
if formatted := fmt.Sprintf("DM-%06d", got); len(formatted) != 9 {
t.Errorf("formatted as %q (%d chars), want 9", formatted, len(formatted))
}
}
}
// The point of the whole exercise: consecutive sequence values must NOT produce
// adjacent identifiers. This is the leak being closed.
func TestConsecutiveSequenceValuesAreNotAdjacent(t *testing.T) {
const run = 500
var previous uint64
adjacent := 0
for i := 0; i < run; i++ {
got, _ := cxScrambledTracking(int64(cxTrackingBase + i))
if i > 0 {
diff := int64(got) - int64(previous)
if diff < 0 {
diff = -diff
}
if diff < 100 {
adjacent++
}
}
previous = got
}
// In a well-scattered 90,000,000-wide range, landing within 100 of the
// previous value should essentially never happen.
if adjacent > 2 {
t.Errorf("%d of %d consecutive pairs landed within 100 of each other — "+
"the identifiers are still walkable", adjacent, run-1)
}
}
// A multi-destination pickup hands ONE customer several consecutive sequence
// values at once. If the mapping were linear — multiply by a coprime, the
// obvious one-line trick — the differences between those tracking numbers would
// all equal the multiplier, and that single booking would hand over the key to
// the whole range. This is the test that rejects that design.
func TestOneBookingDoesNotLeakTheMapping(t *testing.T) {
// Three orders minted back to back, as a three-destination pickup would.
a, _ := cxScrambledTracking(cxTrackingBase + 5000)
b, _ := cxScrambledTracking(cxTrackingBase + 5001)
c, _ := cxScrambledTracking(cxTrackingBase + 5002)
d1 := int64(b) - int64(a)
d2 := int64(c) - int64(b)
if d1 == d2 {
t.Fatalf("consecutive differences are identical (%d) — the mapping is "+
"linear, so one multi-destination booking reveals it and the whole "+
"range becomes enumerable", d1)
}
// And knowing two neighbours must not predict the third.
if int64(c) == int64(b)+d1 {
t.Error("the third identifier is predictable from the first two")
}
}
// The mapping is deterministic — the same sequence value always yields the same
// identifier. It is computed at insert time and stored, so this matters only
// for reasoning and tests, but a non-deterministic mapping would mean the
// scrambling depended on something it should not.
func TestScramblingIsDeterministic(t *testing.T) {
for _, seq := range []int64{cxTrackingBase, cxTrackingBase + 99, cxTrackingBase + 123_456} {
first, _ := cxScrambledTracking(seq)
for i := 0; i < 5; i++ {
again, _ := cxScrambledTracking(seq)
if again != first {
t.Fatalf("seq %d produced %d then %d", seq, first, again)
}
}
}
}
// Past the fixed-width range the caller must be told, so it can let the
// identifier grow a digit rather than wrap onto one already issued. Wrapping
// would be a duplicate, and a duplicate is a failed booking.
func TestExhaustedDomainIsReportedNotWrapped(t *testing.T) {
if _, ok := cxScrambledTracking(cxTrackingBase + cxTrackingDomain); ok {
t.Error("the first sequence value past the tracking domain was accepted — " +
"it would wrap onto an identifier already issued")
}
if _, ok := cxScrambledBooking(cxBookingBase + cxBookingDomain); ok {
t.Error("the first sequence value past the booking domain was accepted")
}
// And the generator falls back to plain sequential formatting there, which
// grows a digit rather than colliding.
if got := fmt.Sprintf("DM-%06d", cxBookingBase+cxBookingDomain); len(got) != 10 {
t.Errorf("the overflow reference formats as %q; it should simply grow a digit", got)
}
}
// A value below the sequence start is not a valid index and must be refused
// rather than producing a negative or wrapped result.
func TestBelowBaseIsRefused(t *testing.T) {
if _, ok := cxScrambledTracking(0); ok {
t.Error("seq 0 accepted for tracking")
}
if _, ok := cxScrambledBooking(cxBookingBase - 1); ok {
t.Error("a sequence value below the booking base was accepted")
}
}
// The Feistel itself is a permutation over the full power-of-two space. This is
// the property everything else rests on, so it is checked directly rather than
// only through its callers.
func TestFeistelIsAPermutation(t *testing.T) {
const halfBits = 8 // a 16-bit space, small enough to check exhaustively
full := uint64(1) << (2 * halfBits)
seen := make(map[uint64]uint64, full)
for x := uint64(0); x < full; x++ {
y := cxFeistelEncrypt(x, halfBits, cxScrambleKey)
if y >= full {
t.Fatalf("encrypt(%d) = %d, outside the %d-wide space", x, y, full)
}
if prev, dup := seen[y]; dup {
t.Fatalf("not a permutation: %d and %d both map to %d", prev, x, y)
}
seen[y] = x
}
if uint64(len(seen)) != full {
t.Fatalf("covered %d of %d values", len(seen), full)
}
}
// A different key must produce a different permutation — otherwise the key is
// not actually keying anything.
func TestKeyChangesThePermutation(t *testing.T) {
const halfBits = 8
differences := 0
for x := uint64(0); x < 256; x++ {
if cxFeistelEncrypt(x, halfBits, []byte("key-one")) !=
cxFeistelEncrypt(x, halfBits, []byte("key-two")) {
differences++
}
}
if differences < 250 {
t.Errorf("only %d of 256 values differed between keys — the key has "+
"little effect on the mapping", differences)
}
}
// Every surface — customer app, miler app, admin console, hub console — reads
// the SAME column, so format consistency is structural: one generator, one
// stored value. These assert the generators themselves produce the documented
// shape, including on the fallback path that runs when the sequence cannot be
// read (no database in a test, which is exactly what exercises it here).
func TestGeneratorsProduceTheDocumentedFormat(t *testing.T) {
for i := 0; i < 50; i++ {
booking := generateBookingNo()
if len(booking) < 9 || booking[:3] != "DM-" {
t.Fatalf("generateBookingNo() = %q, want DM- followed by at least six digits", booking)
}
for _, r := range booking[3:] {
if r < '0' || r > '9' {
t.Fatalf("generateBookingNo() = %q — the part after DM- must be digits only", booking)
}
}
tracking := generateTrackingNo()
if len(tracking) < 11 || tracking[:3] != "DMX" {
t.Fatalf("generateTrackingNo() = %q, want DMX followed by at least eight digits", tracking)
}
for _, r := range tracking[3:] {
if r < '0' || r > '9' {
t.Fatalf("generateTrackingNo() = %q — the part after DMX must be digits only", tracking)
}
}
// A tracking number must never be mistakeable for a booking reference:
// the assistant and the consoles tell them apart by prefix alone.
if tracking[:3] == "DM-" {
t.Fatalf("tracking number %q collides with the booking reference prefix", tracking)
}
}
}
// The fallback path must never emit a short number that formats with leading
// zeros — DM-000042 reads as a broken reference, and a padded id is a support
// call.
func TestFallbackNumberNeverGoesShort(t *testing.T) {
for i := 0; i < 200; i++ {
if n := fallbackNumber(6); n < 100_000 || n > 999_999 {
t.Fatalf("fallbackNumber(6) = %d, outside the six-digit range", n)
}
if n := fallbackNumber(8); n < 10_000_000 || n > 99_999_999 {
t.Fatalf("fallbackNumber(8) = %d, outside the eight-digit range", n)
}
}
}

View File

@@ -0,0 +1,95 @@
package controllers
import (
"fmt"
"math/rand"
"time"
"doormile/db"
"doormile/utils"
)
// Human-facing identifiers.
//
// A pickup booking is DM-######; an order is DMX########. Both columns are
// UNIQUE, and both used to be minted from four random bytes plus a truncated
// unix second. Random short ids collide long before the id space runs out, and
// a collision here is not a retry — it is a failed booking at the moment the
// customer taps Confirm. So both come off a Postgres sequence, which is the
// only generator in this system that can promise uniqueness.
//
// The sequences have no MAXVALUE and no CYCLE (migrations/migrate.go): past
// 999999 the reference simply grows a digit rather than wrapping onto an id
// that already exists. Rows written before this change keep their old
// DM-BK-/DM-TRK- strings; nothing anywhere parses either format, so the two
// coexist and no backfill is needed.
// nextSequenceValue draws the next value from a Postgres sequence.
func nextSequenceValue(sequence string) (int64, bool) {
if db.DB == nil {
return 0, false
}
var n int64
if err := db.DB.Raw(fmt.Sprintf("SELECT nextval('%s')", sequence)).Scan(&n).Error; err != nil {
utils.Warn("identifier sequence unavailable, falling back", "sequence", sequence, "error", err)
return 0, false
}
return n, true
}
// fallbackNumber is used only when the sequence cannot be read — a database
// that is unreachable, or a deployment where the migration has not run yet. It
// keeps the shape of the identifier (so the client and the console never see a
// second format) and takes its entropy from the clock plus a random tail,
// which makes a collision vanishingly unlikely for the short window this path
// is ever live. It is a degradation, not a design: the sequence is the
// guarantee.
func fallbackNumber(digits int) int64 {
span := int64(1)
for i := 0; i < digits; i++ {
span *= 10
}
base := time.Now().UnixNano() % span
jitter := rand.Int63n(1000)
n := (base + jitter) % span
// Never return a value that would render with fewer digits than the format
// promises — DM-000042 reads as a broken reference, not a short one.
if n < span/10 {
n += span / 10
}
return n
}
// generateBookingNo mints a pickup booking reference: DM-482913.
//
// The sequence guarantees uniqueness; cxScrambledBooking scatters it so
// consecutive bookings do not get adjacent references. See
// cxIdentifierScramble.go for why the scattering is a keyed permutation rather
// than arithmetic.
//
// Past 900,000 bookings the fixed-width range is exhausted and the reference
// grows a digit instead of wrapping onto one already issued. Uniqueness is
// never traded for appearance.
func generateBookingNo() string {
if n, ok := nextSequenceValue("cx_booking_reference_seq"); ok {
if scrambled, inRange := cxScrambledBooking(n); inRange {
return fmt.Sprintf("DM-%06d", scrambled)
}
return fmt.Sprintf("DM-%06d", n)
}
return fmt.Sprintf("DM-%06d", fallbackNumber(6))
}
// generateTrackingNo mints an order's tracking number: DMX10482913. One per
// destination, minted when the miler completes the pickup — never at booking
// time, because until the parcels are actually collected there is no order to
// track.
func generateTrackingNo() string {
if n, ok := nextSequenceValue("cx_tracking_seq"); ok {
if scrambled, inRange := cxScrambledTracking(n); inRange {
return fmt.Sprintf("DMX%08d", scrambled)
}
return fmt.Sprintf("DMX%08d", n)
}
return fmt.Sprintf("DMX%08d", fallbackNumber(8))
}

View File

@@ -0,0 +1,160 @@
package controllers
import (
"os"
"strings"
"doormile/constants"
"doormile/db"
"doormile/internal/cxstage"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// QA support — §11 of the deliverables.
//
// "A way to force a booking to any stage on staging. Every tracking state must
// be reachable for QA — this is what lets us delete the debug stepper."
//
// The customer app currently walks its tracking screen through a hardcoded
// stepper because no real backend could produce those states on demand.
// Reaching out_for_delivery honestly needs a rider to accept, drive, weigh a
// parcel, hand it to a hub and start a delivery run — which is not something
// design QA can do before every screenshot.
//
// This endpoint is hard-gated. It refuses outright when ENV is production, and
// it additionally requires CX_ALLOW_STAGE_OVERRIDE to be set: two independent
// switches, because one of them being wrong on a production deploy would hand
// anyone with a customer token the ability to mark their own parcel delivered.
// cxStageOverrideEnabled reports whether the QA stage override may run at all.
func cxStageOverrideEnabled() bool {
if strings.EqualFold(strings.TrimSpace(os.Getenv("ENV")), "production") {
return false
}
return strings.EqualFold(strings.TrimSpace(os.Getenv("CX_ALLOW_STAGE_OVERRIDE")), "true")
}
// ForceCxStage drives a booking to an arbitrary stage on staging.
//
// It writes through the same cxstage recorder every real transition uses, so
// what QA sees is the real projection over real event rows — not a special
// rendering path that could pass while the production one is broken.
func ForceCxStage(c *fiber.Ctx) error {
if !cxStageOverrideEnabled() {
return utils.CxNotFound(c, "Not available")
}
customerID := c.Locals("userid").(int)
reference := strings.TrimSpace(c.Params("reference"))
var req struct {
Stage string `json:"stage"`
// Reason is recorded on the audit row so a forced transition is
// distinguishable from a real one forever after. A staging database
// that gets promoted, or an export read months later, must not present
// invented history as observed history.
Reason string `json:"reason"`
}
if err := c.BodyParser(&req); err != nil {
return utils.CxBadRequest(c, "We could not read that request")
}
stage := strings.TrimSpace(req.Stage)
if constants.CxStageRank(stage) < 0 {
return utils.CxBadRequest(c, "Unknown stage")
}
booking, err := cxLoadBooking(customerID, reference)
if err != nil {
return utils.CxNotFound(c, "We could not find that pickup")
}
var destinations []models.BookingDestination
if err := db.DB.Where("bookingid = ?", booking.Bookingid).
Order("seq ASC").Find(&destinations).Error; err != nil {
utils.Error("ForceCxStage: destination query failed", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
reason = "forced on staging for QA"
}
tx := db.DB.Begin()
// Walk every stage up to the target rather than jumping. A timeline with a
// hole in it is not a state the production flow can produce, so testing
// against one proves nothing about the screen that renders it.
for _, s := range []string{
constants.CxStageBooked, constants.CxStageAssigned, constants.CxStageOnTheWay,
constants.CxStageArrived, constants.CxStagePickedUp, constants.CxStageOrderCreated,
constants.CxStageInTransit, constants.CxStageOutForDelivery, constants.CxStageDelivered,
} {
if constants.CxStageRank(s) > constants.CxStageRank(stage) {
break
}
perOrder := constants.CxStageRank(s) >= constants.CxStageOrder[constants.CxStageInTransit]
if perOrder && len(destinations) > 0 {
for i := range destinations {
id := destinations[i].Bookingdestinationid
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
DestinationID: &id,
Stage: s,
ActorType: constants.CxActorOps,
ActorID: &customerID,
Source: "POST /customer/ops/bookings/{reference}/stage",
Remarks: reason,
}); err != nil {
tx.Rollback()
utils.Error("ForceCxStage: record failed", "booking_id", booking.Bookingid, "stage", s, "error", err)
return utils.CxInternal(c)
}
}
continue
}
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
Stage: s,
ActorType: constants.CxActorOps,
ActorID: &customerID,
Source: "POST /customer/ops/bookings/{reference}/stage",
Remarks: reason,
}); err != nil {
tx.Rollback()
utils.Error("ForceCxStage: record failed", "booking_id", booking.Bookingid, "stage", s, "error", err)
return utils.CxInternal(c)
}
}
// Tracking numbers exist from order_created onward, so a forced booking
// past that point needs them or the orders render with a null trackingId
// and the deep-link path cannot be tested at all.
if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStageOrderCreated] {
for i := range destinations {
if destinations[i].Trackingno != "" {
continue
}
if err := tx.Model(&models.BookingDestination{}).
Where("bookingdestinationid = ?", destinations[i].Bookingdestinationid).
Update("trackingno", generateTrackingNo()).Error; err != nil {
tx.Rollback()
utils.Error("ForceCxStage: could not mint tracking number", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
}
}
if err := tx.Commit().Error; err != nil {
utils.Error("ForceCxStage: commit failed", "booking_id", booking.Bookingid, "error", err)
return utils.CxInternal(c)
}
return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusOK)
}

View File

@@ -0,0 +1,251 @@
package controllers
import (
"math"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
"gorm.io/gorm"
)
// Pickup fan-out: one visit becomes N orders.
//
// This is the change §1 of the customer contract turns on. A pickup booking
// used to convert into exactly one consignment, because a booking carried
// exactly one delivery address in its own columns. A customer-app booking
// carries 1..N destinations, and each of those has to become its own
// consignment with its own tracking number and its own journey — three parcels
// collected in one visit for Chennai, Kochi and Bengaluru travel three separate
// routes the moment they leave the door.
//
// A booking with no destination rows — every console-created express booking,
// and every row written before this table existed — produces exactly one leg
// built from the flat delivery columns, which is byte-for-byte the behaviour
// that was there before. Single-destination is not a special case in either
// direction: it is one leg, through the same code.
// cxPickupLeg is one journey to create at pickup completion.
type cxPickupLeg struct {
// Destination is nil for a booking that has no destination rows.
Destination *models.BookingDestination
DeliveryLatitude float64
DeliveryLongitude float64
DeliveryPincode string
CodAmount float64
// Parcels are the packages going to this destination, already weighed by
// the miler.
Parcels []models.BookingParcel
}
// cxPickupLegs splits a booking into the journeys its parcels are about to
// take.
func cxPickupLegs(tx *gorm.DB, booking *models.PickupBooking) ([]cxPickupLeg, error) {
var destinations []models.BookingDestination
if err := tx.Where("bookingid = ?", booking.Bookingid).
Order("seq ASC").Find(&destinations).Error; err != nil {
return nil, err
}
var parcels []models.BookingParcel
if err := tx.Where("bookingid = ?", booking.Bookingid).Find(&parcels).Error; err != nil {
return nil, err
}
if len(destinations) == 0 {
// The pre-existing shape: one consignment from the booking's own
// delivery columns, carrying every parcel on the booking.
return []cxPickupLeg{{
DeliveryLatitude: booking.Deliverylatitude,
DeliveryLongitude: booking.Deliverylongitude,
DeliveryPincode: booking.Deliverypincode,
Parcels: parcels,
}}, nil
}
byDestination := map[int][]models.BookingParcel{}
unassigned := []models.BookingParcel{}
for _, p := range parcels {
if p.Bookingdestinationid == nil {
unassigned = append(unassigned, p)
continue
}
byDestination[*p.Bookingdestinationid] = append(byDestination[*p.Bookingdestinationid], p)
}
legs := make([]cxPickupLeg, 0, len(destinations))
for i := range destinations {
d := destinations[i]
lat, lng := cxDestinationPoint(&d)
leg := cxPickupLeg{
Destination: &destinations[i],
DeliveryLatitude: lat,
DeliveryLongitude: lng,
DeliveryPincode: d.Pincode,
CodAmount: d.Codamount,
Parcels: byDestination[d.Bookingdestinationid],
}
// Parcels the miler added at the door without naming a destination go
// with the first one. Attributing them to nothing would drop them out
// of every weight total, and the first destination is the one the rider
// was shown.
if i == 0 {
leg.Parcels = append(leg.Parcels, unassigned...)
}
legs = append(legs, leg)
}
return legs, nil
}
// cxDestinationPoint gives a destination the best coordinates it has: the
// customer's own map pin if they dropped one, otherwise the district centre.
// Only state and district are required at booking time, so the centre is
// frequently all there is — and routing skips stops sitting at 0,0.
func cxDestinationPoint(d *models.BookingDestination) (lat, lng float64) {
if d.Pinlatitude != nil && d.Pinlongitude != nil &&
(*d.Pinlatitude != 0 || *d.Pinlongitude != 0) {
return *d.Pinlatitude, *d.Pinlongitude
}
var district models.ServiceableDistrict
if err := db.DB.Select("centrelatitude, centrelongitude").
Where("districtcode = ?", d.Districtcode).First(&district).Error; err == nil {
return district.Centrelatitude, district.Centrelongitude
}
return 0, 0
}
// cxLegWeights totals a leg's packages the way the consignment records them.
// A leg whose packages were never weighed falls back to the same 0.5 kg
// placeholder the single-consignment path already used, so an unweighed pickup
// still produces a chargeable consignment rather than a zero-weight one.
func cxLegWeights(leg cxPickupLeg) (deadWeight, chargeable, maxL, maxW, maxH float64) {
for _, p := range leg.Parcels {
vol := calculateVolumetricWeight(p.Length, p.Width, p.Height)
deadWeight += p.Weight
chargeable += math.Max(p.Weight, vol)
if p.Length > maxL {
maxL = p.Length
}
if p.Width > maxW {
maxW = p.Width
}
if p.Height > maxH {
maxH = p.Height
}
}
if len(leg.Parcels) == 0 || chargeable == 0 {
deadWeight, chargeable = 0.5, 0.5
}
return
}
// cxLinkLegToOrder records that a destination has become an order: its tracking
// number, its consignment, and the delivery date the customer is promised.
func cxLinkLegToOrder(tx *gorm.DB, leg cxPickupLeg, consignmentID int, trackingNo string, expected *time.Time, at time.Time) error {
if leg.Destination == nil {
return nil
}
updates := map[string]interface{}{
"consignmentid": consignmentID,
"trackingno": trackingNo,
"stage": constants.CxStageOrderCreated,
"updatedat": at,
}
if expected != nil {
updates["expecteddeliveryat"] = *expected
}
return tx.Model(&models.BookingDestination{}).
Where("bookingdestinationid = ?", leg.Destination.Bookingdestinationid).
Updates(updates).Error
}
// cxRecordVerification stores what the miler measured at the door: the weight
// the price settles on, when, and who took the reading. It is the evidence half
// of the receipt, and it is per destination because each order is weighed
// separately.
func cxRecordVerification(tx *gorm.DB, destinationID int, weightKg float64, byUserID int, at time.Time) error {
return tx.Model(&models.BookingDestination{}).
Where("bookingdestinationid = ?", destinationID).
Updates(map[string]interface{}{
"verifiedweightkg": weightKg,
"verifiedat": at,
"verifiedbyuserid": byUserID,
"updatedat": at,
}).Error
}
// cxDestinationForConsignment finds which order a consignment belongs to.
//
// The delivery handlers used to reach the booking with
// `WHERE consignmentid = ?` on pickupbookings, which held exactly one
// consignment id. With a fan-out that column only names the FIRST order, so
// that lookup silently found nothing for destinations 2..N — no customer
// notification, no stage advance, on every multi-destination booking. The join
// goes through bookingdestinations now, which is the table that actually knows.
func cxDestinationForConsignment(consignmentID int) (*models.BookingDestination, *models.PickupBooking, bool) {
var dest models.BookingDestination
if err := db.DB.Where("consignmentid = ?", consignmentID).First(&dest).Error; err == nil {
var booking models.PickupBooking
if err := db.DB.First(&booking, dest.Bookingid).Error; err == nil {
return &dest, &booking, true
}
return &dest, nil, false
}
// No destination row: a console-created booking, or one written before the
// fan-out existed. The legacy link still answers for those.
var booking models.PickupBooking
if err := db.DB.Where("consignmentid = ?", consignmentID).First(&booking).Error; err != nil {
return nil, nil, false
}
return nil, &booking, true
}
// cxDestinationIDFor returns the destination id to attribute a per-order stage
// to, or nil when the booking has no destination rows.
func cxDestinationIDFor(dest *models.BookingDestination) *int {
if dest == nil {
return nil
}
id := dest.Bookingdestinationid
return &id
}
// cxEstimatedDelivery is the promise shown to the customer, derived from the
// serving district's own promise text rather than a single platform-wide SLA —
// "Next-day delivery" into Chennai and "2-day delivery" into a district two
// states away are different promises and must not resolve to the same date.
func cxEstimatedDelivery(leg cxPickupLeg, from time.Time) *time.Time {
days := 2
if leg.Destination != nil {
var district models.ServiceableDistrict
if err := db.DB.Select("promise").
Where("districtcode = ?", leg.Destination.Districtcode).
First(&district).Error; err == nil {
switch district.Promise {
case "Same-day delivery":
days = 0
case "Next-day delivery":
days = 1
case "2-day delivery":
days = 2
case "3-day delivery":
days = 3
}
}
}
// Delivered by end of the promised day, expressed in the wall clock this
// database stores.
target := from.AddDate(0, 0, days)
expected := time.Date(target.Year(), target.Month(), target.Day(), 20, 0, 0, 0, time.UTC)
if utils.EpochMillis(expected) < utils.EpochMillis(from) {
expected = from
}
return &expected
}

View File

@@ -0,0 +1,350 @@
package controllers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"doormile/config"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// Places — §6 of the contract.
//
// Both endpoints proxy a geocoder rather than handing the app a key. The legacy
// rider app shipped a Google Maps key inside the binary; it was extracted and
// had to be revoked, and that side now runs on OSM/OSRM with no key at all. The
// customer app is never given one: it asks Doormile, Doormile asks the
// geocoder, and the answer is cached so the same street typed a hundred times
// costs one upstream call.
const (
// cxGeocodeTimeout is short on purpose. The pickup point already has a
// device-supplied coordinate; a slow geocoder should degrade the label, not
// stall the booking form.
cxGeocodeTimeout = 4 * time.Second
// cxGeocodeCacheTTL — addresses do not move. A week is conservative.
cxGeocodeCacheTTL = 7 * 24 * time.Hour
cxSearchCacheTTL = 24 * time.Hour
cxMaxSearchResults = 6
// cxRecentPlacesShown is what the search sheet opens on: the customer's own
// recent and saved places, capped by the design.
cxRecentPlacesShown = 4
)
// cxPlace is the one shape a place is returned in, from search, from reverse
// geocode and from the saved-address list. title is a short label the UI puts
// on one line; sub is the full address under it. Neither is ever null — the
// client types both as non-nullable strings.
type cxPlace struct {
Title string `json:"title"`
Sub string `json:"sub"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}
// ReverseGeocodeCx turns the device's coordinates into the pickup label.
func ReverseGeocodeCx(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
lat, latErr := strconv.ParseFloat(c.Query("lat"), 64)
lng, lngErr := strconv.ParseFloat(c.Query("lng"), 64)
if latErr != nil || lngErr != nil || (lat == 0 && lng == 0) {
return utils.CxBadRequest(c, "We need a location to look up")
}
cacheKey := fmt.Sprintf("cx:geo:rev:%.5f:%.5f", lat, lng)
if cached, ok := cxCacheGet(cacheKey); ok {
var place cxPlace
if json.Unmarshal([]byte(cached), &place) == nil {
return utils.CxOK(c, place)
}
}
place, err := cxReverseGeocode(cfg, lat, lng)
if err != nil {
utils.Warn("ReverseGeocodeCx: upstream failed", "error", err)
// A label the customer can correct beats a blocked booking form.
// The coordinates are what the rider actually navigates to; the
// text is what the customer reads, and they can edit it.
return utils.CxOK(c, cxPlace{
Title: "Selected location",
Sub: fmt.Sprintf("%.5f, %.5f", lat, lng),
Lat: lat,
Lng: lng,
})
}
if data, merr := json.Marshal(place); merr == nil {
cxCacheSet(cacheKey, string(data), cxGeocodeCacheTTL)
}
return utils.CxOK(c, place)
}
}
// SearchCxPlaces backs the pickup-point search sheet.
//
// An empty query is not an error: the sheet opens on it, and answers with the
// customer's own saved and recently used places.
func SearchCxPlaces(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
q := strings.TrimSpace(c.Query("q"))
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
if q == "" {
customerID, _ := c.Locals("userid").(int)
places := cxRecentPlaces(customerID)
return utils.CxList(c, places, len(places), nil)
}
cacheKey := fmt.Sprintf("cx:geo:q:%s:%.2f:%.2f", strings.ToLower(q), lat, lng)
if cached, ok := cxCacheGet(cacheKey); ok {
var places []cxPlace
if json.Unmarshal([]byte(cached), &places) == nil {
return utils.CxList(c, places, len(places), nil)
}
}
places, err := cxSearchPlaces(cfg, q, lat, lng)
if err != nil {
utils.Warn("SearchCxPlaces: upstream failed", "error", err)
// An empty list is a designed state in the sheet ("no matches");
// an error is a retry button in the middle of a booking.
return utils.CxList(c, []cxPlace{}, 0, nil)
}
if data, merr := json.Marshal(places); merr == nil {
cxCacheSet(cacheKey, string(data), cxSearchCacheTTL)
}
return utils.CxList(c, places, len(places), nil)
}
}
// ── Upstream ─────────────────────────────────────────────────────────────────
type nominatimPlace struct {
DisplayName string `json:"display_name"`
Lat string `json:"lat"`
Lon string `json:"lon"`
Name string `json:"name"`
Address struct {
Road string `json:"road"`
HouseNumber string `json:"house_number"`
Neighbourhood string `json:"neighbourhood"`
Suburb string `json:"suburb"`
City string `json:"city"`
Town string `json:"town"`
Village string `json:"village"`
StateDistrict string `json:"state_district"`
State string `json:"state"`
Postcode string `json:"postcode"`
} `json:"address"`
}
func cxReverseGeocode(cfg *config.Config, lat, lng float64) (cxPlace, error) {
endpoint := fmt.Sprintf("%s/reverse?format=jsonv2&lat=%f&lon=%f&addressdetails=1&zoom=18",
strings.TrimRight(cfg.GeocoderURL, "/"), lat, lng)
var place nominatimPlace
if err := cxGeocoderGet(cfg, endpoint, &place); err != nil {
return cxPlace{}, err
}
return cxPlaceFrom(place, lat, lng), nil
}
func cxSearchPlaces(cfg *config.Config, q string, lat, lng float64) ([]cxPlace, error) {
endpoint := fmt.Sprintf("%s/search?format=jsonv2&addressdetails=1&limit=%d&countrycodes=in&q=%s",
strings.TrimRight(cfg.GeocoderURL, "/"), cxMaxSearchResults, url.QueryEscape(q))
// Bias to where the customer is. A "Brookefields" typed in Coimbatore must
// not answer with one in another state first.
if lat != 0 || lng != 0 {
const box = 0.75 // degrees, roughly 80km
endpoint += fmt.Sprintf("&viewbox=%f,%f,%f,%f&bounded=0",
lng-box, lat+box, lng+box, lat-box)
}
var raw []nominatimPlace
if err := cxGeocoderGet(cfg, endpoint, &raw); err != nil {
return nil, err
}
places := make([]cxPlace, 0, len(raw))
for _, r := range raw {
plat, _ := strconv.ParseFloat(r.Lat, 64)
plng, _ := strconv.ParseFloat(r.Lon, 64)
places = append(places, cxPlaceFrom(r, plat, plng))
}
return places, nil
}
func cxGeocoderGet(cfg *config.Config, endpoint string, out interface{}) error {
ctx, cancel := context.WithTimeout(context.Background(), cxGeocodeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
// Nominatim's usage policy requires an identifiable caller; anonymous
// traffic gets throttled or blocked outright.
agent := "doormile-backend/1.0"
if cfg.GeocoderEmail != "" {
agent += " (" + cfg.GeocoderEmail + ")"
}
req.Header.Set("User-Agent", agent)
req.Header.Set("Accept-Language", "en")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("geocoder returned %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// cxPlaceFrom builds the two-line label. title is capped at the 32 characters
// the design allots it, and falls back through the parts of the address most
// likely to be recognisable at a glance.
func cxPlaceFrom(p nominatimPlace, lat, lng float64) cxPlace {
title := strings.TrimSpace(p.Name)
if title == "" {
title = joinNonEmpty(" ", p.Address.HouseNumber, p.Address.Road)
}
if title == "" {
title = firstNonEmpty(p.Address.Neighbourhood, p.Address.Suburb,
p.Address.City, p.Address.Town, p.Address.Village)
}
if title == "" {
// Last resort: the leading segment of the display name.
if i := strings.Index(p.DisplayName, ","); i > 0 {
title = strings.TrimSpace(p.DisplayName[:i])
} else {
title = "Selected location"
}
}
if r := []rune(title); len(r) > 32 {
title = strings.TrimSpace(string(r[:32]))
}
sub := joinNonEmpty(", ",
firstNonEmpty(p.Address.Neighbourhood, p.Address.Suburb),
firstNonEmpty(p.Address.City, p.Address.Town, p.Address.Village, p.Address.StateDistrict),
p.Address.Postcode)
if sub == "" {
sub = strings.TrimSpace(p.DisplayName)
}
if sub == "" {
sub = fmt.Sprintf("%.5f, %.5f", lat, lng)
}
return cxPlace{Title: title, Sub: sub, Lat: lat, Lng: lng}
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
// cxRecentPlaces answers an empty search with the places this customer already
// uses — their saved addresses first, then the pickup points of their recent
// bookings. Booking again from the same doorstep is the common case, and making
// them re-type it is the friction this removes.
func cxRecentPlaces(customerID int) []cxPlace {
places := make([]cxPlace, 0, cxRecentPlacesShown)
seen := map[string]bool{}
add := func(p cxPlace) {
if len(places) >= cxRecentPlacesShown {
return
}
key := fmt.Sprintf("%.4f:%.4f", p.Lat, p.Lng)
if seen[key] || (p.Lat == 0 && p.Lng == 0) {
return
}
seen[key] = true
places = append(places, p)
}
if customerID != 0 {
var saved []models.AppCustomerLocation
if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active").
Order("isdefault DESC, appcustomerlocationid DESC").
Limit(cxRecentPlacesShown).Find(&saved).Error; err == nil {
for _, l := range saved {
title := l.Label
if title == "" {
title = l.Address
}
add(cxPlace{
Title: title,
Sub: joinNonEmpty(", ", l.Address, l.City, l.Pincode),
Lat: l.Latitude,
Lng: l.Longitude,
})
}
}
var recent []models.PickupBooking
if err := db.DB.Select("pickuptitle, pickupsub, pickupaddress, pickuppincode, pickuplatitude, pickuplongitude").
Where("appcustomerid = ?", customerID).
Order("bookingid DESC").Limit(10).Find(&recent).Error; err == nil {
for i := range recent {
b := recent[i]
add(cxPlace{
Title: cxPickupTitle(&b),
Sub: cxPickupSub(&b),
Lat: b.Pickuplatitude,
Lng: b.Pickuplongitude,
})
}
}
}
return places
}
// ── Cache ────────────────────────────────────────────────────────────────────
// cxCacheGet / cxCacheSet are best-effort. A geocoder answer is a convenience,
// never a correctness requirement, so a Redis outage costs latency and upstream
// quota rather than the feature.
func cxCacheGet(key string) (string, bool) {
if db.Rdb == nil {
return "", false
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
value, err := db.Rdb.Get(ctx, key).Result()
if err != nil {
return "", false
}
return value, true
}
func cxCacheSet(key, value string, ttl time.Duration) {
if db.Rdb == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
if err := db.Rdb.Set(ctx, key, value, ttl).Err(); err != nil {
utils.Warn("cxCacheSet: failed", "key", key, "error", err)
}
}

View File

@@ -13,6 +13,7 @@ import (
"doormile/db"
"doormile/dto"
"doormile/internal/assignment"
"doormile/internal/legs"
"doormile/internal/routing"
"doormile/models"
"doormile/utils"
@@ -70,14 +71,11 @@ func zoneName(pincode string) string {
}
// haversineKM returns the great-circle distance between two lat/lon points in km.
// haversineKM stays the name the whole controllers package calls, and now
// delegates to internal/legs so the route sequencer measures distance with the
// identical implementation rather than a second copy of it.
func haversineKM(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadiusKM = 6371.0
toRad := func(deg float64) float64 { return deg * math.Pi / 180 }
dLat := toRad(lat2 - lat1)
dLon := toRad(lon2 - lon1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return legs.HaversineKM(lat1, lon1, lat2, lon2)
}
// humanizeRelativeTime renders a timestamp as "5 min ago" / "2 hrs ago" / "3 days ago".
@@ -315,13 +313,25 @@ func GetHubUnassignedBookings(c *fiber.Ctx) error {
var customer models.AppCustomer
db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer)
// What kind of place this is collected from, and which one. A Base/Hub
// pickup carries the base id, so the dispatch board can show that the
// parcel starts at a base rather than at a customer's door — and the row
// the rider app receives carries the same pair.
customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname)
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName)
response = append(response, fiber.Map{
"bookingid": b.Bookingid,
"bookingno": b.Bookingno,
"customer_name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
"pickup_address": b.Pickupaddress,
"customer_name": customerName,
"pickup_source_type": sourceType,
"sourceid": sourceID,
"pickuplocationid": sourceID,
"pickup_source_name": sourceName,
"pickup_address": sourceAddress,
"pickup_pincode": b.Pickuppincode,
"delivery_address": b.Deliveryaddress,
"delivery_pincode": b.Deliverypincode,
"parcels": b.Parcels,
"created_at": b.Createdat,
})
@@ -389,13 +399,21 @@ func GetHubBookingsRange(c *fiber.Ctx) error {
}
}
customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname)
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName)
response = append(response, fiber.Map{
"bookingid": b.Bookingid,
"bookingno": b.Bookingno,
"customer_name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
"pickup_address": b.Pickupaddress,
"customer_name": customerName,
"pickup_source_type": sourceType,
"sourceid": sourceID,
"pickuplocationid": sourceID,
"pickup_source_name": sourceName,
"pickup_address": sourceAddress,
"pickup_pincode": b.Pickuppincode,
"delivery_address": b.Deliveryaddress,
"delivery_pincode": b.Deliverypincode,
"parcels": b.Parcels,
"status": hubBookingDisplayStatus(b.Status, hasMiler),
"milername": milerName,
@@ -437,6 +455,10 @@ func renderInboundConsignment(cs models.Consignment) fiber.Map {
"temperature": "N/A",
"status": cs.Status,
"updatedat": cs.Updatedat,
// The physical-receipt fact, distinct from updatedat, which moves on any
// write. Null on rows inwarded before this column existed.
"inwardedat": cs.Inwardedat,
"inbound_status": "received",
}
}
@@ -539,11 +561,18 @@ func CreateInboundScan(c *fiber.Ctx) error {
}
}
now := time.Now()
consignment.Status = constants.ConsignmentInwardedAtHub
consignment.Currenthubid = &hubID
consignment.Condition = req.Condition
consignment.Shelf = recommendedShelf
consignment.Updatedat = time.Now()
consignment.Updatedat = now
// The inbound scan is a physical receipt, so it stamps the same received-at
// fact the rider handover does. Kept first-write-wins: a second scan of the
// same parcel must not move the time it actually arrived.
if consignment.Inwardedat == nil {
consignment.Inwardedat = &now
}
if err := db.DB.Save(&consignment).Error; err != nil {
return utils.Internal(c, "failed to update consignment")

View File

@@ -0,0 +1,286 @@
package controllers
import (
"fmt"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// --------------------
// BASE INBOUND — what is on its way in, and confirming it arrived
//
// The console side of the rider handover. Wire vocabulary stays hub
// (Inwarded_at_Hub, currenthubid); the rider app renders it as Base.
// --------------------
// scopeConsignmentsToOwnTenant is the consignment counterpart of
// scopeBookingsToOwnTenant: partner-tenant hub staff see only their own tenant's
// parcels, Doormile staff see everything. Same rule, different table — without
// it a partner's staff would read every other client's parcels passing through
// the same base.
func scopeConsignmentsToOwnTenant(c *fiber.Ctx, query *gorm.DB) *gorm.DB {
staff, err := getCurrentHubStaff(c)
if err != nil || isDoormileStaff(staff) {
return query
}
return query.Where("tenantid = ?", *staff.Tenantid)
}
// canHubStaffAccessConsignment proves ownership of a consignment addressed by id
// before it is written to, rather than trusting the path parameter.
func canHubStaffAccessConsignment(c *fiber.Ctx, cn *models.Consignment) bool {
staff, err := getCurrentHubStaff(c)
if err != nil {
return false
}
if isDoormileStaff(staff) {
return true
}
return staff.Tenantid != nil && cn.Tenantid == *staff.Tenantid
}
// GetHubInboundExpected lists parcels a rider is currently carrying towards this
// base — collected, routed here, not yet handed over. Between a rider collecting
// an intercity parcel and inwarding it, nobody at the destination base could see
// it was coming; this is that view.
//
// It reads consignments on Created — collected, in a rider's hands, with a base
// as the next leg — whose current base is this one. Under the compatibility flow
// a hub-routed parcel is marked Inwarded_at_Hub at pickup and so never appears
// here; that is expected, and GetHubInboundToday covers those.
func GetHubInboundExpected(c *fiber.Ctx) error {
hubID := c.Locals("hubid").(int)
query := db.DB.Where("currenthubid = ? AND status = ? AND deletedat IS NULL",
hubID, constants.ConsignmentCreated)
query = scopeConsignmentsToOwnTenant(c, query)
var consignments []models.Consignment
if err := query.Order("createdat DESC").Find(&consignments).Error; err != nil {
return utils.Internal(c, "failed to fetch expected inbound consignments")
}
// Batched lookups — this stays a handful of queries however many parcels are
// in flight towards the base.
ids := make([]int, 0, len(consignments))
for _, cs := range consignments {
ids = append(ids, cs.Consignmentid)
}
bookingByConsignment := map[int]models.PickupBooking{}
riderIDs := []int{}
customerIDs := []int{}
if len(ids) > 0 {
var bookings []models.PickupBooking
db.DB.Where("consignmentid IN ?", ids).Find(&bookings)
for _, b := range bookings {
if b.Consignmentid != nil {
bookingByConsignment[*b.Consignmentid] = b
}
if b.Assignedmileruserid != nil {
riderIDs = append(riderIDs, *b.Assignedmileruserid)
}
customerIDs = append(customerIDs, b.Appcustomerid)
}
}
riderByID := map[int]models.AppUser{}
if len(riderIDs) > 0 {
var riders []models.AppUser
db.DB.Where("userid IN ?", riderIDs).Find(&riders)
for _, r := range riders {
riderByID[r.Userid] = r
}
}
customerByID := map[int]models.AppCustomer{}
if len(customerIDs) > 0 {
var customers []models.AppCustomer
db.DB.Where("appcustomerid IN ?", customerIDs).Find(&customers)
for _, cu := range customers {
customerByID[cu.Appcustomerid] = cu
}
}
response := make([]fiber.Map, 0, len(consignments))
for i := range consignments {
cs := consignments[i]
row := fiber.Map{
"consignmentid": cs.Consignmentid,
"trackingno": cs.Trackingno,
// destination_base is the base this parcel moves on to after here, and
// is only known once something routes it onward — null more often than
// not. The delivery pincode is the reliable statement of where it ends
// up, so both are given rather than one standing in for the other.
"destination_base": renderBase(loadHub(cs.Destinationhubid)),
"final_destination": cs.Deliverypincode,
"delivery_pincode": cs.Deliverypincode,
"chargeableweight": cs.Chargeableweight,
"current_state": cs.Status,
"inbound_status": "expected",
"next_action": nextActionForConsignment(cs.Status),
"collected_at": cs.Createdat,
"pickup_pincode": cs.Pickuppincode,
}
if b, ok := bookingByConsignment[cs.Consignmentid]; ok {
customerName := ""
if cu, ok := customerByID[b.Appcustomerid]; ok {
customerName = strings.TrimSpace(cu.Firstname + " " + cu.Lastname)
}
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName)
row["bookingid"] = b.Bookingid
row["bookingno"] = b.Bookingno
row["customer_name"] = customerName
row["pickup_source_type"] = sourceType
row["pickup_source_id"] = sourceID
row["source"] = sourceName
row["pickup_address"] = sourceAddress
row["destination_address"] = b.Deliveryaddress
if b.Assignedmileruserid != nil {
row["rider_userid"] = *b.Assignedmileruserid
if r, ok := riderByID[*b.Assignedmileruserid]; ok {
row["rider"] = r.Authname
row["rider_phone"] = r.Contactno
}
}
}
response = append(response, row)
}
return utils.List(c, response, int64(len(response)))
}
// ReconcileHubInbound is the base's side of the rider handover: staff either
// confirm the parcel is physically here, or record that it never arrived despite
// a rider marking it handed over.
//
// received=true is the ordinary case and is idempotent — confirming a parcel
// that is already inwarded re-affirms it rather than failing, because staff
// working through a pile will hit some rows twice.
//
// received=false is the reconciliation path. It deliberately does not quietly
// move the parcel backwards: it raises an exception naming the discrepancy, so a
// parcel a rider swears was handed over and staff never saw becomes a tracked
// open item rather than a disagreement nobody owns.
func ReconcileHubInbound(c *fiber.Ctx) error {
hubID := c.Locals("hubid").(int)
// HubStaffAuth sets userid to the hubstaffaccountid, which is NOT an
// appusers.userid. consignmentexceptions.reportedbyuserid and
// consignmenthistory.userid both carry a real FK to appusers(userid), so
// writing a hub-staff id into either violates it — the row is rejected and the
// whole action 500s. The staff identity goes into the free-text fields
// instead, and the FK-bearing columns are left null. createdby on
// consignmentexceptions carries no FK, so it can hold the staff id.
staffAccountID, _ := c.Locals("userid").(int)
consignmentID, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid consignment ID")
}
var req struct {
// Pointer so an omitted field is never read as "not received".
Received *bool `json:"received"`
Remarks string `json:"remarks"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Received == nil {
return utils.BadRequest(c, "received is required (true = the parcel is physically here, false = it never arrived)")
}
var consignment models.Consignment
if err := db.DB.Where("consignmentid = ? AND deletedat IS NULL", consignmentID).
First(&consignment).Error; err != nil {
return utils.NotFound(c, "consignment not found")
}
if !canHubStaffAccessConsignment(c, &consignment) {
return utils.NotFound(c, "consignment not found")
}
now := time.Now()
if !*req.Received {
description := strings.TrimSpace(req.Remarks)
if description == "" {
description = "Rider recorded a handover at this base but the parcel was not physically received."
}
exception := models.ConsignmentException{
Consignmentid: consignment.Consignmentid,
Hubid: &hubID,
// Lost is the closest type the consignmentexceptions CHECK constraint
// already allows, and it is honest: a parcel recorded as handed over
// that nobody can find is lost until it turns up. A dedicated
// Handover_Not_Received type would need that constraint widened first.
Exceptiontype: constants.ExceptionLost,
Severity: "High",
Description: fmt.Sprintf("%s (reported by hub staff account %d)", description, staffAccountID),
Status: constants.ExceptionOpen,
Createdby: staffAccountID,
}
if err := db.DB.Create(&exception).Error; err != nil {
return utils.Internal(c, "failed to raise handover exception")
}
db.DB.Create(&models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Hubid: &hubID,
Eventstatus: consignment.Status,
Remarks: fmt.Sprintf("Handover disputed at base by hub staff account %d: %s",
staffAccountID, description),
})
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"trackingno": consignment.Trackingno,
"consignmentstatus": consignment.Status,
"received": false,
"exceptionid": exception.Exceptionid,
"exceptiontype": exception.Exceptiontype,
})
}
alreadyInwarded := consignment.Status == constants.ConsignmentInwardedAtHub
if !alreadyInwarded {
consignment.Status = constants.ConsignmentInwardedAtHub
consignment.Currenthubid = &hubID
if consignment.Originhubid == nil {
consignment.Originhubid = &hubID
}
consignment.Updatedat = now
consignment.Updatedby = staffAccountID
}
if consignment.Inwardedat == nil {
consignment.Inwardedat = &now
}
if err := db.DB.Save(&consignment).Error; err != nil {
return utils.Internal(c, "failed to record receipt")
}
if !alreadyInwarded {
remarks := strings.TrimSpace(req.Remarks)
if remarks == "" {
remarks = "Physical receipt confirmed at base"
}
db.DB.Create(&models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Hubid: &hubID,
Eventstatus: constants.ConsignmentInwardedAtHub,
Remarks: fmt.Sprintf("%s (hub staff account %d)", remarks, staffAccountID),
})
}
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"trackingno": consignment.Trackingno,
"consignmentstatus": consignment.Status,
"inwardedat": consignment.Inwardedat,
"received": true,
"already_received": alreadyInwarded,
})
}

View File

@@ -0,0 +1,534 @@
package controllers
import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// --------------------
// BASE / HUB HANDOVER — the logistics next-leg surface
//
// Vocabulary note, because two words are in play for one thing: the wire says
// hub (inward_at_hub, Inwarded_at_Hub, next_hub, pickup_source_type "hub") and
// the rider app renders that as Base. Nothing here changes a wire value to suit
// the app's wording, and nothing in the app's wording should leak back in here.
// --------------------
// hubHandoverEnabled gates the two-step hub flow: a hub-routed parcel stops at
// Created — collected, in the rider's hands, on its way to a base — and only
// reaches Inwarded_at_Hub when the handover is actually recorded, by the rider
// (POST /miler/consignments/:id/inward-at-hub) or by base staff (the console
// inbound scan).
//
// Default OFF, and it must stay off until a rider-app build that calls the
// handover endpoint is live. With it off, pickup-complete keeps marking a
// hub-routed parcel Inwarded_at_Hub the instant it is collected — which is not
// true of where the parcel physically is, but is what the current app and the
// console's inbound views expect. Flipping it early would leave every intercity
// parcel sitting on Created with no button in the rider's app to advance it and
// no row in the base's inbound list.
//
// Read at request time (env MILER_HUB_HANDOVER_ENABLED=true) so it can be turned
// on without a redeploy, same as MILER_COLLECTED_STATE_ENABLED. Everything else
// in this file — next_hub, the handover endpoint itself, next_action on the
// queue read, base master data, inbound visibility — is ungated and safe for the
// current app.
func hubHandoverEnabled() bool {
return strings.EqualFold(os.Getenv("MILER_HUB_HANDOVER_ENABLED"), "true")
}
// renderBase is the one shape a base is ever returned in, so pickup-complete,
// the booking rows, the handover response and GET /miler/bases cannot drift
// apart. All six fields every time: the id keys the handover, the name is the
// heading the rider reads, address and pincode are what they read at the gate,
// and the coordinates are the only thing that can drive Navigate. Five of six
// still leaves a rider unable to get there.
func renderBase(hub *models.Hub) fiber.Map {
if hub == nil {
return nil
}
return fiber.Map{
"id": hub.Hubid,
"name": hub.Hubname,
"address": hub.Address,
"pincode": hub.Pincode,
"latitude": hub.Latitude,
"longitude": hub.Longitude,
}
}
// loadHub reads one base by id, ignoring soft-deleted rows. Returns nil rather
// than an error for a missing id so callers can treat "no base" and "unknown
// base" the same way where that is the right call.
func loadHub(hubID *int) *models.Hub {
if hubID == nil || *hubID == 0 {
return nil
}
var hub models.Hub
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", *hubID).First(&hub).Error; err != nil {
return nil
}
return &hub
}
// nearestActiveHub finds the closest active base to a point. Used only as a last
// resort when neither the booking nor the rider names one — a parcel with
// nowhere to go is worse than a parcel sent to the nearest gate. Returns nil
// when no active base has usable coordinates.
func nearestActiveHub(lat, lon float64) *models.Hub {
if lat == 0 && lon == 0 {
return nil
}
var hubs []models.Hub
if err := db.DB.Where("deletedat IS NULL AND status = ?", "Active").Find(&hubs).Error; err != nil {
return nil
}
var best *models.Hub
bestKM := 0.0
for i := range hubs {
h := &hubs[i]
if h.Latitude == 0 && h.Longitude == 0 {
continue
}
d := haversineKM(lat, lon, h.Latitude, h.Longitude)
if best == nil || d < bestKM {
best, bestKM = h, d
}
}
return best
}
// resolveHandoverHub decides which base a hub-routed parcel is carried to. The
// decision is the backend's, never the app's — the app is told where to go and
// navigates there.
//
// Order, most authoritative first:
// 1. the base the booking was routed to (nearesthubid), when the console or the
// dispatch layer set one. Nothing populates this column today; it is checked
// first so that the moment something does, it wins without another change here.
// 2. the collecting rider's own base — the operational default: a rider brings
// the parcel back to where they work out of.
// 3. the active base nearest the pickup point, for a rider with no base set.
// 4. any base at all, so a parcel is never left with nowhere to go.
func resolveHandoverHub(booking *models.PickupBooking, riderHubID *int) *models.Hub {
if hub := loadHub(booking.Nearesthubid); hub != nil {
return warnIfUnnavigable(hub)
}
if hub := loadHub(riderHubID); hub != nil {
return warnIfUnnavigable(hub)
}
if hub := nearestActiveHub(booking.Pickuplatitude, booking.Pickuplongitude); hub != nil {
return hub
}
var hub models.Hub
if db.DB.Where("deletedat IS NULL").Order("hubid").First(&hub).Error == nil {
return warnIfUnnavigable(&hub)
}
return nil
}
// warnIfUnnavigable flags a base the rider cannot actually be routed to. The
// correct base is still returned — sending a rider to a different base because
// this one has bad master data would be worse than sending them to the right one
// with a missing pin. It is a data problem, and it needs to be visible as one.
func warnIfUnnavigable(hub *models.Hub) *models.Hub {
if hub.Latitude == 0 && hub.Longitude == 0 {
utils.Warn("base has no coordinates — Navigate will not work for riders sent here",
"hubid", hub.Hubid, "hubname", hub.Hubname)
}
if strings.TrimSpace(hub.Address) == "" {
utils.Warn("base has no address — the rider has nothing to read at the gate",
"hubid", hub.Hubid, "hubname", hub.Hubname)
}
return hub
}
// derivePickupSourceType classifies where a booking is collected from for rows
// written before pickupsourcetype existed, and as a safety net for any writer
// that forgets to set it. A stored value always wins — this only fills a blank.
//
// A base-origin booking names a base; a client-site pickup names a tenant
// location (a kitchen, branch or depot — "merchant"); everything else is a
// person's door. "customer" is the honest answer for the last case and is
// returned as a value, never as an omission.
func derivePickupSourceType(b *models.PickupBooking) string {
if b.Pickupsourcetype != "" {
return b.Pickupsourcetype
}
if b.Pickuphubid != nil {
return constants.PickupSourceHub
}
if b.Tenantlocationid != nil {
return constants.PickupSourceMerchant
}
return constants.PickupSourceCustomer
}
// pickupSource resolves the source-type, id, name and address the rider app puts
// at the top of a pickup stop. customerName is the booking's customer, used for
// the door-pickup case so a collection at a house is titled with the sender's
// name rather than the rider's own base name.
//
// sourceID is nil for a customer pickup — there is no configured location and
// inventing one would be a lie. That is precisely why pickup_source_type is
// carried on the row: the app can then tell "no id because it is a front door"
// from "no id because nobody filled it in".
func pickupSource(b *models.PickupBooking, customerName string) (sourceType string, sourceID *int, name, address string) {
sourceType = derivePickupSourceType(b)
address = b.Pickupaddress
switch sourceType {
case constants.PickupSourceHub:
sourceID = b.Pickuphubid
if hub := loadHub(b.Pickuphubid); hub != nil {
name = hub.Hubname
if address == "" {
address = hub.Address
}
}
case constants.PickupSourceMerchant, constants.PickupSourceStore:
sourceID = b.Tenantlocationid
if b.Tenantlocationid != nil {
var loc models.TenantLocation
if db.DB.Where("tenantlocationid = ?", *b.Tenantlocationid).First(&loc).Error == nil {
name = loc.Locationname
if address == "" {
address = loc.Address
}
}
}
default:
// Customer door: the sender's own name and the address on the booking.
name = strings.TrimSpace(customerName)
}
if name == "" {
name = strings.TrimSpace(b.Providerlocation)
}
return sourceType, sourceID, name, address
}
// nextActionForConsignment maps a consignment's state to what the rider does
// next with it. This is the single definition — pickup-complete and the queue
// read both call it, so a poll can never disagree with the answer the pivot
// gave. Anything terminal returns "none" so a finished parcel retires from the
// rider's screen instead of lingering.
func nextActionForConsignment(status string) string {
switch status {
case constants.ConsignmentCreated:
// Collected and still in the rider's hands, routed to a base: carry it
// there and hand it over. Under the compatibility flow a hub-routed
// parcel never sits here — it is already Inwarded_at_Hub.
return constants.NextActionInwardAtHub
case constants.ConsignmentCollectedByMiler:
return constants.NextActionStartDelivery
case constants.ConsignmentOutForDelivery:
return constants.NextActionDeliver
case constants.ConsignmentInwardedAtHub:
return constants.NextActionHandedToHub
default:
// Tripsheet_Loaded, In_Transit, Delivered, RTO, Returned, Missing,
// Damaged — all past this rider's leg.
return constants.NextActionNone
}
}
// nextHubForConsignment names the base a parcel is on its way to, for a
// consignment still in a rider's hands. A parcel that has already been inwarded
// has no next base — it is at one.
func nextHubForConsignment(cn *models.Consignment) fiber.Map {
if cn == nil || cn.Status != constants.ConsignmentCreated {
return nil
}
return renderBase(loadHub(cn.Currenthubid))
}
// --------------------
// GET /miler/bases — base master data on a rider token
//
// The rider app could previously only see GET /admin/tenants/:id/locations,
// which is a different dataset entirely (a client's own sites) and is closed to
// a miler token anyway: /admin/* requires roles 1/3/4 and a rider is role 5, so
// that route answers 401 for them by design, not by oversight.
// --------------------
func MilerGetBases(c *fiber.Ctx) error {
query := db.DB.Where("deletedat IS NULL")
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
} else {
query = query.Where("status = ?", "Active")
}
if appLocationID := c.Query("applocationid"); appLocationID != "" {
query = query.Where("applocationid = ?", appLocationID)
}
var hubs []models.Hub
if err := query.Find(&hubs).Error; err != nil {
return utils.Internal(c, "failed to fetch bases")
}
// Ordered nearest-first from wherever the rider last reported being, so the
// base they are most likely to want is at the top. Falls back to id order
// when the rider has no position yet.
milerUserID := c.Locals("userid").(int)
var profile models.MilerProfile
hasPos := db.DB.Where("userid = ?", milerUserID).First(&profile).Error == nil &&
(profile.Currentlatitude != 0 || profile.Currentlongitude != 0)
rows := make([]fiber.Map, 0, len(hubs))
for i := range hubs {
row := renderBase(&hubs[i])
if hasPos && (hubs[i].Latitude != 0 || hubs[i].Longitude != 0) {
row["distance_km"] = haversineKM(profile.Currentlatitude, profile.Currentlongitude,
hubs[i].Latitude, hubs[i].Longitude)
}
rows = append(rows, row)
}
if hasPos {
sort.SliceStable(rows, func(i, j int) bool {
di, oki := rows[i]["distance_km"].(float64)
dj, okj := rows[j]["distance_km"].(float64)
switch {
case oki && okj:
return di < dj
case oki:
return true
default:
return false
}
})
}
return utils.List(c, rows, int64(len(rows)))
}
// --------------------
// POST /miler/consignments/:id/inward-at-hub — the rider handover
//
// The authoritative record that a rider physically handed a parcel in at a base.
// Idempotent (retries at a loading bay with bad signal are normal, and the route
// also carries the shared Idempotency-Key middleware), and it answers with the
// resulting state rather than a bare 200 — every lifecycle transition the app
// makes is checked against the state that comes back.
// --------------------
func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
consignmentID, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "invalid consignment ID")
}
var req struct {
HubID *int `json:"hub_id"`
// hubid accepted as an alias: the same value has been spelled both ways
// across this API's history and a handover is not worth failing over a
// missing underscore.
HubIDAlt *int `json:"hubid"`
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
Lat *float64 `json:"lat"`
Lon *float64 `json:"lon"`
}
// A body is optional — a rider handing a parcel into the base it is already
// routed to needs to send nothing at all.
_ = c.BodyParser(&req)
consignment, code, err := milerConsignmentForRider(milerUserID, consignmentID)
if err != nil {
if code == constants.ErrConsignmentNotFound {
return utils.Fail(c, fiber.StatusNotFound, code, "consignment not found")
}
return utils.Fail(c, fiber.StatusForbidden, code, "this consignment is not assigned to you")
}
hubID := req.HubID
if hubID == nil {
hubID = req.HubIDAlt
}
if hubID == nil {
// Nothing named: hand it into the base it was routed to.
hubID = consignment.Currenthubid
}
if hubID == nil {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrHubRequired,
"hub_id is required — this consignment is not routed to a base")
}
hub := loadHub(hubID)
if hub == nil {
return utils.Fail(c, fiber.StatusNotFound, constants.ErrHubNotFound, "hub_id does not match a known base")
}
// Already inwarded: answer with the state that stands rather than failing, so
// a retry after a dropped response confirms rather than errors. This is also
// what a rider on the compatibility flow hits every time — there,
// pickup-complete already marked the parcel Inwarded_at_Hub.
if consignment.Status == constants.ConsignmentInwardedAtHub {
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"trackingno": consignment.Trackingno,
"consignmentstatus": consignment.Status,
"inwardedat": consignment.Inwardedat,
"hub": renderBase(loadHub(consignment.Currenthubid)),
"next_action": nextActionForConsignment(consignment.Status),
"already_inwarded": true,
})
}
// Only a parcel actually in this rider's hands can be handed over. A parcel
// already out for delivery has to be delivered or skipped; a delivered or
// returned one is past this leg entirely.
if consignment.Status != constants.ConsignmentCreated &&
consignment.Status != constants.ConsignmentCollectedByMiler {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
fmt.Sprintf("consignment is %s — it cannot be handed over at a base from this state", consignment.Status))
}
lat, lon := 0.0, 0.0
if req.Latitude != nil {
lat = *req.Latitude
} else if req.Lat != nil {
lat = *req.Lat
}
if req.Longitude != nil {
lon = *req.Longitude
} else if req.Lon != nil {
lon = *req.Lon
}
now := time.Now()
tx := db.DB.Begin()
consignment.Status = constants.ConsignmentInwardedAtHub
consignment.Currenthubid = &hub.Hubid
if consignment.Originhubid == nil {
consignment.Originhubid = &hub.Hubid
}
consignment.Inwardedat = &now
consignment.Updatedat = now
consignment.Updatedby = milerUserID
if err := tx.Save(consignment).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record the handover")
}
history := models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Hubid: &hub.Hubid,
Userid: &milerUserID,
Eventstatus: constants.ConsignmentInwardedAtHub,
Remarks: fmt.Sprintf("Rider handed parcel in at %s (%.5f, %.5f)",
hub.Hubname, lat, lon),
}
if err := tx.Create(&history).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record handover history")
}
// The rider's leg ends here, so the assignment closes and they return to the
// pool. riderkms is the distance actually ridden on this leg — pickup point to
// the base gate — and ridercharges the order amount, both written the same way
// MilerDeliverConsignment writes them for a final-mile leg. Without this an
// intercity rider's every job reported zero distance and zero value.
// Resolved through bookingdestinations, not through
// pickupbookings.consignmentid. That column names only the FIRST order of a
// multi-destination pickup, so joining on it found nothing for orders 2..N
// — and an intercity rider handing in the second parcel of a three-stop
// pickup had their assignment left open and their distance recorded as zero.
if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil {
booking := *bookingPtr
dropLat, dropLon := lat, lon
if dropLat == 0 && dropLon == 0 {
dropLat, dropLon = hub.Latitude, hub.Longitude
}
riderKms := haversineKM(consignment.Pickuplatitude, consignment.Pickuplongitude, dropLat, dropLon)
orderAmount := 0.0
var serviceOpt models.BookingServiceOption
if tx.Where("bookingid = ?", booking.Bookingid).Order("createdat DESC").
First(&serviceOpt).Error == nil {
orderAmount = serviceOpt.Estimatedprice
}
if err := tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
booking.Bookingid, milerUserID,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentCompleted,
"completedat": now,
"riderkms": riderKms,
"ridercharges": orderAmount,
}).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to close assignment")
}
}
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update miler availability")
}
// The customer's "In transit" milestone. Recorded against THIS order, not
// the booking, because the other parcels from the same visit may still be
// in the rider's hands.
notifyInTransit, err := recordCxConsignmentStage(tx, consignment.Consignmentid,
constants.ConsignmentInwardedAtHub, constants.CxActorMiler, &milerUserID,
"POST /miler/consignments/{id}/inward-at-hub")
if err != nil {
tx.Rollback()
utils.Error("MilerInwardConsignmentAtHub: could not record in_transit",
"consignment_id", consignment.Consignmentid, "error", err)
return utils.Internal(c, "failed to record the handover")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to record the handover")
}
notifyInTransit()
// Best-effort, on an already-bound subject — a dropped event must never fail
// a handover the rider has physically completed.
if db.Js != nil {
payload := map[string]interface{}{
"consignmentid": consignment.Consignmentid,
"trackingno": consignment.Trackingno,
"status": constants.ConsignmentInwardedAtHub,
"hubid": hub.Hubid,
"mileruserid": milerUserID,
"inwardedat": now.UnixMilli(),
}
if data, err := json.Marshal(payload); err == nil {
if _, err := db.Js.Publish("booking.status.updated", data); err != nil {
utils.Warn("MilerInwardConsignmentAtHub: NATS publish failed",
"consignment_id", consignment.Consignmentid, "error", err)
}
}
}
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"trackingno": consignment.Trackingno,
"consignmentstatus": consignment.Status,
"inwardedat": consignment.Inwardedat,
"hub": renderBase(hub),
"next_action": nextActionForConsignment(consignment.Status),
"already_inwarded": false,
})
}

View File

@@ -0,0 +1,149 @@
package controllers
import (
"os"
"testing"
"doormile/constants"
"doormile/models"
)
func intPtr(v int) *int { return &v }
func TestNextActionForConsignment(t *testing.T) {
cases := []struct {
name string
status string
want string
}{
{
// The whole point of request 27: a hub-routed parcel sits on Created
// while it is being carried to a base, and the app must be able to
// rebuild that leg from server state after a restart.
name: "collected and routed to a base means carry it there",
status: constants.ConsignmentCreated,
want: constants.NextActionInwardAtHub,
},
{
name: "collected hyperlocal parcel waits for start-delivery",
status: constants.ConsignmentCollectedByMiler,
want: constants.NextActionStartDelivery,
},
{
name: "out for delivery means deliver",
status: constants.ConsignmentOutForDelivery,
want: constants.NextActionDeliver,
},
{
name: "already handed in at a base leaves the rider nothing to do",
status: constants.ConsignmentInwardedAtHub,
want: constants.NextActionHandedToHub,
},
{
name: "delivered is past this rider's leg",
status: constants.ConsignmentDelivered,
want: constants.NextActionNone,
},
{
name: "in transit between bases is not a rider action",
status: constants.ConsignmentInTransit,
want: constants.NextActionNone,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := nextActionForConsignment(tc.status); got != tc.want {
t.Errorf("nextActionForConsignment(%q) = %q, want %q", tc.status, got, tc.want)
}
})
}
}
func TestDerivePickupSourceType(t *testing.T) {
cases := []struct {
name string
booking models.PickupBooking
want string
}{
{
name: "a stored type always wins",
booking: models.PickupBooking{Pickupsourcetype: constants.PickupSourceStore, Tenantlocationid: intPtr(7)},
want: constants.PickupSourceStore,
},
{
name: "a booking naming a base is a base pickup",
booking: models.PickupBooking{Pickuphubid: intPtr(1)},
want: constants.PickupSourceHub,
},
{
name: "a booking naming a client site is a merchant pickup",
booking: models.PickupBooking{Tenantlocationid: intPtr(7)},
want: constants.PickupSourceMerchant,
},
{
// The case the whole column exists for: a front-door pickup has no
// location id, and "customer" must be a value rather than a blank.
name: "a booking naming no location at all is a customer door",
booking: models.PickupBooking{Pickupaddress: "12 Race Course Road"},
want: constants.PickupSourceCustomer,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := derivePickupSourceType(&tc.booking); got != tc.want {
t.Errorf("derivePickupSourceType() = %q, want %q", got, tc.want)
}
})
}
}
func TestRenderBaseCarriesAllSixFields(t *testing.T) {
// Five of six leaves a rider unable to get there: the id keys the handover,
// the name is the heading, address and pincode are read at the gate, and the
// coordinates are the only thing that can drive Navigate.
hub := models.Hub{
Hubid: 1,
Hubname: "Coimbatore Hub",
Address: "14 Avinashi Road, Peelamedu, Coimbatore",
Pincode: "641004",
Latitude: 11.0272,
Longitude: 76.9905,
}
got := renderBase(&hub)
for _, field := range []string{"id", "name", "address", "pincode", "latitude", "longitude"} {
if _, ok := got[field]; !ok {
t.Errorf("renderBase() is missing %q", field)
}
}
if len(got) != 6 {
t.Errorf("renderBase() returned %d fields, want exactly 6: %v", len(got), got)
}
if renderBase(nil) != nil {
t.Error("renderBase(nil) should be nil, so an unresolved base is absent rather than empty")
}
}
func TestHubHandoverEnabled(t *testing.T) {
// Default OFF matters operationally: turning it on before a rider-app build
// that can hand a parcel over would strand every intercity parcel on Created
// with no button to advance it.
t.Setenv("MILER_HUB_HANDOVER_ENABLED", "")
os.Unsetenv("MILER_HUB_HANDOVER_ENABLED")
if hubHandoverEnabled() {
t.Error("hub handover must default to off when the env var is unset")
}
t.Setenv("MILER_HUB_HANDOVER_ENABLED", "TRUE")
if !hubHandoverEnabled() {
t.Error("hub handover should be on for TRUE, matching the case-insensitive read used elsewhere")
}
t.Setenv("MILER_HUB_HANDOVER_ENABLED", "1")
if hubHandoverEnabled() {
t.Error(`only "true" turns the flow on — "1" must not`)
}
}

View File

@@ -0,0 +1,137 @@
package controllers
import (
"testing"
"doormile/constants"
)
// The bulk-upload test sheet, checked against the rule that actually decides.
//
// krow_talent_app/tests/fixtures/doormile-logistics-test.xlsx carries 16 rows
// and an "Expected Routing" column saying, in words, what each one should do.
// That column is only a claim until something checks it, and the thing that
// decides is here, in Go — so it is checked here rather than restated in a
// JavaScript test, which would only prove that two copies of the rule agree
// with each other.
//
// If a row of the sheet is edited, this table is what says whether the sheet is
// still testing what it says it tests.
//
// Pickup is the sheet's single sender: Jayanthi's kitchen, Edayarpalayam,
// Coimbatore 641025.
const (
sheetPickupPincode = "641025"
sheetPickupLat = 11.0168
sheetPickupLng = 76.9558
)
func TestBulkTestSheetRoutesAsDocumented(t *testing.T) {
cases := []struct {
row int
receiver string
pincode string
lat, lng float64
wantLocal bool
wantAction string
wantConsStat string // with the hub-handover flow ON
why string
}{
// Customer -> Base. A different postal area, so the parcel cannot be
// carried to the receiver by the collecting rider.
{1, "Suresh Kumar", "600001", 13.091, 80.285, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "641 -> 600, Chennai"},
{2, "Priya Raghavan", "600028", 13.018, 80.256, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "641 -> 600, Chennai"},
{3, "Anil Reddy", "500081", 17.44, 78.3489, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate, Hyderabad"},
{4, "Meera Krishnan", "500032", 17.4156, 78.3378, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate with COD"},
{5, "Rahul Menon", "560001", 12.975, 77.606, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate, Bengaluru"},
{6, "Divya Nair", "560066", 12.9698, 77.75, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate with COD"},
{7, "Karthik Subramani", "625001", 9.9195, 78.119, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "same state, different area"},
{8, "Lakshmi Devi", "636001", 11.664, 78.146, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "same state, different area"},
// Customer -> Customer. The control group: if any of these routes to a
// base, the prefix rule has broken.
{9, "Ganesh Iyer", "641004", 11.029, 76.993, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area"},
{10, "Revathi Balaji", "641012", 11.018, 76.966, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area, COD"},
{11, "Vignesh Murugan", "641025", 11.008, 76.928, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "identical pincode"},
{12, "Anitha Selvam", "641038", 11.023, 76.945, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area"},
// No pincode: the decision falls to straight-line distance. Both
// outcomes are present, because a fallback that only ever answers one
// way is not being tested.
{13, "Mohan Das", "", 11.05, 77.01, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "~8km, inside the 30km fallback"},
{14, "Sridhar Venkat", "", 13.0827, 80.2707, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "~430km, far outside the fallback"},
{15, "Bhavani Shankar", "64", 13.06, 80.24, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "2-digit pincode is unusable, distance decides"},
// The row that proves the prefix rule outranks distance.
{16, "Ramesh Palanisamy", "642001", 10.658, 77.008, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "~40km but 642 is a different area"},
}
if len(cases) != 16 {
t.Fatalf("the sheet has 16 rows, this table has %d — they must not drift apart", len(cases))
}
for _, tc := range cases {
t.Run(tc.receiver, func(t *testing.T) {
gotLocal := isHyperlocalBooking(
sheetPickupPincode, tc.pincode,
sheetPickupLat, sheetPickupLng,
tc.lat, tc.lng,
)
if gotLocal != tc.wantLocal {
t.Fatalf("row %d (%s): hyperlocal = %v, want %v — %s",
tc.row, tc.receiver, gotLocal, tc.wantLocal, tc.why)
}
// What the rider is actually told to do with it, from the same
// helper pickup-complete and the queue read both use.
status := constants.ConsignmentCreated
if gotLocal {
status = constants.ConsignmentOutForDelivery
}
if status != tc.wantConsStat {
t.Errorf("row %d: consignment state %s, want %s", tc.row, status, tc.wantConsStat)
}
if got := nextActionForConsignment(status); got != tc.wantAction {
t.Errorf("row %d: next_action %s, want %s", tc.row, got, tc.wantAction)
}
})
}
}
// The sheet is only worth uploading if it actually splits both ways. A file
// that turned out to be all-hyperlocal would pass every assertion above and
// still test nothing — which is exactly the problem with the tenant's own
// export that this sheet was written to replace.
func TestBulkTestSheetExercisesBothLegs(t *testing.T) {
type dest struct {
pincode string
lat, lng float64
}
dests := []dest{
{"600001", 13.091, 80.285}, {"600028", 13.018, 80.256},
{"500081", 17.44, 78.3489}, {"500032", 17.4156, 78.3378},
{"560001", 12.975, 77.606}, {"560066", 12.9698, 77.75},
{"625001", 9.9195, 78.119}, {"636001", 11.664, 78.146},
{"641004", 11.029, 76.993}, {"641012", 11.018, 76.966},
{"641025", 11.008, 76.928}, {"641038", 11.023, 76.945},
{"", 11.05, 77.01}, {"", 13.0827, 80.2707},
{"64", 13.06, 80.24}, {"642001", 10.658, 77.008},
}
var local, hub int
for _, d := range dests {
if isHyperlocalBooking(sheetPickupPincode, d.pincode, sheetPickupLat, sheetPickupLng, d.lat, d.lng) {
local++
} else {
hub++
}
}
if hub < 5 {
t.Errorf("only %d rows route through a base — too few to exercise the handover flow", hub)
}
if local < 3 {
t.Errorf("only %d rows go direct to the customer — no control group", local)
}
t.Logf("sheet splits %d base-handover / %d direct-to-customer", hub, local)
}

View File

@@ -254,6 +254,28 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
}
}
// A customer-app pickup fans out into one consignment per destination, and
// each of those is a SEPARATE delivery the rider has to make. Without this
// the queue showed one row per booking keyed on pickupbookings.consignmentid
// — which names only the first order — so on a three-destination pickup two
// parcels would exist in the rider's bag with no stop, no deliver button and
// no way to close them.
//
// Bookings with no destination rows (every console/express booking, and
// everything written before the fan-out) are untouched: they still produce
// exactly one row, built from the booking's own columns.
destinationsByBooking := map[int][]models.BookingDestination{}
if len(bookingIDs) > 0 {
var destinations []models.BookingDestination
db.DB.Where("bookingid IN ?", bookingIDs).Order("seq ASC").Find(&destinations)
for _, d := range destinations {
destinationsByBooking[d.Bookingid] = append(destinationsByBooking[d.Bookingid], d)
if d.Consignmentid != nil {
consignmentIDs = append(consignmentIDs, *d.Consignmentid)
}
}
}
// step / road-optimized sequence lives on the active assignment row, written
// by the express route optimizer. Step 0 = not sequenced (single-stop or
// optimizer down), never a position — passed through verbatim.
@@ -287,20 +309,65 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
var customer models.AppCustomer
db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer)
// Where this parcel is collected FROM, and what kind of place that is, so
// Home can title the stop correctly. Without pickup_source_type every
// logistics pickup was grouped under the rider's own base name and a
// collection at a shop looked identical to one at a house.
//
// sourceid is null for a customer door and that is a real answer, not a
// missing one — the type is what makes the difference legible. pickuplocationid
// mirrors sourceid: the app has used both spellings for the same thing, and
// the booking row never carried either before now.
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b,
customer.Firstname+" "+customer.Lastname)
stops := milerStopsForBooking(&b, destinationsByBooking[b.Bookingid])
for _, stop := range stops {
// Cash-to-collect: prefer the consignment's COD once it exists, otherwise
// fall back to a pending Cash payment on the booking. Prepaid/UPI stays 0.
codAmount := 0.0
paymentMode := ""
if b.Consignmentid != nil {
if cn, ok := codByConsignment[*b.Consignmentid]; ok {
var consignmentStatus string
// next_action / next_hub: the leg this parcel is on, rebuilt from server
// state on every poll. pickup-complete used to be the only place that ever
// said it, so a restart mid-leg left the app with nothing authoritative to
// read — consignment status alone cannot separate a hub-routed parcel from
// a freshly-collected hyperlocal one, since both can sit on Created.
nextAction := constants.NextActionPickup
var nextHub fiber.Map
if stop.consignmentID != nil {
if cn, ok := codByConsignment[*stop.consignmentID]; ok {
consignmentStatus = cn.Status
paymentMode = cn.Paymentmode
codAmount = cn.Codamount - cn.Codcollected
if codAmount < 0 {
codAmount = 0
}
nextAction = nextActionForConsignment(cn.Status)
nextHub = nextHubForConsignment(&cn)
} else {
// The consignment row did not load. Fall back to what this
// destination asked for rather than to zero — a rider shown
// ₹0 collects nothing, and the customer's money is the one
// thing that must not silently vanish from a stop.
codAmount = stop.codAmount
}
} else if stop.codAmount > 0 {
// Not yet collected: the collection is what the customer
// requested for this door.
codAmount = stop.codAmount
}
if codAmount == 0 {
if b.Status == constants.BookingCancelled {
nextAction = constants.NextActionNone
}
// The pickup fee is charged once for the visit, so it is only offered
// against the first stop — asking a rider to collect it again at the
// second parcel would double-charge the customer.
if codAmount == 0 && stop.seq == 0 {
for _, p := range b.Payments {
if p.Paymentmode == constants.PaymentModeCash && p.Paymentstatus == constants.PaymentStatusPending {
codAmount = p.Amount
@@ -313,28 +380,39 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
}
}
// consignmentid/consignmentstatus so the app can call deliver, skip and
// start-delivery straight from the list without a per-order lookup.
var consignmentStatus string
if b.Consignmentid != nil {
if cn, ok := codByConsignment[*b.Consignmentid]; ok {
consignmentStatus = cn.Status
}
}
row := fiber.Map{
"bookingid": b.Bookingid,
"bookingreference": b.Bookingno,
"status": b.Status,
"stoptype": milerStopType(b.Status),
"consignmentid": b.Consignmentid,
// consignmentid/consignmentstatus so the app can call deliver, skip and
// start-delivery straight from the list without a per-order lookup.
"consignmentid": stop.consignmentID,
"consignmentstatus": consignmentStatus,
"pickupaddress": b.Pickupaddress,
"trackingno": stop.trackingNo,
// Which of the booking's destinations this stop is, and how many
// there are in total, so the app can say "Stop 2 of 3" instead of
// showing three identical-looking rows. Always 0 and 1 for a
// single-destination or console booking.
"destinationseq": stop.seq,
"destinationcount": len(stops),
"next_action": nextAction,
"next_hub": nextHub,
"pickup_source_type": sourceType,
"sourceid": sourceID,
"pickuplocationid": sourceID,
"pickup_source_name": sourceName,
"pickupaddress": sourceAddress,
"pickuplatitude": b.Pickuplatitude,
"pickuplongitude": b.Pickuplongitude,
"deliveryaddress": b.Deliveryaddress,
"deliverylatitude": b.Deliverylatitude,
"deliverylongitude": b.Deliverylongitude,
"deliveryaddress": stop.deliveryAddress,
"deliverylatitude": stop.deliveryLat,
"deliverylongitude": stop.deliveryLng,
"recipientname": stop.recipientName,
"recipientphone": stop.recipientPhone,
"customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
"customerphone": customer.Phone,
// Arrival fact: the rider app derives its "Arrived" rung from
@@ -345,9 +423,16 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
"reachedat": b.Arrivedat,
"arrivallatitude": b.Arrivallatitude,
"arrivallongitude": b.Arrivallongitude,
"parcels": b.Parcels,
"parcels": stop.parcels,
"serviceoptions": b.ServiceOptions,
"codamount": codAmount,
// collectionamt is the same number under the name the rider app
// reads. Added rather than renamed: `codamount` is what this
// endpoint has always emitted and the deployed build parses it,
// so removing it would break every stop on every existing
// device. Both are written from one variable, so they cannot
// drift apart.
"collectionamt": codAmount,
"paymentmode": paymentMode,
"createdat": b.Createdat,
// Route sequencing — 0/empty when the stop was never sequenced.
@@ -370,9 +455,11 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
}
response = append(response, row)
}
}
// Sequenced stops ascend by step; unsequenced (step 0) fall to the end while
// keeping the newest-first order the app already relied on.
// keeping the newest-first order the app already relied on. Stops from one
// booking share its step, so they stay adjacent and in destination order.
sort.SliceStable(response, func(i, j int) bool {
si, sj := response[i]["step"].(int), response[j]["step"].(int)
switch {
@@ -390,6 +477,121 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
return utils.List(c, response, int64(len(response)))
}
// milerStop is one thing the rider actually has to do with one parcel — a
// pickup before collection, a delivery leg after it.
type milerStop struct {
seq int
consignmentID *int
trackingNo string
deliveryAddress string
deliveryLat float64
deliveryLng float64
recipientName string
recipientPhone string
// codAmount is the collection the CUSTOMER asked for at THIS door, read off
// the destination row. The consignment's own figure wins at delivery time
// because it also knows what has already been collected — but this is what
// makes the per-stop split provable without a database, and it is the value
// used if the consignment row could not be loaded. Money must never fall
// back to another destination's number.
codAmount float64
parcels []models.BookingParcel
}
// milerStopsForBooking decides how many stops a booking is worth to the rider.
//
// Before the parcels are collected it is always ONE stop: the rider makes a
// single visit to the customer's door, whatever it is carrying away. After
// collection a customer-app pickup becomes one stop per destination, because
// each parcel now has its own journey, its own tracking number and its own
// deliver/skip action.
//
// A booking with no destination rows — every console/express booking, and
// everything written before the fan-out — is always one stop built from the
// booking's own columns, exactly as before.
func milerStopsForBooking(b *models.PickupBooking, destinations []models.BookingDestination) []milerStop {
single := []milerStop{{
seq: 0,
consignmentID: b.Consignmentid,
deliveryAddress: b.Deliveryaddress,
deliveryLat: b.Deliverylatitude,
deliveryLng: b.Deliverylongitude,
parcels: b.Parcels,
}}
// A booking with exactly one destination row still carries its COD there.
if len(destinations) == 1 {
single[0].codAmount = destinations[0].Codamount
}
if len(destinations) == 0 {
return single
}
// Not collected yet: one visit, one stop. The destinations are still only an
// intention, and showing three rows for a pickup that has not happened would
// have the rider drive to the same door three times.
collected := false
for _, d := range destinations {
if d.Consignmentid != nil {
collected = true
break
}
}
if !collected {
// Destination 0's address is already mirrored onto the booking, so the
// single pre-pickup stop is correct as built above.
return single
}
parcelsByDestination := map[int][]models.BookingParcel{}
for _, p := range b.Parcels {
if p.Bookingdestinationid != nil {
parcelsByDestination[*p.Bookingdestinationid] = append(parcelsByDestination[*p.Bookingdestinationid], p)
}
}
// Stop order is `seq`, guaranteed here rather than inherited from whatever
// ORDER BY the caller happened to use. The queue query does sort by seq
// today, so this changes nothing — but the ordering IS the route the rider
// drives, and leaving it as an unstated precondition means the next caller,
// or an edited query, silently reorders someone's afternoon. Sorted on a
// copy so the caller's slice is never mutated underneath it.
ordered := make([]models.BookingDestination, len(destinations))
copy(ordered, destinations)
sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Seq < ordered[j].Seq })
destinations = ordered
stops := make([]milerStop, 0, len(destinations))
for _, d := range destinations {
lat, lng := 0.0, 0.0
if d.Pinlatitude != nil && d.Pinlongitude != nil {
lat, lng = *d.Pinlatitude, *d.Pinlongitude
}
stops = append(stops, milerStop{
seq: d.Seq,
consignmentID: d.Consignmentid,
trackingNo: d.Trackingno,
deliveryAddress: joinNonEmpty(", ", d.Building, d.Street, d.Landmark, d.Districtname, d.Statename),
deliveryLat: lat,
deliveryLng: lng,
recipientName: d.Recipientname,
recipientPhone: d.Recipientphone,
codAmount: d.Codamount,
parcels: parcelsByDestination[d.Bookingdestinationid],
})
}
// Destination 0's coordinates are mirrored onto the booking and may have
// been corrected there by the rider at the door, so prefer those when the
// destination itself never got a pin.
if stops[0].deliveryLat == 0 && stops[0].deliveryLng == 0 {
stops[0].deliveryLat = b.Deliverylatitude
stops[0].deliveryLng = b.Deliverylongitude
}
return stops
}
// milerConsignmentForRider loads a consignment and confirms it belongs to this
// rider — either it is linked to a booking currently assigned to them, or they
// are the one who collected it (Createdby). Returns a stable error code on
@@ -403,6 +605,19 @@ func milerConsignmentForRider(milerUserID, consignmentID int) (*models.Consignme
db.DB.Model(&models.PickupBooking{}).
Where("consignmentid = ? AND assignedmileruserid = ?", consignmentID, milerUserID).
Count(&count)
// pickupbookings.consignmentid names only the FIRST order of a
// multi-destination pickup, so the count above misses orders 2..N entirely.
// The destination table is what actually knows which booking a consignment
// belongs to.
if count == 0 {
db.DB.Model(&models.BookingDestination{}).
Joins("JOIN pickupbookings ON pickupbookings.bookingid = bookingdestinations.bookingid").
Where("bookingdestinations.consignmentid = ? AND pickupbookings.assignedmileruserid = ?",
consignmentID, milerUserID).
Count(&count)
}
if count == 0 && consignment.Createdby != milerUserID {
return nil, constants.ErrConsignmentNotAssigned, fmt.Errorf("not this rider's consignment")
}
@@ -442,6 +657,12 @@ func MilerGetConsignment(c *fiber.Ctx) error {
"can_start_delivery": consignment.Status == constants.ConsignmentCollectedByMiler,
"can_deliver": consignment.Status == constants.ConsignmentOutForDelivery,
"can_skip": consignment.Status == constants.ConsignmentOutForDelivery || consignment.Status == constants.ConsignmentCollectedByMiler,
// The same leg information the queue read and pickup-complete give, so a
// single-consignment refresh is as authoritative as a full poll.
"next_action": nextActionForConsignment(consignment.Status),
"next_hub": nextHubForConsignment(consignment),
"can_inward_at_hub": consignment.Status == constants.ConsignmentCreated,
"inwardedat": consignment.Inwardedat,
})
}
@@ -504,14 +725,30 @@ func MilerStartDelivery(c *fiber.Ctx) error {
return utils.Internal(c, "failed to update miler availability")
}
notifyOutForDelivery, err := recordCxConsignmentStage(tx, consignment.Consignmentid,
constants.ConsignmentOutForDelivery, constants.CxActorMiler, &milerUserID,
"POST /miler/consignments/{id}/start-delivery")
if err != nil {
tx.Rollback()
utils.Error("MilerStartDelivery: could not record out_for_delivery",
"consignment_id", consignment.Consignmentid, "error", err)
return utils.Internal(c, "failed to start delivery")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to start delivery")
}
notifyOutForDelivery()
// Tell the customer it's on the way, and hand the receiver their OTP (only the
// receiver — the rider is told it at the door).
var booking models.PickupBooking
if db.DB.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil {
//
// Resolved through bookingdestinations: pickupbookings.consignmentid names
// only the first order of a multi-destination pickup, so joining on it meant
// no customer was ever told about orders 2..N going out for delivery.
if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil {
booking := *bookingPtr
var customer models.AppCustomer
if db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error == nil && customer.Devicetoken != "" {
body := "Your parcel is out for delivery."
@@ -558,16 +795,32 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
return utils.BadRequest(c, "deliveredtoname is required")
}
var consignment models.Consignment
if err := db.DB.First(&consignment, consignmentID).Error; err != nil {
// Ownership and the booking behind the parcel, via the one helper that
// understands a multi-destination pickup. This used to be a direct
// `WHERE consignmentid = ? AND assignedmileruserid = ?` on pickupbookings —
// which names only the FIRST order of a pickup, so a rider delivering the
// second parcel of a three-destination visit was told "assigned consignment
// not found" and could not close the delivery at all.
// Both failures answer 404 with the exact messages this endpoint has always
// returned. milerConsignmentForRider distinguishes "no such consignment"
// from "not yours" and start-delivery reports that as a 403, but the
// deployed rider app was built against a 404 here and changing a live
// endpoint's status code is not this work's business. Only the LOOKUP is
// fixed; the contract is byte-identical.
consignmentPtr, code, err := milerConsignmentForRider(milerUserID, consignmentID)
if err != nil {
if code == constants.ErrConsignmentNotFound {
return utils.NotFound(c, "consignment not found")
}
var booking models.PickupBooking
if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID).
First(&booking).Error; err != nil {
return utils.NotFound(c, "assigned consignment not found")
}
consignment := *consignmentPtr
_, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid)
if !ok || bookingPtr == nil {
return utils.NotFound(c, "assigned consignment not found")
}
booking := *bookingPtr
if consignment.Status != constants.ConsignmentOutForDelivery {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
@@ -662,10 +915,27 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
return utils.Internal(c, "failed to close assignment")
}
// The customer's Delivered milestone, against THIS order. The booking only
// reads as completed once every destination has landed — cxstage rolls the
// booking up from the least-advanced order, so a two-parcel pickup with one
// still in transit stays "In transit" rather than telling the customer
// everything arrived.
notifyDelivered, err := recordCxConsignmentStage(tx, consignment.Consignmentid,
constants.ConsignmentDelivered, constants.CxActorMiler, &milerUserID,
"POST /miler/consignments/{id}/deliver")
if err != nil {
tx.Rollback()
utils.Error("MilerDeliverConsignment: could not record delivered",
"consignment_id", consignment.Consignmentid, "error", err)
return utils.Internal(c, "failed to confirm delivery")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to confirm delivery")
}
notifyDelivered()
if db.Js != nil {
payload := map[string]interface{}{
"bookingid": booking.Bookingid,

View File

@@ -2,7 +2,6 @@ package controllers
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"math"
@@ -16,6 +15,8 @@ import (
"doormile/db"
"doormile/dto"
"doormile/internal/assignment"
"doormile/internal/cxstage"
"doormile/internal/legs"
"doormile/internal/notify"
"doormile/internal/routing"
"doormile/models"
@@ -25,12 +26,6 @@ import (
"github.com/redis/go-redis/v9"
)
func generateTrackingNo() string {
b := make([]byte, 4)
rand.Read(b)
return fmt.Sprintf("DM-TRK-%X-%d", b, time.Now().Unix()%100000)
}
func LoginMiler(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.MilerLoginRequest)
@@ -528,10 +523,40 @@ func AcceptMilerAssignment(c *fiber.Ctx) error {
return utils.Internal(c, "failed to update miler availability")
}
// Accepting is the customer's "on the way": the rider has seen the job and
// is heading over. `assigned` was already recorded when the assignment was
// created (assignMilerTx / commitAssignment) and is re-asserted here only
// as a safety net for a booking assigned before this surface existed —
// Record dedupes, so it appends nothing when it is already on the timeline.
//
// Deriving on_the_way from the GPS stream instead would mean re-deriving it
// on every ping: thousands of writes to learn something the accept already
// said.
stageAt := utils.DBNow()
for _, stage := range []string{constants.CxStageAssigned, constants.CxStageOnTheWay} {
if err := cxstage.Record(tx, cxstage.Event{
BookingID: assignment.Bookingid,
Stage: stage,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/assignments/{id}/accept",
At: stageAt,
}); err != nil {
tx.Rollback()
utils.Error("AcceptMilerAssignment: could not record customer stage",
"booking_id", assignment.Bookingid, "stage", stage, "error", err)
return utils.Internal(c, "failed to accept assignment")
}
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to commit assignment acceptance")
}
// No push here: `assigned` was already announced when the assignment was
// created, and on_the_way deliberately rolls up on the timeline. The
// existing "Miler Accepted" notification below is the one the customer gets.
// Accepting a stop moves it into the active set the optimizer orders over, so
// re-sequence the rider off the request path. No-op below two active stops.
routing.SequenceMilerStopsAsync(milerUserID)
@@ -686,6 +711,17 @@ func MilerCancelAssignment(c *fiber.Ctx) error {
return utils.Internal(c, "failed to update miler availability")
}
// The pickup is NOT cancelled — it goes back into the pool. The customer's
// stage has to walk back with it, or they keep seeing "Miler assigned" and
// a rider card for someone who is no longer coming.
if err := cxstage.Release(tx, booking.Bookingid, req.Reason,
constants.CxActorMiler, &milerUserID,
"POST /miler/bookings/{id}/cancel"); err != nil {
tx.Rollback()
utils.Error("MilerCancelAssignment: could not release the customer stage", "booking_id", booking.Bookingid, "error", err)
return utils.Internal(c, "failed to commit cancellation")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to commit cancellation")
}
@@ -750,9 +786,29 @@ func BookingReachedCustomer(c *fiber.Ctx) error {
return utils.Internal(c, "failed to update miler availability")
}
// Arrival is the LAST cancellable stage on the customer side, so it has to
// be recorded transactionally with the arrival fact itself. A gap between
// the two is a window in which the customer can still cancel a pickup the
// rider is already standing at.
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
Stage: constants.CxStageArrived,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/bookings/{id}/reached",
At: utils.DBNow(),
}); err != nil {
tx.Rollback()
utils.Error("BookingReachedCustomer: could not record arrived stage", "booking_id", booking.Bookingid, "error", err)
return utils.Internal(c, "failed to record arrival")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to confirm arrival")
}
go cxstage.Notify(booking.Bookingid, nil, constants.CxStageArrived)
return utils.OK(c, fiber.Map{
"bookingid": booking.Bookingid,
"status": booking.Status,
@@ -858,12 +914,19 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
return utils.NotFound(c, "assigned booking not found")
}
// Photos are the evidence half of the receipt. Weight without a photograph
// is a number the customer has no way to check, and this is the only point
// in the flow where anyone is standing next to the parcel. Sent as storage
// keys from the presigned upload (POST /miler/uploads/sign), not as raw
// URLs: the customer is served a short-lived signed link derived from the
// key, never a permanent one.
type ParcelUpdate struct {
ParcelID int `json:"parcel_id"`
Weight float64 `json:"weight"`
Length float64 `json:"length"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Photos []string `json:"photos"`
}
var req struct {
Parcels []ParcelUpdate `json:"parcels"`
@@ -886,8 +949,13 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
parcelMap[parcels[i].Bookingparcelid] = &parcels[i]
}
now := time.Now()
now := utils.DBNow()
var totalChargeable float64
// Chargeable weight per destination, so each order settles on the weight of
// its own parcels rather than on the whole visit's total.
perDestination := map[int]float64{}
tx := db.DB.Begin()
for _, upd := range req.Parcels {
p, ok := parcelMap[upd.ParcelID]
@@ -899,10 +967,51 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
p.Width = upd.Width
p.Height = upd.Height
p.Updatedat = now
db.DB.Save(p)
if err := tx.Save(p).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save parcel measurements")
}
volumetric := calculateVolumetricWeight(upd.Length, upd.Width, upd.Height)
totalChargeable += math.Max(upd.Weight, volumetric)
chargeable := math.Max(upd.Weight, volumetric)
totalChargeable += chargeable
if p.Bookingdestinationid != nil {
perDestination[*p.Bookingdestinationid] += chargeable
}
for _, key := range upd.Photos {
key = strings.TrimSpace(key)
if key == "" {
continue
}
photo := models.BookingParcelPhoto{
Bookingid: bookingID,
Bookingdestinationid: p.Bookingdestinationid,
Objectkey: key,
Capturedbyuserid: &milerUserID,
Capturedat: now,
}
if err := tx.Create(&photo).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save parcel photo")
}
}
}
// The verification block the customer's receipt reads. Written here, at the
// door, where the measurement was actually taken — pickup-complete restates
// it from the same parcel rows when the price settles, so the two cannot
// disagree.
for destinationID, weight := range perDestination {
if err := cxRecordVerification(tx, destinationID, weight, milerUserID, now); err != nil {
tx.Rollback()
utils.Error("BookingParcelConfirm: could not record verification", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the parcel weight")
}
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to confirm parcels")
}
return utils.OK(c, fiber.Map{
@@ -957,10 +1066,7 @@ func BookingPaymentCollect(c *fiber.Ctx) error {
// final-mile delivery. Pincodes shorter than 3 characters are treated as
// unknown rather than matching, so bad data falls back to the safe hub route.
func isHyperlocal(pickupPincode, deliveryPincode string) bool {
if len(pickupPincode) < 3 || len(deliveryPincode) < 3 {
return false
}
return pickupPincode[:3] == deliveryPincode[:3]
return legs.SamePostalArea(pickupPincode, deliveryPincode)
}
// maxHyperlocalKM bounds the straight-line pickup→delivery distance under which
@@ -969,7 +1075,7 @@ func isHyperlocal(pickupPincode, deliveryPincode string) bool {
// created kitchen→customer bookings (e.g. DailyGrubs) frequently carry accurate
// coordinates but no delivery pincode, and must not be wrongly routed through a
// hub. When both pincodes are present the prefix rule still wins.
const maxHyperlocalKM = 30.0
const maxHyperlocalKM = legs.MaxHyperlocalKM
// isHyperlocalBooking decides whether a booking can skip the hub and be carried
// straight to the final mile. It prefers the pincode-prefix rule (isHyperlocal)
@@ -977,18 +1083,7 @@ const maxHyperlocalKM = 30.0
// between the pickup and delivery coordinates when a pincode is missing — so a
// same-area booking whose address carried no pincode isn't sent to a hub.
func isHyperlocalBooking(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng float64) bool {
// Both pincodes present: the prefix rule decides definitively (a matching
// pincode is hyperlocal, a differing one is genuinely inter-area — don't let
// distance override that).
if len(pickupPincode) >= 3 && len(deliveryPincode) >= 3 {
return isHyperlocal(pickupPincode, deliveryPincode)
}
// A pincode is missing: fall back to straight-line distance when we have both
// coordinates.
if pLat != 0 && pLng != 0 && dLat != 0 && dLng != 0 {
return haversineKM(pLat, pLng, dLat, dLng) <= maxHyperlocalKM
}
return false
return legs.IsHyperlocal(pickupPincode, deliveryPincode, pLat, pLng, dLat, dLng)
}
// collectedStateEnabled gates the two-step hyperlocal delivery flow
@@ -1043,60 +1138,30 @@ func BookingPickupComplete(c *fiber.Ctx) error {
}
}
var parcels []models.BookingParcel
tx.Where("bookingid = ?", bookingID).Find(&parcels)
var totalDead, totalChargeable, maxL, maxW, maxH float64
for _, p := range parcels {
vol := calculateVolumetricWeight(p.Length, p.Width, p.Height)
totalDead += p.Weight
totalChargeable += math.Max(p.Weight, vol)
if p.Length > maxL {
maxL = p.Length
}
if p.Width > maxW {
maxW = p.Width
}
if p.Height > maxH {
maxH = p.Height
}
}
if len(parcels) == 0 {
totalDead = 0.5
totalChargeable = 0.5
// One visit, N orders. A customer-app booking fans out into one consignment
// per destination — each with its own tracking number and its own journey —
// while a console-created booking, which has no destination rows, produces
// the single consignment it always did. cxPickupLegs is what decides which
// of those this is; nothing below needs to know.
legs, err := cxPickupLegs(tx, &booking)
if err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not resolve pickup legs", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to read the parcels on this booking")
}
trackingNo := generateTrackingNo()
// The base this parcel belongs to. Backend decides — the app is told where to
// go and never picks a base itself. resolveHandoverHub prefers the base the
// booking was routed to, then the collecting rider's own base (the previous
// behaviour, and still the operational default), then the nearest active base
// to the pickup point — that last step replaces a fallback that took whichever
// hub row happened to come back first.
handoverHub := resolveHandoverHub(&booking, profile.Hubid)
var defaultHubID *int
if profile.Hubid != nil {
defaultHubID = profile.Hubid
if handoverHub != nil {
defaultHubID = &handoverHub.Hubid
} else {
utils.Warn("BookingPickupComplete: miler has no assigned hub, falling back to first hub row", "miler_user_id", milerUserID, "booking_id", bookingID)
var hub models.Hub
if tx.First(&hub).Error == nil {
defaultHubID = &hub.Hubid
}
}
// Hyperlocal shortcut: pickup and delivery in the same postal area mean no
// hub-to-hub tripsheet leg is needed, so the same miler carries it to the
// final mile instead of parking it at the hub.
//
// With the collected-state flow ON it lands in Collected_By_Miler — collected
// but not yet out for delivery — and the rider taps start-delivery to move it
// to Out_for_Delivery, which lets the console tell "collected" from "actively
// delivering". With it OFF (default, and what the current app expects) it goes
// straight to Out_for_Delivery exactly as before.
consignmentStatus := constants.ConsignmentInwardedAtHub
if isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode,
booking.Pickuplatitude, booking.Pickuplongitude,
booking.Deliverylatitude, booking.Deliverylongitude) {
if collectedStateEnabled() {
consignmentStatus = constants.ConsignmentCollectedByMiler
} else {
consignmentStatus = constants.ConsignmentOutForDelivery
}
utils.Warn("BookingPickupComplete: no base could be resolved for this pickup", "miler_user_id", milerUserID, "booking_id", bookingID)
}
// The consignment's tenant is the booking's own tenant (set explicitly at
@@ -1110,6 +1175,59 @@ func BookingPickupComplete(c *fiber.Ctx) error {
consignmentTenantID = *booking.Tenantid
}
var tenant models.Tenant
tenantNeedsOTP := false
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil {
tenantNeedsOTP = tenant.Requiredeliveryotp
}
// Money collected at the door. Split across the legs below rather than
// stamped whole onto each one: a single payment covering a three-stop
// pickup must not appear three times in the books.
var payment models.BookingPayment
hasPayment := tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil
created := make([]models.Consignment, 0, len(legs))
trackingNos := make([]string, 0, len(legs))
riderMarkedBusy := false
assignmentStillOpen := false
for i, leg := range legs {
totalDead, totalChargeable, maxL, maxW, maxH := cxLegWeights(leg)
trackingNo := generateTrackingNo()
// A hub-routed parcel: with the hub-handover flow ON it stops at Created —
// collected, in the rider's hands, on its way to a base — and only reaches
// Inwarded_at_Hub when the handover is actually recorded. With it OFF
// (default, and what the current app expects) it is marked Inwarded_at_Hub
// here, which is not where the parcel physically is but is what the current
// app and the console's inbound views read.
consignmentStatus := constants.ConsignmentInwardedAtHub
if hubHandoverEnabled() {
consignmentStatus = constants.ConsignmentCreated
}
// Hyperlocal shortcut: pickup and delivery in the same postal area mean no
// hub-to-hub tripsheet leg is needed, so the same miler carries it to the
// final mile instead of parking it at the hub. Decided per leg, because on
// a multi-destination pickup one parcel can be going round the corner while
// another is going to another state.
//
// With the collected-state flow ON it lands in Collected_By_Miler — collected
// but not yet out for delivery — and the rider taps start-delivery to move it
// to Out_for_Delivery, which lets the console tell "collected" from "actively
// delivering". With it OFF (default, and what the current app expects) it goes
// straight to Out_for_Delivery exactly as before.
if isHyperlocalBooking(booking.Pickuppincode, leg.DeliveryPincode,
booking.Pickuplatitude, booking.Pickuplongitude,
leg.DeliveryLatitude, leg.DeliveryLongitude) {
if collectedStateEnabled() {
consignmentStatus = constants.ConsignmentCollectedByMiler
} else {
consignmentStatus = constants.ConsignmentOutForDelivery
}
}
// Carried over so the consignment stays traceable to the client site it was
// collected from — for a food client that's the kitchen, and "how many
// parcels went out of which kitchen" is unanswerable without it.
@@ -1120,10 +1238,10 @@ func BookingPickupComplete(c *fiber.Ctx) error {
Tenantlocationid: booking.Tenantlocationid,
Pickuplatitude: booking.Pickuplatitude,
Pickuplongitude: booking.Pickuplongitude,
Deliverylatitude: booking.Deliverylatitude,
Deliverylongitude: booking.Deliverylongitude,
Deliverylatitude: leg.DeliveryLatitude,
Deliverylongitude: leg.DeliveryLongitude,
Pickuppincode: booking.Pickuppincode,
Deliverypincode: booking.Deliverypincode,
Deliverypincode: leg.DeliveryPincode,
Length: maxL,
Width: maxW,
Height: maxH,
@@ -1132,18 +1250,35 @@ func BookingPickupComplete(c *fiber.Ctx) error {
Chargeableweight: totalChargeable,
Paymentmode: "Prepaid",
Status: consignmentStatus,
Estimateddeliveryat: nil,
Estimateddeliveryat: cxEstimatedDelivery(leg, now),
Createdby: milerUserID,
Originhubid: defaultHubID,
Currenthubid: defaultHubID,
}
var payment models.BookingPayment
if tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil {
// COD the customer asked to have collected at THIS door, on their behalf.
// Doormile is the carrier, not the seller — this money is never Doormile's.
if leg.CodAmount > 0 {
consignment.Codamount = leg.CodAmount
consignment.Paymentmode = "COD"
}
// Under the compatibility flow the parcel is treated as received at the base
// the moment it is collected, so the received-at fact is stamped here too —
// otherwise every parcel inwarded this way would have a null handover time
// and base reconciliation would have nothing to compare against.
if consignmentStatus == constants.ConsignmentInwardedAtHub {
consignment.Inwardedat = &now
}
// The pickup fee the miler collected covers the whole visit, so it is
// recorded once — against the first order — rather than repeated on
// every leg.
if hasPayment && i == 0 {
if payment.Paymentstatus == constants.PaymentStatusPaid {
consignment.Codcollected = payment.Amount
} else {
consignment.Codamount = payment.Amount
consignment.Codamount += payment.Amount
consignment.Paymentmode = "COD"
}
}
@@ -1153,23 +1288,32 @@ func BookingPickupComplete(c *fiber.Ctx) error {
// a hyperlocal parcel stops at Collected_By_Miler and its OTP is issued later
// at start-delivery instead, so this block simply doesn't fire. Only clients
// that ask for one get an OTP (Tenant.Requiredeliveryotp).
if consignmentStatus == constants.ConsignmentOutForDelivery {
var tenant models.Tenant
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil && tenant.Requiredeliveryotp {
if consignmentStatus == constants.ConsignmentOutForDelivery && tenantNeedsOTP {
consignment.Deliveryotp = utils.GenerateNumericOTP(6)
}
}
if err := tx.Create(&consignment).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to convert booking to consignment")
}
booking.Consignmentid = &consignment.Consignmentid
booking.Status = constants.BookingConvertedConsignment
if err := tx.Save(&booking).Error; err != nil {
if err := cxLinkLegToOrder(tx, leg, consignment.Consignmentid, trackingNo,
consignment.Estimateddeliveryat, now); err != nil {
tx.Rollback()
return utils.Internal(c, "failed to link booking to consignment")
utils.Error("BookingPickupComplete: could not link destination to order", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to link the destination to its order")
}
// What the miler weighed at this door, kept where the customer's receipt
// reads it. Without this the verification block stays empty and the
// receipt loses the evidence behind the settled price.
if leg.Destination != nil {
if err := cxRecordVerification(tx, leg.Destination.Bookingdestinationid,
totalChargeable, milerUserID, now); err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not record verification", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the parcel weight")
}
}
history := models.ConsignmentHistory{
@@ -1184,13 +1328,125 @@ func BookingPickupComplete(c *fiber.Ctx) error {
return utils.Internal(c, "failed to record consignment history")
}
// A hyperlocal parcel is still in the rider's hands (they will deliver it), so
// they stay Picked_Up and out of the assignment pool until they finish. A
// hub-routed parcel was dropped at the hub, so the rider frees up.
// Two separate questions, deliberately not merged.
//
// "Is the rider marked busy?" keeps the EXACT rule this endpoint has
// always applied — Created or Collected_By_Miler only. A hyperlocal
// parcel that goes straight to Out_for_Delivery has always left the
// rider Available here, even though the comment below says otherwise.
// That mismatch is pre-existing and is the default path today; changing
// it would alter live rider availability, which is not this work's
// business. Flagged in docs/customer-app-api.md, not silently fixed.
//
// "Is the assignment still open?" is the one that has to understand the
// fan-out: it closes only when every leg has been handed over at a base,
// which for a single-leg booking is identical to the previous behaviour.
if consignmentStatus == constants.ConsignmentCollectedByMiler ||
consignmentStatus == constants.ConsignmentCreated {
riderMarkedBusy = true
}
if consignmentStatus != constants.ConsignmentInwardedAtHub {
assignmentStillOpen = true
}
// The customer's per-order stage. in_transit is recorded here only on the
// compatibility flow, where the parcel really is treated as received at
// the base the instant it is collected; on the handover flow it waits for
// the rider to actually hand it over.
destinationID := cxDestinationIDFor(leg.Destination)
if err := cxstage.Record(tx, cxstage.Event{
BookingID: bookingID,
DestinationID: destinationID,
Stage: constants.CxStageOrderCreated,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/bookings/{id}/pickup-complete",
At: utils.DBNow(),
}); err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not record order_created", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the pickup")
}
// On the compatibility flow a parcel is already past order_created the
// instant it is collected — hub-routed ones are stamped Inwarded_at_Hub
// here, and hyperlocal ones go straight to Out_for_Delivery. Recording
// what the status actually says keeps the timeline honest: those
// transitions really did happen at this moment, and omitting them would
// leave a parcel showing "Package collected" while the rider is already
// carrying it to the door.
if implied, ok := cxStageForConsignmentStatus(consignmentStatus); ok {
if err := cxstage.Record(tx, cxstage.Event{
BookingID: bookingID,
DestinationID: destinationID,
Stage: implied,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/bookings/{id}/pickup-complete",
At: utils.DBNow(),
}); err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not record per-order stage", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the pickup")
}
}
created = append(created, consignment)
trackingNos = append(trackingNos, trackingNo)
}
// Recorded before order_created in wall-clock terms — the parcels were in the
// rider's hands before the orders existed — but written after, because the
// weights the price settles on are only known once the legs are built.
if err := cxstage.Record(tx, cxstage.Event{
BookingID: bookingID,
Stage: constants.CxStagePickedUp,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/bookings/{id}/pickup-complete",
At: utils.DBNow(),
}); err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not record picked_up", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the pickup")
}
// pickupbookings.consignmentid names the FIRST order only. It is kept for
// the console and the legacy reads that still join on it; anything that
// needs the whole set goes through bookingdestinations.
first := created[0]
booking.Consignmentid = &first.Consignmentid
booking.Status = constants.BookingConvertedConsignment
if err := tx.Save(&booking).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to link booking to consignment")
}
// Unchanged from before the fan-out: a parcel still on its way to a base
// keeps the rider marked busy; anything else frees them up.
postPickupAvailability := constants.MilerAvailable
if consignmentStatus == constants.ConsignmentCollectedByMiler {
if riderMarkedBusy {
postPickupAvailability = constants.MilerPickedUp
}
// A parcel that is already inwarded at the base ends this rider's leg, so the
// assignment closes with it. Without this the assignment stayed open forever
// on the compatibility flow and the rider could not go off duty — MilerEndDuty
// refuses while any assignment is still Assigned/Accepted. On the handover
// flow the assignment stays open on purpose and closes at inward-at-hub.
if !assignmentStillOpen {
if err := tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
bookingID, milerUserID,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentCompleted,
"completedat": now,
}).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to close assignment")
}
}
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
Update("availabilitystatus", postPickupAvailability).Error; err != nil {
tx.Rollback()
@@ -1201,49 +1457,90 @@ func BookingPickupComplete(c *fiber.Ctx) error {
return utils.Internal(c, "failed to complete pickup")
}
// One notification for the milestone, not one per order: three tracking
// numbers arriving as three buzzes for a single visit is noise, and
// order_created deliberately rolls up on the timeline.
go cxstage.Notify(bookingID, nil, constants.CxStagePickedUp)
// If an OTP was issued here (parcel went straight out for delivery), it goes to
// the receiver in this notification — the rider is told it at the door. When
// the collected-state flow is on, no OTP exists yet and the notification is
// just "collected"; the OTP rides the start-delivery notification instead.
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo)
payload := map[string]string{
"booking_id": strconv.Itoa(bookingID),
"tracking_no": trackingNo,
}
if consignment.Deliveryotp != "" {
body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignment.Deliveryotp)
payload["delivery_otp"] = consignment.Deliveryotp
}
if notifyErr := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); notifyErr != nil {
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", bookingID, "error", notifyErr)
}
}
notifyCustomerOnPickup(&booking, created, trackingNos)
return utils.OK(c, fiber.Map{
"tracking_no": trackingNo,
"consignment_id": consignment.Consignmentid,
"consignmentstatus": consignment.Status,
// next_action says what the rider does next; next_hub says where. An
// inward_at_hub with no base named leaves a rider holding a parcel with
// nowhere to take it, so the two travel together — and next_hub carries all
// six fields, because the coordinates are the only thing that can drive
// Navigate and the address and pincode are what the rider reads at the gate.
//
// consignment_id is always present: the delivery leg is keyed on it, and
// without it the app cannot name the parcel it is about to act on.
//
// The single-consignment fields still describe the FIRST order, unchanged,
// so the deployed rider app keeps working exactly as before. `consignments`
// is additive and carries the full set for a build that can show them.
resp := fiber.Map{
"tracking_no": trackingNos[0],
"consignment_id": first.Consignmentid,
"consignmentstatus": first.Status,
"status": first.Status,
"booking_no": booking.Bookingno,
"booking_status": booking.Status,
"next_action": pickupNextAction(consignment.Status),
})
"next_action": nextActionForConsignment(first.Status),
"consignments": renderPickupOrders(created, trackingNos),
}
if first.Status == constants.ConsignmentCreated ||
first.Status == constants.ConsignmentInwardedAtHub {
resp["next_hub"] = renderBase(handoverHub)
}
return utils.OK(c, resp)
}
// pickupNextAction tells the app what the rider does next after a pickup, so it
// doesn't have to encode the hub-vs-hyperlocal branch itself:
// - Collected_By_Miler → tap start-delivery (collected-state flow on)
// - Out_for_Delivery → deliver directly (hyperlocal, collected-state off)
// - Inwarded_at_Hub → handed to the hub, done for this rider
func pickupNextAction(consignmentStatus string) string {
switch consignmentStatus {
case constants.ConsignmentCollectedByMiler:
return "start_delivery"
case constants.ConsignmentOutForDelivery:
return "deliver"
default:
return "handed_to_hub"
// renderPickupOrders lists every order a pickup produced, so a rider carrying
// three parcels from one visit can be shown three stops rather than one.
func renderPickupOrders(consignments []models.Consignment, trackingNos []string) []fiber.Map {
out := make([]fiber.Map, 0, len(consignments))
for i := range consignments {
cn := consignments[i]
out = append(out, fiber.Map{
"consignment_id": cn.Consignmentid,
"tracking_no": trackingNos[i],
"consignmentstatus": cn.Status,
"next_action": nextActionForConsignment(cn.Status),
"delivery_pincode": cn.Deliverypincode,
})
}
return out
}
// notifyCustomerOnPickup tells the customer their parcels were collected, and
// hands the receiver any delivery OTP that was issued. Best-effort: a push that
// fails must never fail a pickup that already committed.
func notifyCustomerOnPickup(booking *models.PickupBooking, consignments []models.Consignment, trackingNos []string) {
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err != nil {
return
}
if customer.Devicetoken == "" {
return
}
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNos[0])
if len(trackingNos) > 1 {
body = fmt.Sprintf("%d parcels picked up — first tracking no: %s", len(trackingNos), trackingNos[0])
}
payload := map[string]string{
"booking_id": strconv.Itoa(booking.Bookingid),
"tracking_no": trackingNos[0],
"reference": booking.Bookingno,
}
if len(consignments) > 0 && consignments[0].Deliveryotp != "" {
body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignments[0].Deliveryotp)
payload["delivery_otp"] = consignments[0].Deliveryotp
}
if err := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); err != nil {
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", booking.Bookingid, "error", err)
}
}

View File

@@ -1,128 +0,0 @@
package controllers
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"time"
"doormile/config"
"doormile/db"
"doormile/dto"
"doormile/internal/mail"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)
const (
otpTTL = 5 * time.Minute
otpMaxAttempts = 5
// otpVerifiedTTL is how long a successful email verification stays usable as
// proof of identity for a follow-up action such as a PIN reset. Long enough
// to type a new PIN, short enough that a stale verification can't be
// redeemed later.
otpVerifiedTTL = 10 * time.Minute
)
func generateOtpCode() string {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "000000"
}
return fmt.Sprintf("%06d", n.Int64())
}
func otpKey(email string) string { return fmt.Sprintf("otp:email:%s", email) }
func otpAttemptsKey(email string) string { return fmt.Sprintf("otp:email:%s:attempts", email) }
// otpVerifiedKey marks an email as recently proven. Verification previously
// left no trace at all, so nothing downstream could require it — which is why
// ResetCustomerPin was able to overwrite a PIN on nothing but a phone number.
func otpVerifiedKey(email string) string { return fmt.Sprintf("otp:email:%s:verified", email) }
// ConsumeEmailVerification reports whether the email was verified recently, and
// clears the marker so a single verification can authorise exactly one action.
func ConsumeEmailVerification(email string) bool {
if db.Rdb == nil || email == "" {
return false
}
ctx := context.Background()
n, err := db.Rdb.Del(ctx, otpVerifiedKey(email)).Result()
return err == nil && n > 0
}
func SendCustomerEmailOtp(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.SendEmailOtpRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Email == "" {
return utils.BadRequest(c, "email is required")
}
if db.Rdb == nil {
return utils.Internal(c, "verification service unavailable")
}
code := generateOtpCode()
ctx := context.Background()
if err := db.Rdb.Set(ctx, otpKey(req.Email), code, otpTTL).Err(); err != nil {
return utils.Internal(c, "failed to generate verification code")
}
db.Rdb.Del(ctx, otpAttemptsKey(req.Email))
if err := mail.SendOTPEmail(cfg, req.Email, code); err != nil {
utils.Warn("failed to send OTP email", "email", req.Email, "error", err)
return utils.Internal(c, "failed to send verification email")
}
return utils.Message(c, "verification code sent")
}
}
func VerifyCustomerEmailOtp() fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.VerifyEmailOtpRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Email == "" || req.Otp == "" {
return utils.BadRequest(c, "email and otp are required")
}
if db.Rdb == nil {
return utils.Internal(c, "verification service unavailable")
}
ctx := context.Background()
key := otpKey(req.Email)
stored, err := db.Rdb.Get(ctx, key).Result()
if err == redis.Nil {
return utils.BadRequest(c, "verification code expired or not found, please resend")
} else if err != nil {
return utils.Internal(c, "failed to verify code")
}
if stored != req.Otp {
attemptsKey := otpAttemptsKey(req.Email)
attempts, _ := db.Rdb.Incr(ctx, attemptsKey).Result()
db.Rdb.Expire(ctx, attemptsKey, otpTTL)
if attempts >= otpMaxAttempts {
db.Rdb.Del(ctx, key, attemptsKey)
return utils.BadRequest(c, "too many incorrect attempts, please request a new code")
}
return utils.Unauthorized(c, "incorrect verification code")
}
db.Rdb.Del(ctx, key, otpAttemptsKey(req.Email))
// Recorded so a follow-up PIN reset can prove this email was verified.
db.Rdb.Set(ctx, otpVerifiedKey(req.Email), "1", otpVerifiedTTL)
return utils.Message(c, "email verified successfully")
}
}

80
cors_test.go Normal file
View File

@@ -0,0 +1,80 @@
package main
import "testing"
// Which Origin headers count as "this machine".
//
// This exists because Flutter Web's dev server binds a random high port on every
// launch, so no fixed allowlist can name it — the app hit
// PreflightMissingAllowOriginHeader from http://localhost:65256 and would have
// hit it again from a different port tomorrow.
//
// The reason it is worth a test rather than a one-line helper: the obvious
// implementation is a prefix or substring match on "localhost", and that quietly
// admits http://localhost.attacker.com — a completely different machine that
// merely starts with the right word. Combined with AllowCredentials, that would
// let an attacker-controlled page make authenticated calls as the signed-in user.
// A parsed-host comparison is the only version that is actually safe, and this
// pins it.
func TestLoopbackOriginsAreAllowed(t *testing.T) {
for _, origin := range []string{
"http://localhost:65256", // the port Flutter Web picked; it changes every run
"http://localhost:5173",
"http://localhost",
"https://localhost:8443",
"http://127.0.0.1:3000",
"http://127.0.0.1",
"http://[::1]:8080",
} {
if !isLoopbackOrigin(origin) {
t.Errorf("%q is this machine and should be allowed in development", origin)
}
}
}
func TestLookalikeOriginsAreRefused(t *testing.T) {
// Every one of these contains "localhost" or "127.0.0.1" as a substring and
// is a different host. A prefix or Contains check would admit all of them.
for _, origin := range []string{
"http://localhost.attacker.com",
"https://localhost.evil.io:443",
"http://notlocalhost",
"http://mylocalhost:3000",
"http://127.0.0.1.attacker.com",
"http://evil.com/?x=http://localhost:3000",
"http://evil.com#localhost",
} {
if isLoopbackOrigin(origin) {
t.Errorf("%q is NOT this machine and must be refused", origin)
}
}
}
func TestNonHTTPSchemesAreRefused(t *testing.T) {
// An Origin is a scheme/host/port triple. Anything else is either a browser
// that will not send it or something forged, and neither should be trusted.
for _, origin := range []string{
"file://localhost",
"ftp://localhost:21",
"javascript:alert(1)",
"chrome-extension://abcdefghijklmnop",
} {
if isLoopbackOrigin(origin) {
t.Errorf("%q is not an http(s) origin and must be refused", origin)
}
}
}
func TestMalformedOriginsAreRefused(t *testing.T) {
for _, origin := range []string{
"",
"null", // what a sandboxed iframe sends
"not a url at all",
"://missing-scheme",
} {
if isLoopbackOrigin(origin) {
t.Errorf("%q is not a usable origin and must be refused", origin)
}
}
}

80
docs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,80 @@
# Doormile Backend — Changelog & Summary of Recent Changes
This document provides a comprehensive log of the major features, architectural upgrades, schema modifications, and API changes recently implemented in the `doormile_backend`.
---
## 1. Customer App v1 API Rebuild (`doormile_cx`)
The customer-facing surface was completely rebuilt from the legacy single-destination / PIN-based flow to the production Customer App v1 contract.
### Key Additions & Refactorings:
- **Authentication (`controllers/cxAuthController.go`)**:
- Replaced legacy PIN authentication with 4-digit OTP verification via SMS/Email (`/customer/auth/send-otp`, `/customer/auth/verify-otp`).
- Refresh token rotation and session management (`/customer/auth/refresh`, `/customer/auth/logout`, `/customer/auth/logout-all`).
- Profile management and saved delivery locations (`/customer/profile`, `/customer/locations`).
- **Multi-Destination Booking & Fanout (`controllers/cxBookingController.go`, `controllers/cxPickupFanout.go`)**:
- Support for multi-stop pickups and multi-destination consignments under single parent bookings.
- Pickup fanout algorithm ensuring correct route clustering and assignment creation.
- **Dynamic Fare Engine (`controllers/cxFareController.go`)**:
- Automated distance-based, weight-tiered, and peak-hour pricing calculations (`/customer/fare/estimate`).
- **Stage Rollup & Tracking Lifecycle (`internal/cxstage/stage.go`, `controllers/cxBookingView.go`)**:
- Unified lifecycle status machine mapping complex internal consignment and hub states to simplified customer stages (`created`, `assigned`, `arrived`, `picked_up`, `at_hub`, `out_for_delivery`, `delivered`, `cancelled`).
- Strict 5-minute cancellation window enforced server-side.
- **Public Reference Obfuscation (`controllers/cxIdentifiers.go`, `controllers/cxIdentifierScramble.go`)**:
- Replaced sequential database ID leaks in public endpoints with collision-resistant Sqids/hash tokens.
- **Catalogue, Serviceability & Places (`controllers/cxCatalogueController.go`, `controllers/cxPlacesController.go`)**:
- Dynamic serviceability limits, slot schedules, and parcel category catalog queries (`/customer/catalogue`, `/customer/serviceability/limits`, `/customer/serviceability/slots`).
- Places autocomplete proxy and reverse geocoding (`/customer/places/autocomplete`, `/customer/places/reverse-geocode`).
- **Push Device Token Registry (`controllers/cxDeviceController.go`)**:
- FCM/APNS device token registration for push notifications (`/customer/devices/register`, `/customer/devices/deregister`).
- **Ops Staging Overrides (`controllers/cxOpsController.go`)**:
- Development and QA testing endpoint for simulating order stage transitions in non-production environments (`POST /ops/bookings/:ref/stage`).
---
## 2. Logistics Base Handover & Hub Routing
- **Logistics Handover (`controllers/logisticsHandoverController.go`)**:
- Handover workflows between milers and logistics bases / hubs.
- Audit logging of parcel check-ins and handoffs.
- **Hub Inbound Processing (`controllers/hubInboundController.go`)**:
- Bag scanning, parcel inwarding, and multi-hub dispatch reconciliation.
- **Leg Optimizer & Routing (`internal/legs/legs.go`, `internal/routing/optimizer.go`)**:
- Multi-hop inter-hub routing and transit leg calculations.
---
## 3. Miler App & Assignment Enhancements
- **Arrival Confirmation Facts**:
- Added support for `reachedat` / `arrivedat` timestamps on booking assignments and miler action payloads.
- **Miler POD S3/Spaces Upload (`internal/storage/spaces.go`)**:
- Presigned upload URL generation (`/miler/uploads/sign`) allowing milers to upload Proof of Delivery photos directly to object storage.
- **Tenant Context**:
- Exposed `tenantname` in `verify-pin` and miler profile responses.
- **Consignment Status Check Expansion**:
- Updated database checks to permit `Collected_By_Miler` and `Cancelled` statuses.
---
## 4. Middleware, Observability & Core Utilities
- **Request ID & Structured Logging (`middlewares/requestid.go`, `middlewares/logger.go`)**:
- Correlation IDs attached to incoming requests and structured log entries.
- **Epoch Timestamp Conversions (`utils/epoch.go`)**:
- Standardized millisecond/second epoch converters for unified JSON responses.
- **Database Migrations & Models (`models/customer_app.go`, `migrations/migrate.go`)**:
- Database schema migrations for customer auth tokens, devices, OTP logs, and extended booking columns.
---
## 5. Comprehensive Test Suite
Added automated test suites covering all new and modified packages:
- `controllers/cxCustomerApp_test.go` — Customer auth, booking creation, and validation tests.
- `controllers/cxHttp_test.go` — End-to-end HTTP endpoint tests.
- `routes/routes_customer_test.go` — Route registration and regression guards.
- `internal/cxstage/stage_test.go` — Lifecycle state rollup logic and cancellation-window enforcement.
- `utils/epoch_test.go` — Epoch timestamp validation.
- `controllers/logisticsHandover_test.go` & `controllers/logisticsRouting_test.go` — Handover and routing tests.

View File

@@ -13,8 +13,14 @@ things a new dev (or a fresh Claude session on another machine) needs:
Related docs already in this repo:
- [`CLAUDE.md`](../CLAUDE.md) — the full project memory (architecture, data
model, route surface). Start there for *what the system is*.
- [`docs/CHANGELOG.md`](CHANGELOG.md) — summary of recent backend changes and new endpoints.
- [`docs/customer-app-api.md`](customer-app-api.md) — Customer App v1 API design and endpoint specs.
- [`docs/customer-api-testing.md`](customer-api-testing.md) — step-by-step test guide and curl references for customer endpoints.
- [`docs/openapi-customer.yaml`](openapi-customer.yaml) — OpenAPI 3.0 specification for customer API.
- [`docs/doormile-flow.md`](doormile-flow.md) — end-to-end booking/assignment flow.
- [`docs/miler-app-api.md`](miler-app-api.md), [`docs/express-console-api.md`](express-console-api.md) — API contracts.
- [`docs/logistics-base-handover.md`](logistics-base-handover.md) — the pickup-source
and base-handover flow (requests 25–31), including the state transitions.
- [`docs/jupiter2doormile.md`](jupiter2doormile.md) — the legacy→new migration map.
- [`docs/test-booking-runbook.md`](test-booking-runbook.md) — how to run a test booking.
- [`skills.md`](../skills.md) — note on the installed Claude skill pack.
@@ -133,6 +139,13 @@ scope fits; don't let a broad trigger override house style.
- **Known live flag:** `MILER_COLLECTED_STATE_ENABLED=true` (hyperlocal two-step
pickup). `TRUSTED_PROXIES` must be set (api.doormile.com sits behind a
reverse proxy) or per-IP rate limits collapse all clients into one bucket.
- **`MILER_HUB_HANDOVER_ENABLED` — default off, and must stay off** until a rider
build that calls `POST /miler/consignments/:id/inward-at-hub` is live. On, a
hub-routed parcel stops at `Created` until the rider records the handover; off,
pickup-complete marks it `Inwarded_at_Hub` immediately, which is what the
deployed app expects. Flipping it early strands every intercity parcel on
`Created` with no button in the app to advance it and no row in any base's
received list. See [`logistics-base-handover.md`](logistics-base-handover.md).
### 2.4 Timezone convention (subtle — read before touching any time field)
The DB and backend time helpers run on **IST (Asia/Kolkata) wall-clock**.
@@ -146,7 +159,10 @@ Redis zset scores and time-window queries by 5h30m.
Status-column CHECK constraints are **not** created by GORM AutoMigrate — they
predate this codebase. Adding a new status *constant* in Go is not enough; the
DB rejects it with **SQLSTATE 23514**. Before adding any status enum value,
widen the matching `*_status_check` constraint in `migrations/migrate.go`.
widen the matching `*_status_check` constraint in `migrations/migrate.go`. This
is also why the base-handover reconciliation path raises a `Lost` exception
rather than a more precise `Handover_Not_Received` — the latter would need
`consignmentexceptions` widened first.
Constraints exist on: `consignments`, `pickupbookings`,
`milerprofiles.availabilitystatus`, `bookingassignments`,
`consignmentexceptions`, `tripsheets`. `consignmenthistory` has no status check.

View File

@@ -0,0 +1,392 @@
# Customer API — file reference & test guide
Companion to [`customer-app-api.md`](customer-app-api.md), which explains *why*.
This one is the flat reference: what each file is for, and how to call every
endpoint.
**Base URL:** `https://api.doormile.com/api/v1`
**Staging:** `https://staging-api.doormile.com/api/v1`
---
## 1. What `seed_customer_app.sql` is for
**It is the only thing that makes the booking form work.** The customer app's
first four calls read from tables that ship empty, so without this file the app
opens to a blank state picker and nothing can be booked.
Run it **once, after the Go service has started at least once** — the service
runs `AutoMigrate` on boot (`main.go:97`), and this file fills the tables that
creates. Running it before the first boot fails: the tables do not exist yet.
```bash
psql "postgres://admin:PASSWORD@HOST:5433/logistics" -f seed_customer_app.sql
```
It is **idempotent** — safe to re-run. Every insert is `ON CONFLICT DO UPDATE`
or guarded by `WHERE NOT EXISTS`.
### What it inserts, and why each part matters
| Rows | Purpose |
|---|---|
| **5 states** (TN, KL, KA, TG, PY) | `GET /serviceability/states`. Puducherry is seeded serviceable with **no open district** on purpose — it is the only way to exercise the app's "Opening soon" screen. |
| **16 districts** | `GET /serviceability/states/{code}/districts`. Madurai, Kozhikode and Dakshina Kannada are seeded **unavailable with a reason**, because the app renders those names in a "coming soon" line. Each carries `centrelatitude`/`centrelongitude` — for most destinations that is the *only* geography the parcel has until the miler corrects it at the door, and it is what the fare estimate is priced against. |
| **6 pickup slot templates** | `GET /pickup-slots`. Template `t5` is seeded at **capacity 0** — the only way to reach the "Fully booked" state in design QA without actually filling a window. |
| **1 booking-limits row** | `GET /config/booking-limits`. **`maxdestinations` is seeded at 1, not 5** — see §4 below. |
| **2 test customers** | `+919999900001`, `+919999900002`. Pair with `CX_STAGING_OTP=1234` so automated tests can sign in without a real handset. |
| **A hub-attachment UPDATE** | Links each district to its nearest active hub by proximity, so the destination card can name a serving base. Done by distance rather than hard-coded ids because hub ids differ per environment. |
It also **mirrors the client's in-app mock exactly**, because those cases back
the app's 18 widget tests and its design QA. Change the seeded availability and
a green client test suite stops meaning anything.
---
## 2. Changed files — what each one is for
### New files
| File | What it is for |
|---|---|
| `models/customer_app.go` | The 9 new tables. `BookingDestination` is the one that makes multi-destination expressible. |
| `internal/cxstage/stage.go` | Writes the customer's stage timeline from the miler's operational writes. The one place a stage is recorded. |
| `internal/sms/sms.go` | The seam for an SMS gateway. **No provider is wired in** — codes go to the log until one is. |
| `controllers/cxAuthController.go` | OTP auth: request, signup, verify, refresh, logout, me. |
| `controllers/cxCatalogueController.go` | States, districts, pickup slots, booking limits. |
| `controllers/cxPlacesController.go` | Geocoder proxy + Redis cache. The app is never handed a map key. |
| `controllers/cxFareController.go` | Fare estimate for one pickup visit. |
| `controllers/cxBookingController.go` | Create, list, detail, cancel, patch destination, order-by-tracking-id. |
| `controllers/cxBookingView.go` | Builds the canonical booking JSON. Every read that returns a booking goes through it. |
| `controllers/cxPickupFanout.go` | Splits one pickup into N orders at pickup-complete. |
| `controllers/cxConsignmentHooks.go` | Maps a consignment status to a per-order customer stage. |
| `controllers/cxDeviceController.go` | Push token registration. |
| `controllers/cxIdentifiers.go` | Mints `DM-######` and `DMX########` from Postgres sequences. |
| `controllers/cxOpsController.go` | QA-only: force a booking to any stage. Double-gated. |
| `utils/response_cx.go` | The customer response envelope. Separate from `utils.OK`/`Fail` on purpose. |
| `utils/epoch.go` | IST→epoch-millis conversion. Every customer timestamp goes through it. |
| `middlewares/requestid.go` | Echoes `X-Request-Id` on every response. |
| `seed_customer_app.sql` | §1 above. |
| `docs/openapi-customer.yaml` | The spec — 24 paths, 28 operations. |
| `docs/customer-app-api.md` | The change record and reasoning. |
| `scratch/cx_readonly_probe.go` | Read-only check of whether the migration is additive against a real DB. Writes nothing. |
| `controllers/cxHttp_test.go` | HTTP status-code and envelope tests. |
| `controllers/cxCustomerApp_test.go` | Pure-logic and fan-out tests. |
| `routes/routes_customer_test.go` | Routing + the guard proving miler/console are untouched. |
| `internal/cxstage/stage_test.go`, `utils/epoch_test.go` | Stage rollup and timestamp tests. |
### Modified files
| File | What changed |
|---|---|
| `routes/routes.go` | Customer routes 19 → 28. |
| `controllers/customerController.go` | PIN auth + old booking handlers **deleted**; profile and locations kept. |
| `controllers/milerController.go` | Pickup-complete now fans out; parcel-confirm takes photos; stage hooks. |
| `controllers/milerAppController.go` | Rider queue emits one stop per order; two broken lookups fixed. |
| `controllers/logisticsHandoverController.go` | Consignment→booking lookup fixed; `in_transit` recorded. |
| `controllers/adminController.go` | Ops cancel now reaches the customer's projection. |
| `controllers/booking_assignment_service.go` | Records `assigned` on manual assignment. |
| `internal/assignment/crm_assignment.go` | Records `assigned` on auto-assignment. |
| `models/booking.go` | 10 new columns on `PickupBooking`, 1 on `BookingParcel`. |
| `constants/constants.go` | 9 stage keys, statuses, actor types. |
| `migrations/migrate.go` | 9 tables, 2 sequences, 2 indexes. |
| `middlewares/idempotency.go` | Anonymous-caller scoping (security fix). |
| `middlewares/city_gate.go` | Exported `PincodeInOperatingCity`. |
| `middlewares/logger.go` | Logs `client`, `platform`, `requestid`. |
| `internal/storage/spaces.go` | `PresignGet` for signed parcel photos. |
| `config/config.go` | `GEOCODER_URL`, `GEOCODER_EMAIL`. |
| `utils/helper.go` | `GenerateTokenWithTTL`. |
| `main.go` | Registers `RequestID()`. |
| `dto/auth.go` | Retired PIN DTOs removed. |
| `controllers/otpController.go` | **Deleted** — its two routes were customer-only and are superseded. |
---
## 3. Every endpoint, with a request you can paste
Every URL below is complete — copy and paste it. Production host shown; for
staging swap `api.doormile.com` for `staging-api.doormile.com`, nothing else
changes.
Authenticated calls need one header, using the `accessToken` returned by
`/customer/auth/otp/verify`:
```
Authorization: Bearer <accessToken>
```
### 3.1 Auth — no token required
**1. Request a sign-in code**
```
POST https://api.doormile.com/api/v1/customer/auth/otp/request
```
```json
{ "identifier": "+919999900001" }
```
**2. Sign up (creates the account AND sends the code)**
```
POST https://api.doormile.com/api/v1/customer/auth/signup
```
```json
{ "name": "Joe Oommen", "phone": "+919876543210", "email": "joe@example.com" }
```
**3. Verify the code → returns the session**
```
POST https://api.doormile.com/api/v1/customer/auth/otp/verify
Header: Idempotency-Key: <any unique string>
```
```json
{ "identifier": "+919999900001", "code": "1234" }
```
> On staging `code` is whatever `CX_STAGING_OTP` is set to. Copy `data.accessToken`
> from the response — everything below needs it.
**4. Rotate the session**
```
POST https://api.doormile.com/api/v1/customer/auth/refresh
```
```json
{ "refreshToken": "<refreshToken from verify>" }
```
**5. Who am I** — `GET https://api.doormile.com/api/v1/customer/auth/me` (token required, no body)
**6. Sign out**
```
POST https://api.doormile.com/api/v1/customer/auth/logout
```
```json
{ "refreshToken": "<token>", "deviceToken": "<fcm token>" }
```
### 3.2 Catalogue — no token required
| # | Method | URL |
|---|---|---|
| 7 | GET | `https://api.doormile.com/api/v1/customer/serviceability/states` |
| 8 | GET | `https://api.doormile.com/api/v1/customer/serviceability/states/TN/districts` |
| 9 | GET | `https://api.doormile.com/api/v1/customer/pickup-slots?lat=11.0168&lng=76.9558` |
| 10 | GET | `https://api.doormile.com/api/v1/customer/config/booking-limits?lat=11.0168&lng=76.9558` |
No bodies. Call **9** first in any booking test — you need a real `slotId` from
it, and slot ids expire.
### 3.3 Places — token required
| # | Method | URL |
|---|---|---|
| 11 | GET | `https://api.doormile.com/api/v1/customer/places/reverse-geocode?lat=11.0168&lng=76.9558` |
| 12 | GET | `https://api.doormile.com/api/v1/customer/places/search?q=brookefields&lat=11.0168&lng=76.9558` |
Empty `q` on **12** returns the customer's saved and recent places.
### 3.4 Fare estimate — token required
**13.**
```
POST https://api.doormile.com/api/v1/customer/fare/estimate
```
```json
{
"pickup": { "lat": 11.0168, "lng": 76.9558 },
"destinations": [
{ "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 2 },
{ "stateCode": "KL", "districtCode": "KL-EKM", "packageCount": 1 }
]
}
```
### 3.5 Bookings — token required
**14. Create a pickup**
```
POST https://api.doormile.com/api/v1/customer/bookings
Header: Idempotency-Key: <any unique string>
```
```json
{
"pickup": {
"title": "12 Nehru Street",
"sub": "Gandhipuram, Coimbatore 641012",
"lat": 11.0168,
"lng": 76.9558
},
"slotId": "PASTE_A_REAL_ID_FROM_ENDPOINT_9",
"destinations": [
{
"stateCode": "TN",
"districtCode": "TN-MAA",
"packageCount": 2,
"details": {
"street": "12th Main",
"building": "3B",
"landmark": "Near bus stand",
"recipientName": "Meera S",
"recipientPhone": "+919884412210",
"instructions": "Call before delivery",
"codAmount": 1200,
"pin": { "lat": 13.0827, "lng": 80.2707 }
}
}
],
"estimate": { "min": 167, "max": 267 }
}
```
> ⚠️ **One destination only** while `maxdestinations = 1` (§4). Adding a second
> returns `400 "Up to 1 destinations per pickup"`.
> Returns `reference` (`DM-######`) and **no tracking number** — those are minted
> when the miler completes the pickup.
**15. List** — `GET https://api.doormile.com/api/v1/customer/bookings?status=active&limit=20`
`status` is `active` | `completed` | `cancelled`. Page with `&cursor=<nextCursor>`.
**16. Detail** — `GET https://api.doormile.com/api/v1/customer/bookings/DM-482913`
The tracking screen and receipt both render from this. Supports `If-None-Match`.
**17. Cancel**
```
POST https://api.doormile.com/api/v1/customer/bookings/DM-482913/cancel
```
```json
{ "reason": "Package not ready" }
```
> Allowed through `arrived`. From `picked_up` onward returns `409`.
**18. Fill in a destination's details after booking**
```
PATCH https://api.doormile.com/api/v1/customer/bookings/DM-482913/destinations/0
```
```json
{
"street": "12th Main",
"landmark": "Near bus stand",
"recipientName": "Meera S",
"recipientPhone": "+919884412210",
"instructions": "Call before delivery",
"pin": { "lat": 13.08, "lng": 80.27 }
}
```
> `0` is the destination's position. Any subset of fields; `null` clears one.
**19. One order by tracking number** — `GET https://api.doormile.com/api/v1/customer/orders/DMX10482913`
### 3.6 Devices — token required
**20. Register a push token**
```
POST https://api.doormile.com/api/v1/customer/devices
```
```json
{ "token": "<fcm-token>", "platform": "android", "appVersion": "1.0.0+12" }
```
**21. Unregister** — `DELETE https://api.doormile.com/api/v1/customer/devices/<fcm-token>`
### 3.7 Account — token required
**22. Profile** — `GET https://api.doormile.com/api/v1/customer/profile`
**23. Update profile**
```
PUT https://api.doormile.com/api/v1/customer/profile
```
```json
{ "name": "Joe Oommen", "email": "joe@example.com", "defaultPincode": "641012" }
```
**24. Saved addresses** — `GET https://api.doormile.com/api/v1/customer/locations`
**25. Save an address**
```
POST https://api.doormile.com/api/v1/customer/locations
```
```json
{
"label": "Home",
"address": "12 Nehru Street, Gandhipuram",
"landmark": "Near the bus stand",
"city": "Coimbatore",
"state": "Tamil Nadu",
"pincode": "641012",
"latitude": 11.0168,
"longitude": 76.9558,
"receivername": "Joe Oommen",
"receiverphone": "+919876543210",
"isdefault": true
}
```
**26. Update an address** — `PUT https://api.doormile.com/api/v1/customer/locations/1` (same body)
**27. Delete an address** — `DELETE https://api.doormile.com/api/v1/customer/locations/1`
### 3.8 QA only — staging
**28. Force a booking to any stage**
```
POST https://api.doormile.com/api/v1/customer/ops/bookings/DM-482913/stage
```
```json
{ "stage": "out_for_delivery", "reason": "design QA" }
```
> Returns `404` unless **both** `ENV != production` **and**
> `CX_ALLOW_STAGE_OVERRIDE=true`. Valid stages: `booked`, `assigned`,
> `on_the_way`, `arrived`, `picked_up`, `order_created`, `in_transit`,
> `out_for_delivery`, `delivered`.
---
## 4. Before you test — two things that will bite you
**`maxdestinations` is seeded at 1.** Not a bug. The fan-out works server-side,
but the deployed rider app keys its stop list on `orderid`, which is
booking-level — all three stops of a three-destination pickup collapse into one
in its local store, and two parcels would have no stop and no way to be closed.
Raise it only once a rider build keying on `consignmentid` is live:
```sql
UPDATE customerbookinglimits SET maxdestinations = 5 WHERE applocationid IS NULL;
```
**There is no SMS provider.** OTP codes are written to the application log and
nowhere else. On staging set `CX_STAGING_OTP=1234` and use the seeded test
accounts. It is refused when `ENV=production`.
---
## 5. Suggested test order
1. `GET /serviceability/states` — proves the seed ran
2. `GET /serviceability/states/TN/districts`
3. `GET /pickup-slots?lat=&lng=` — **copy a real `slotId`**
4. `GET /config/booking-limits` — confirm `maxDestinations: 1`
5. `POST /auth/otp/request` with `+919999900001`
6. `POST /auth/otp/verify` with the staging code — **copy `accessToken`**
7. `GET /auth/me`
8. `POST /fare/estimate`
9. `POST /bookings` with the slot id from step 3
10. `GET /bookings/{reference}` — the canonical object
11. `PATCH /bookings/{reference}/destinations/0`
12. `POST /ops/bookings/{reference}/stage` → `delivered`, then re-read step 10
13. `POST /bookings/{reference}/cancel` on a fresh booking → expect `409` after
`picked_up`, `200` before
## 6. Reading the responses
Success:
```json
{ "success": true, "data": { ... }, "message": "" }
```
List:
```json
{ "success": true, "data": [ ... ], "total": 9, "nextCursor": "1042", "message": "" }
```
Failure:
```json
{ "success": false, "message": "Add at least one destination", "error": { "code": "invalid" } }
```
Branch on `error.code`, never on the message text. Codes: `invalid`,
`invalid_name`, `invalid_otp`, `unauthorized`, `forbidden`, `not_found`,
`conflict`, `unserviceable`, `rate_limited`, `server_error`.

View File

@@ -0,0 +1,291 @@
# Doormile Customer App (`doormile_cx`) API — Quick Reference
> **Base URL:** `https://api.doormile.com/api/v1`
> **Namespace:** `/customer/*` | **Auth Role:** `9` (Customer) | **Data Envelope:** `{ "success": true, "data": { ... } }`
---
## 1. Global Conventions
| Aspect | Specification | Details / Rules |
|---|---|---|
| **Naming** | `camelCase` | All request & response JSON fields use camelCase. |
| **Timestamps** | Epoch milliseconds (UTC, `int64`) | Parse directly with `DateTime.fromMillisecondsSinceEpoch(ts)`. |
| **Identifiers** | Sequence-backed Feistel permutation | **Booking Reference:** `DM-482913`<br>**Tracking Number:** `DMX10482913` |
| **Headers** | `Authorization: Bearer <accessToken>`<br>`X-Client: doormile-cx/<version>+<build>`<br>`X-Platform: android | ios`<br>`Idempotency-Key: <uuid>` | • Required on all routes except pre-auth & catalogue.<br>• Client telemetry logged on every request.<br>• Idempotency supported on booking creation & OTP verification. |
| **Token Lifetime** | Access: `1 hour` \| Refresh: `60 days` | Refresh tokens rotate on every use. Replaying a revoked token revokes the entire chain. |
---
## 2. Complete Endpoint Reference (28 Routes)
### 🔐 Authentication (Pre-Auth & Session)
| Method | Endpoint | Auth | Description |
|---|---|:---:|---|
| `POST` | `/customer/auth/otp/request` | ❌ | Request OTP via SMS/Email (returns uniform success regardless of account existence). |
| `POST` | `/customer/auth/otp/verify` | ❌ | Verify OTP, returns JWT tokens & customer profile. |
| `POST` | `/customer/auth/signup` | ❌ | Register new customer or treat existing phone as sign-in. |
| `POST` | `/customer/auth/refresh` | ❌ | Rotate refresh token to issue a new access token. |
| `POST` | `/customer/auth/logout` | ✅ | Revoke session (omit `refreshToken` to sign out everywhere). |
| `GET` | `/customer/auth/me` | ✅ | Fetch currently authenticated customer identity. |
### 📦 Serviceability & Catalogue
| Method | Endpoint | Auth | Description |
|---|---|:---:|---|
| `GET` | `/customer/serviceability/states` | ❌ | List serviceable states (`ETag` / 304 supported). |
| `GET` | `/customer/serviceability/states/:code/districts` | ❌ | List serviceable districts in state (`ETag` / 304). |
| `GET` | `/customer/pickup-slots` | ❌ | List today's & tomorrow's time slots with zone capacity (`ETag` / 304). |
| `GET` | `/customer/config/booking-limits` | ❌ | Get booking limits (`maxDestinations`, `maxPackages`, `maxCodAmount`). |
### 📍 Places & Geocoding
| Method | Endpoint | Auth | Description |
|---|---|:---:|---|
| `GET` | `/customer/places/search?q=:query&lat=:lat&lng=:lng` | ✅ | Place search / autocomplete (empty query returns recent/saved). |
| `GET` | `/customer/places/reverse-geocode?lat=:lat&lng=:lng` | ✅ | Reverse geocode coordinates to structured address. |
### 💰 Fare Estimation
| Method | Endpoint | Auth | Description |
|---|---|:---:|---|
| `POST` | `/customer/fare/estimate` | ✅ | Compute estimated fare band (`minRupees`–`maxRupees`) & route distance. |
### 🚚 Bookings & Tracking
| Method | Endpoint | Auth | Description |
|---|---|:---:|---|
| `POST` | `/customer/bookings` | ✅ | Create multi-destination pickup booking (`Idempotency-Key` supported). |
| `GET` | `/customer/bookings?limit=20&cursor=:cursor&status=active` | ✅ | Keyset paginated customer bookings list. |
| `GET` | `/customer/bookings/:reference` | ✅ | Get full booking detail & live tracking snapshot (`ETag` supported). |
| `POST` | `/customer/bookings/:reference/cancel` | ✅ | Cancel booking (allowed strictly before rider status `arrived`). |
| `PATCH` | `/customer/bookings/:reference/destinations/:index` | ✅ | Update recipient details on a pending destination stop. |
| `GET` | `/customer/orders/:trackingId` | ✅ | Look up single order/parcel status by tracking number. |
### 👤 Profile & Saved Locations
| Method | Endpoint | Auth | Description |
|---|---|:---:|---|
| `GET` | `/customer/profile` | ✅ | Get customer profile details. |
| `PUT` | `/customer/profile` | ✅ | Update customer name/email. |
| `GET` | `/customer/locations` | ✅ | List up to 10 saved delivery/pickup addresses. |
| `POST` | `/customer/locations` | ✅ | Save a new location. |
| `PUT` | `/customer/locations/:id` | ✅ | Update an existing saved location. |
| `DELETE` | `/customer/locations/:id` | ✅ | Delete a saved location. |
### 🔔 Devices & Push Notifications
| Method | Endpoint | Auth | Description |
|---|---|:---:|---|
| `POST` | `/customer/devices` | ✅ | Register FCM device token for milestone push updates. |
| `DELETE` | `/customer/devices/:token` | ✅ | Unregister device token on logout. |
### 🛠️ Ops QA Testing (Non-Production Only)
| Method | Endpoint | Auth | Description |
|---|---|:---:|---|
| `POST` | `/customer/ops/bookings/:reference/stage` | ✅ | Double-gated test helper to walk a booking through stages. |
---
## 3. Core Request & Response Payloads
### 1) OTP Verification (`POST /customer/auth/otp/verify`)
```json
// Request
{
"identifier": "+919876543210",
"otp": "1234"
}
// Response (200 OK)
{
"success": true,
"data": {
"accessToken": "eyJhbGciOi...",
"refreshToken": "d8f1e2a3...",
"expiresIn": 3600,
"customer": {
"id": 1042,
"name": "Alex Kumar",
"phone": "+919876543210",
"email": "alex@example.com"
}
}
}
```
### 2) Fare Estimate (`POST /customer/fare/estimate`)
```json
// Request
{
"pickup": {
"latitude": 13.0827,
"longitude": 80.2707,
"stateCode": "TN",
"districtCode": "CHN"
},
"destinations": [
{
"stateCode": "TN",
"districtCode": "CHN",
"packages": [{ "weightKg": 2.5 }]
},
{
"stateCode": "KA",
"districtCode": "BLR",
"packages": [{ "weightKg": 1.0 }]
}
]
}
// Response (200 OK)
{
"success": true,
"data": {
"minRupees": 240,
"maxRupees": 310,
"routeKm": 348.5,
"breakdown": {
"baseFare": 180,
"additionalStopsUplift": 60,
"estimatedTax": 0
}
}
}
```
### 3) Booking Creation (`POST /customer/bookings`)
```json
// Request
{
"slotId": "slot_20260908_t2",
"pickup": {
"title": "Home",
"sub": "Flat 4B, Green Towers, Anna Nagar",
"latitude": 13.0827,
"longitude": 80.2707,
"contactName": "Alex Kumar",
"contactPhone": "+919876543210"
},
"destinations": [
{
"recipientName": "Priya S",
"recipientPhone": "+919840123456",
"building": "12/A",
"street": "MG Road",
"landmark": "Near Metro",
"districtCode": "CHN",
"stateCode": "TN",
"latitude": 13.0850,
"longitude": 80.2100,
"packageCount": 1,
"codAmount": 450
}
],
"remarks": "Handle with care"
}
// Response (201 Created)
{
"success": true,
"data": {
"reference": "DM-482913",
"stage": "booked",
"status": "active",
"cancellable": true,
"createdAt": 1788775499000,
"slotId": "slot_20260908_t2",
"pickup": {
"title": "Home",
"sub": "Flat 4B, Green Towers, Anna Nagar",
"latitude": 13.0827,
"longitude": 80.2707
},
"destinations": [
{
"index": 0,
"stateName": "Tamil Nadu",
"districtName": "Chennai",
"packageCount": 1,
"codAmount": 450,
"trackingId": null,
"stage": null
}
]
}
}
```
---
## 4. Lifecycle & Stage Machine
### Stage Progression Sequence
```
[ booked ] ──► [ assigned ] ──► [ arrived ] ──► [ picked_up ] ──► [ order_created ]
│ (cancel window closes)
▼
[ delivered ] ◄── [ out_for_delivery ] ◄── [ in_transit ]
```
### Stage Vocabulary
| Stage Key | Meaning / Trigger | Cancellable? | Scope |
|---|---|:---:|---|
| `booked` | Order submitted by customer | ✅ Yes | Booking |
| `assigned` | Rider assigned to visit | ✅ Yes | Booking |
| `on_the_way` | Rider accepted assignment | ✅ Yes | Booking |
| `arrived` | Rider arrived at pickup point (**cancellation cutoff**) | ❌ No | Booking |
| `picked_up` | Parcels collected and weighed | ❌ No | Booking |
| `order_created` | Tracking IDs generated per destination | ❌ No | Per Destination |
| `in_transit` | Parcels sorted / inwarded at hub | ❌ No | Per Destination |
| `out_for_delivery` | Dispatched with delivery agent | ❌ No | Per Destination |
| `delivered` | Successfully delivered to recipient | ❌ No | Per Destination |
| `cancelled` | Cancelled by customer or ops before arrival | — | Terminal |
> [!IMPORTANT]
> **Rollup Rule:** The overall booking stage reflects the **slowest order**. If Destination 1 is `delivered` but Destination 2 is `in_transit`, the booking rollup remains `in_transit`.
---
## 5. Cross-App Impact & Compatibility
| Client / Component | Observable Change | Impact / Handling |
|---|---|---|
| **Miler App (Flutter)** | Multi-stop collection | Post-collection returns multiple stops sharing one `bookingid`. Keys must resolve by `consignmentid`. |
| **Admin Console** | Booking numbers & Search | Display format is now `DM-482913`. Searches match exact substring. |
| **Hub Console** | Tracking IDs & Inbound | Tracking numbers are now `DMX10482913`. |
| **Safety Mitigation** | `maxDestinations` Gate | Configured in DB (`customerbookinglimits.maxdestinations = 1`) to keep fanout single-destination until mobile updates deploy. |
---
## 6. Standard Error Codes & Envelopes
All errors return JSON in standard format:
```json
{
"success": false,
"error": {
"code": "SLOT_UNAVAILABLE",
"message": "That pickup time has passed — pick a new slot"
}
}
```
| HTTP Status | Error Code (`error.code`) | Meaning / Recommended Client Action |
|---|---|---|
| `400` | `INVALID_INPUT` / `SLOT_EXPIRED` | Validation error or expired slot date. Prompt user to re-select. |
| `401` | `UNAUTHORIZED` | Token missing or expired. Redirect to OTP login / refresh session. |
| `403` | `FORBIDDEN` | Caller lacks role 9 customer access. |
| `404` | `NOT_FOUND` | Booking reference or tracking ID does not exist. |
| `409` | `SLOT_CAPACITY_FULL` | Slot filled up during checkout race. Prompt user to choose another time. |
| `409` | `BOOKING_NOT_CANCELLABLE` | Customer attempted cancel after rider `arrived`. Show un-cancellable alert. |
| `422` | `UNSERVICEABLE_PINCODE` | Location is outside active operating zones. |
| `429` | `RATE_LIMITED` | OTP requests exceeded limit (max 5/hour). Show countdown timer. |
| `500` | `INTERNAL_ERROR` | Generic server error (`X-Request-Id` logged). |
---
## 7. Environment Variables & Deploy Flags
```bash
GEOCODER_URL=https://nominatim.openstreetmap.org # Geocoding proxy
GEOCODER_EMAIL=ops@doormile.com # Nominatim contact policy
MILER_CALL_PROXY= # Set to proxy number in production (protects rider PII)
CX_STAGING_OTP=1234 # Fixed OTP for staging QA (disabled if ENV=production)
CX_ID_SCRAMBLE_KEY= # Optional custom key for Feistel sequence permutation
CX_ALLOW_STAGE_OVERRIDE=false # Double-gated QA stage override tool
```

928
docs/customer-app-api.md Normal file
View File

@@ -0,0 +1,928 @@
# Customer App API (`doormile_cx`) — change record & integration guide
**Audience:** the `doormile_cx` app developer, plus anyone reviewing this before
it goes to production.
**Backend:** Go + Fiber, `api.doormile.com/api/v1`, namespace `/customer/*`.
**Status:** code complete, compiler- and unit-verified, **not yet run against a
real database**. Read §11 before deploying.
| | |
|---|---|
| OpenAPI 3.1 spec | [`openapi-customer.yaml`](openapi-customer.yaml) |
| Staging seed | [`../seed_customer_app.sql`](../seed_customer_app.sql) |
| Requirements this answers | *Doormile — Backend Requirements (Customer App v1)* |
| Reviewed | Once, by a reviewer with the Flutter apps but not this repo — **§13** records every finding and what changed |
---
## 0. The one question first: does this affect the Miler app or the consoles?
# YES.
Not "no". Anyone telling you otherwise has not read the diff. The customer work
could not be built without touching shared code, because the thing it changes —
one pickup producing many orders — is a fact the rider app and the consoles both
have to cope with.
Here is the complete list, with severity. Nothing is omitted.
### 🔴 Behaviour changes those clients WILL observe
| # | What changed | Who sees it | Action needed |
|---|---|---|---|
| 1 | **Booking references are now `DM-482913`**, not `DM-BK-A1B2C3D4-48291`. New bookings only; existing rows keep their old strings. | Admin console, hub console, Miler app — anywhere a booking number is displayed | None in code (nothing parses the format). Tell ops the format changed. |
| 2 | **Tracking numbers are now `DMX10482913`**, not `DM-TRK-A1B2C3D4-48291`. New consignments only. | Same as above, plus any printed label or customer-facing tracking link | Same. Check label templates for a hardcoded width. |
| 3 | **`GET /miler/bookings` can return more than one row per booking.** A customer-app pickup with 3 destinations becomes 3 stops *after* collection — each with its own `consignmentid`, `trackingno`, address and COD. Before collection it is still 1 row. Console/express bookings are still always 1 row. | Miler app | **A multi-file change, not a one-liner. See §0.6.** |
| 4 | **`POST /miler/bookings/{id}/pickup-complete` gained a `consignments` array** listing every order the pickup produced. All previous top-level fields still describe the first order, unchanged. | Miler app | Purely additive — safe to ignore, but a multi-destination build should read it. |
| 5 | **`POST /miler/bookings/{id}/parcel` accepts an optional `photos: []` per parcel** (storage keys from `/miler/uploads/sign`). Response shape unchanged. | Miler app | Optional. Without photos the customer receipt shows a weight and no evidence. |
| 6 | **`PickupBooking` JSON gained 10 fields** (`slotid`, `customerstage`, `customerstatus`, `estimateminrupees`, `estimatemaxrupees`, `routekm`, `pickuptitle`, `pickupsub`, `cancelreason`, `destinations`) and `BookingParcel` gained `bookingdestinationid`. These serialise on every admin/hub endpoint that returns the model raw. | Admin + hub consoles | Additive JSON. Safe for a JS client that reads named keys. |
| 7 | **Every response now carries an `X-Request-Id` header.** | Everyone | None. Log it — it is how a support report gets correlated. |
| 8 | **Unknown paths under `/customer/*` answer `401`, not `404`.** So the retired `doormile_customer_app` calling `POST /customer/login` gets `401 "authorization header is required"`. | The old customer app | Expected — that app is being retired. Pre-existing Fiber group behaviour, not introduced here. |
### 🟡 Internal changes with no client-visible contract change
| # | What changed | Why it is safe |
|---|---|---|
| 9 | Three miler endpoints now resolve a consignment through `bookingdestinations` instead of `pickupbookings.consignmentid` | Bug fix. Identical result for a single-destination booking; the old query returned *nothing* for orders 2..N. See §5. |
| 10 | `assignMilerTx` and `commitAssignment` record a customer stage | Guarded: returns immediately for any booking whose source is not `Customer_App`. An express booking does zero extra writes. |
| 11 | `AcceptMilerAssignment`, `BookingReachedCustomer`, `MilerCancelAssignment`, `MilerStartDelivery`, `MilerInwardConsignmentAtHub`, `MilerDeliverConsignment` record customer stages | Same guard. Response bodies unchanged. |
| 12 | `middlewares.Idempotency` scopes anonymous callers differently | Authenticated key format is **byte-identical** to before (`idem:<uid>:<key>`), so no in-flight rider key is orphaned at deploy. See §5.4. |
| 13 | Request logs gained `client`, `platform`, `requestid` fields | Log-only. |
| 14 | `utils.GenerateToken` delegates to a new `GenerateTokenWithTTL` | Still 24h for miler/admin/hub. Only the customer surface passes a different TTL. |
| 15 | `internal/storage` gained `PresignGet` | New function; nothing existing calls it. |
| 16 | `middlewares/city_gate.go` gained an exported `PincodeInOperatingCity` | New function; `CityGateMiddleware` itself is unchanged. |
| 17 | 9 new tables + 11 new columns via `AutoMigrate`; 2 sequences, 2 indexes | All additive and nullable/defaulted. See §11 for the deploy note. |
### 🟢 Deliberately NOT changed, though I was tempted
Two things I changed while building, then **reverted**, because they were miler
behaviour and out of scope:
- **Rider availability after pickup-complete.** I had made a rider stay
`Picked_Up` when a hyperlocal parcel goes straight to `Out_for_Delivery`. The
endpoint has always set them `Available` there. The existing code comment says
the opposite of what the code does — a pre-existing inconsistency I have
flagged rather than silently "fixed", because changing it alters live rider
availability and the dispatch pool. **Ops decision, not mine.**
- **`POST /miler/consignments/{id}/deliver` error status.** I had changed a
"not yours" failure from `404` to `403` for consistency with start-delivery.
Reverted: the deployed rider app was built against `404` and its exact
messages. Only the broken *lookup* is fixed.
---
## 0.5 Page-by-page: exactly which screens are affected
Read from the client source in this workspace (`doormile_crm` = admin console,
`doormile_hub_console` = hub console), not inferred from the API. The Miler app
is not in this workspace, so its rows are derived from the endpoints it calls and
must be confirmed against the Flutter source.
Legend: 🔴 needs a code change · 🟡 visible change, no code change · 🟢 fixed by
this work · ⚪ no impact
### Miler app (Flutter — verify against the app source)
| Screen | Endpoint behind it | Impact |
|---|---|---|
| **Home / my stops** | `GET /miler/bookings` | 🔴 **A pickup can now be several rows.** After collection, a 3-destination customer booking returns 3 stops sharing one `bookingid`. **Key the list on `consignmentid`.** New fields `destinationseq` / `destinationcount` give you "Stop 2 of 3", and `trackingno`, `recipientname`, `recipientphone` are now per stop. Before collection it is still exactly 1 row, and console/express bookings are always 1 row. |
| **Pickup — complete** | `POST /miler/bookings/{id}/pickup-complete` | 🟡 Response gained `consignments[]` (every order the visit produced). All existing top-level fields still describe the first order and are unchanged, so an old build keeps working. |
| **Pickup — weigh/photograph parcels** | `POST /miler/bookings/{id}/parcel` | 🟡 Each parcel now accepts an optional `photos: []` of storage keys from `/miler/uploads/sign`. Response shape unchanged. Without photos the customer's receipt shows a weight with no evidence behind it. |
| **Delivery — deliver / skip** | `POST /miler/consignments/{id}/deliver` | ⚪ Contract unchanged. The internal lookup was broken for orders 2..N and is fixed; status codes and messages are byte-identical. |
| **Base handover** | `POST /miler/consignments/{id}/inward-at-hub` | ⚪ Contract unchanged; same lookup fix. |
| **Earnings** | `GET /miler/earnings` | 🟢 **Fixed before it shipped.** Customer-app jobs would have recorded ₹0 — see §5.6. |
| **Any screen showing a tracking number** | — | 🟡 `DMX10482913` instead of `DM-TRK-A1B2C3D4-48291`. Ten characters shorter, so nothing overflows. |
| Duty, breaks, notifications, support, profile, bases | — | ⚪ Untouched. |
### Admin console (`doormile_crm`)
Only **one** of its screens reads booking shape at all. Verified by grep across
`src/pages`.
| Screen | File | Impact |
|---|---|---|
| **Orders list** | `src/pages/bookings/Bookings.jsx` | 🟡 Four things: (1) the **Booking No** column now shows `DM-482913` — column is `proportional(1.5)`, the string is shorter, nothing overflows; (2) **search** filters `bookingno` by substring, so typing `DM-BK` finds no new bookings — tell ops; (3) the **Delivery City** column reads the flat `deliverycity`, which is **destination 0 only** — a 3-destination pickup shows "Chennai" and does not hint at the other two; (4) the **Price** column reads `serviceoptions[0].estimatedprice` and 🟢 **would have shown "N/A" for every customer booking** until §5.6 was fixed. |
| **Cancel order** (from the Orders list) | `POST /admin/bookings/{id}/cancel`, `.../bulk-cancel` | 🟢 **Fixed before it shipped.** An ops cancel never reached the customer's app — see §5.7. No console change needed; the fix is backend-side. |
| Dashboard, Tenants, Customers, Pricing, Riders, Reports, Invoices, Team, Settings, Survey, auth & error screens | — | ⚪ None of them read booking or consignment shape. |
### Hub console (`doormile_hub_console`)
| Screen | File | Reads | Impact |
|---|---|---|---|
| **Inbound** | `src/pages/operations/Inbound.jsx` | `trackingno` | 🟡 Tracking-number format change is visible in the table. Destination cell already ellipsises at `maxWidth: 200px`. |
| **Routing** | `src/pages/operations/Routing.jsx` | `trackingno` | 🟡 Format change visible in the package list and the detail card (monospace, `0.85rem`). Search-by-tracking-number still works — it matches the value the API returns, not a pattern. |
| **Order Assignment** | `src/pages/operations/OrderAssignment.jsx` | `bookingid`, `deliveryaddress` | 🟡 The row id uses the numeric `bookingid`, so the ID-format change does not touch it. But `drop` reads the flat `deliveryaddress`, which is **destination 0 only** — staff assigning a 3-destination pickup see one address. Not broken; incomplete. |
| **Rider Routes** | `src/pages/operations/RiderRoutes.jsx` | `bookingid`, `consignmentid`, `parcels` | 🟡 Reads `consignmentid` off the booking, which names only the **first** order. A rider carrying three parcels from one visit may render as one stop on this screen. |
| **Tracking Map** | `src/pages/operations/TrackingMap.jsx` | `bookingid` | ⚪ Numeric id only. |
| **Dashboard** | `src/pages/Dashboard.jsx` | `parcels` | ⚪ Counts only. Every parcel still hangs off the booking, so the totals stay correct. |
| **Dispatch** | `src/pages/operations/Dispatch.jsx` | `parcels` | ⚪ As above. |
| **Riders** | `src/pages/operations/Riders.jsx` | `parcels` | ⚪ As above. |
| Hub Settings, Login, Signup | — | ⚪ Untouched. |
### What nobody has to change today
Multi-destination pickups **only exist once the new customer app is live**. Every
booking in the database today has zero destination rows and therefore takes the
single-leg path everywhere — the rider queue returns one row, the consoles show
one address, and the fan-out never runs. The 🟡 "destination 0 only" rows above
become real on the day the first three-destination pickup is booked, not on the
day this deploys.
The exceptions that are live immediately: the **ID formats** (§0 items 1–2) and
the extra **JSON fields** (§0 item 6).
---
## 0.6 The rider-app change, scoped properly
An earlier draft of this document said "key the list on `consignmentid`" as
though that were a small edit. It is not, and the correction came from a review
with access to the Flutter source that this backend repo does not have. Recorded
here in full because the item is assigned to the app team and was understated.
**The rider app's identity key is `orderid`, not `bookingid`.** The adapter sets
it at `lib/data/api_config.dart:321`:
```dart
'orderid': ref ?? id, // ref = bookingreference, id = bookingid
```
Both of those are **booking-level**, and all three destination rows of one
pickup carry the same `bookingid` and the same `bookingreference`. So `orderid`
is identical across the three — and `orderid` is what dedupes:
- `lib/data/accepted_store.dart:527` — accepted stops live in a `Map` keyed on
`orderid`. Three stops collapse to one, and because that store is
SharedPreferences-backed, **the collapse survives an app restart.**
- `lib/data/work_repository.dart:314,321` and `order_events.dart` — per-order
event stamps key on `orderid`, so orders 2..N overwrite order 1's clocks.
`consignmentid` is available to the adapter (`api_config.dart:443`), so the
change is feasible — but it lands across `accepted_store`, `work_repository`,
`order_events` and `assignment_lookup`.
**Until it ships, a multi-destination pickup shows the rider one stop instead of
three** — the same customer-visible failure §5.1 fixed on the server. The server
being right is not enough here.
**`destinationseq` / `destinationcount` are invisible to the app today.**
`pickupFromBooking` builds a fixed map, so a field the adapter does not name is a
field the app can never see (the file says as much around line 583). "Stop 2 of
3" needs those two keys added to the adapter as well.
### The mitigation that needs no app release
`maxDestinations` is **server configuration**, not a constant — the client reads
it from `GET /customer/config/booking-limits` and adapts. Setting
`customerbookinglimits.maxdestinations = 1` keeps every pickup single-destination
until the rider build lands, which means:
- the customer app can ship and book end to end today;
- the fan-out code stays dormant, so no order can be stranded;
- lifting the cap later is one `UPDATE`, with no deploy on any side.
**Recommendation: seed it at 1 and raise it only once a rider build that keys on
`consignmentid` is live.** This is the same discipline
`MILER_HUB_HANDOVER_ENABLED` already applies in this codebase — do not turn on a
server behaviour the deployed rider app cannot complete.
---
## 0.7 Fan-out row audit — field by field
Requested audit, run against the code and locked down by tests. `GET /miler/bookings`
after collection, one row per order.
| Field | Source | Destination-specific? | Test |
|---|---|---|---|
| `consignmentid` | `bookingdestinations.consignmentid` | ✅ | `TestFanoutTrackingAndConsignmentIdsStayWithTheirStop` |
| `trackingno` | `bookingdestinations.trackingno` | ✅ | same |
| `deliveryaddress` | built from the destination's building / street / landmark / district / state | ✅ | `TestFanoutStopsHaveDistinctAddresses` |
| `deliverylatitude` | destination pin, else district centre | ✅ | `TestFanoutStopsHaveDistinctCoordinates` |
| `deliverylongitude` | same | ✅ | same |
| `recipientname` | `bookingdestinations.recipientname` | ✅ | `TestFanoutRecipientsStayWithTheirStop` |
| `recipientphone` | `bookingdestinations.recipientphone` | ✅ | same |
| `collectionamt` | consignment `codamount − codcollected`, falling back to the destination's own `codamount` | ✅ **newly added** | `TestFanoutCodIsNotCopiedAcrossDestinations` |
| `destinationseq` | `bookingdestinations.seq` | ✅ | `TestFanoutRouteOrderFollowsDestinationSeq` |
| `destinationcount` | number of stops on the pickup | ✅ uniform per booking | `TestFanoutDestinationCountIsUniform` |
**Three findings from running this audit:**
1. **`collectionamt` did not exist.** The endpoint emitted `codamount` only.
Added as an additional key rather than a rename — `codamount` is what this
endpoint has always sent and the deployed rider build parses it, so removing
it would break every stop on every existing device. Both are written from one
variable and cannot drift.
2. **COD was not on the stop at all.** It was resolved in the handler from the
consignment map, so it could not be unit-proven, and it fell back to **0** if
the consignment row failed to load. A rider shown ₹0 collects nothing. The
stop now carries the destination's own `codamount` as the fallback.
3. **Stop ordering was not guaranteed.** `milerStopsForBooking` iterated the
slice as given and relied entirely on the caller's `ORDER BY seq`. It worked,
but the ordering *is the route the rider drives*, and leaving it as an
unstated precondition means the next caller silently reorders someone's
afternoon. It now sorts by `seq` on a copy.
**Booking-level fallback is confined to one place and one case:** a booking with
**no destination rows** — every console/express booking and every row written
before this table existed — produces exactly one stop from the booking's flat
columns. A booking that *has* destination rows never reads them. Destination 0's
coordinates are used as a fallback for stop 0 only, and only when that
destination has no pin of its own, because that is the same row mirrored.
---
## 1. v1 requirements → what actually shipped
The requirements doc described the target. This is the delta between it and the
code as it now stands.
### 1.1 Endpoints — all 22 requirement endpoints implemented
Route arithmetic, since three numbers in this document have to reconcile:
**28 registered customer routes** = 8 pre-auth (4 auth + 4 catalogue) + 20
authenticated. The 22 below is the count of endpoints the *requirements* asked
for; the remaining 6 are the profile and saved-address routes in the table under
it. The OpenAPI spec now documents all 28 (24 paths / 28 operations).
| § | Endpoint | Status | Deviation from the requirement |
|---|---|---|---|
| 4.1 | `POST /customer/auth/otp/request` | ✅ | Answers identically whether or not the account exists — telling an anonymous caller "no account found" would make this a directory of who is registered. |
| 4.2 | `POST /customer/auth/signup` | ✅ | Existing phone treated as sign-in, as specified. |
| 4.3 | `POST /customer/auth/otp/verify` | ✅ | Idempotency-Key honoured. |
| 4.4 | `POST /customer/auth/refresh` | ✅ | Rotation. A **revoked** token replayed revokes the whole chain. |
| 4.4 | `POST /customer/auth/logout` | ✅ | No `refreshToken` in the body ⇒ signs out everywhere. |
| 4.4 | `GET /customer/auth/me` | ✅ | — |
| 5.1 | `GET /customer/serviceability/states` | ✅ | + ETag/304. |
| 5.2 | `GET .../states/{code}/districts` | ✅ | + ETag/304. |
| 5.3 | `GET /customer/pickup-slots` | ✅ | Slot ids encode their date (`slot_20260905_t1`) so a stale cached slot resolves to *that* day and is refused, not silently booked today. |
| 5.4 | `GET /customer/config/booking-limits` | ✅ | Accepts `lat`/`lng` for per-city limits. |
| 6.1 | `GET /customer/places/reverse-geocode` | ✅ | On upstream failure returns a coordinate label, **not** an error. |
| 6.2 | `GET /customer/places/search` | ✅ | Empty `q` ⇒ saved + recent places. |
| 7 | `POST /customer/fare/estimate` | ✅ | — |
| 9.1 | `POST /customer/bookings` | ✅ | — |
| 9.2 | `GET /customer/bookings` | ✅ | **Keyset** pagination, not offset. |
| 9.3 | `GET /customer/bookings/{reference}` | ✅ | + ETag/304 for polling. |
| 9.4 | `POST .../cancel` | ✅ | Cancelling an already-cancelled booking returns **200**, not 409. |
| 9.5 | `PATCH .../destinations/{index}` | ✅ | — |
| 9.6 | `GET /customer/orders/{trackingId}` | ✅ | Returns the full booking object (the requirement offered a slimmer shape; the client already parses this one). |
| 10 | `POST /customer/devices` | ✅ | + `DELETE /customer/devices/{token}`. |
| 10 | Push on milestone change | ✅ | `on_the_way` and `order_created` deliberately silent, per §10. |
| 11 | `POST /customer/ops/bookings/{ref}/stage` | ✅ | Double-gated. Walks every intermediate stage rather than jumping. |
**Also kept** (not in the requirements, not removed): `GET/PUT
/customer/profile`, `GET/POST/PUT/DELETE /customer/locations` — these answer §13.5
"saved addresses".
### Identifier scrambling — sequence-backed, but not readable
Both identifiers come off a Postgres sequence, because a sequence is the only
generator here that can promise uniqueness: the columns are `UNIQUE`, and a
random 8-digit id collides with ~43% probability by the ten-thousandth parcel —
a rider unable to complete a pickup.
But a raw sequence is readable. `DMX10000042` and `DMX10000043` are visibly
adjacent, so anyone holding two numbers learns the throughput between them, and
anyone holding one can guess its neighbours. So the index goes through a keyed
4-round Feistel permutation before it is formatted
(`controllers/cxIdentifierScramble.go`):
```
seq 10004200 -> DMX35031894 seq 100000 -> DM-499505
seq 10004201 -> DMX31070203 seq 100001 -> DM-341775
seq 10004202 -> DMX62788115 seq 100002 -> DM-106366
```
A Feistel network is a bijection for **any** round function, so uniqueness is
untouched. `TestBookingScrambleIsCollisionFree` proves it **exhaustively across
all 900,000 booking references**; the tracking test covers 200,000 consecutive
values; `TestFeistelIsAPermutation` checks the primitive itself exhaustively.
Deliberately **not** the obvious one-liner (multiply by a coprime). That is also
a bijection, but it is *linear* — and a multi-destination pickup hands one
customer three consecutive sequence values, so the differences between their
three tracking numbers would all equal the multiplier. A single booking would
reveal the mapping and make the whole range walkable.
`TestOneBookingDoesNotLeakTheMapping` rejects that design.
Past the fixed-width range (900,000 bookings / 90,000,000 orders) the identifier
grows a digit rather than wrapping onto one already issued. Uniqueness is never
traded for appearance.
**This is not a security boundary.** Every route that resolves a tracking number
is already authenticated and owner-scoped — that is what stops a stranger reading
someone's parcel. This removes the leak in the identifier itself, so
authorisation is not the only thing standing between an outsider and your volume
figures. Key: `CX_ID_SCRAMBLE_KEY`; a working default ships so the scrambling can
never be silently off.
### 1.2 Contract decisions the requirements left open
| Question | Decision | Consequence for the app |
|---|---|---|
| §3.2 field naming | **camelCase**, as preferred | **No mapping layer needed.** Served from purpose-built projections, so `/miler/*` keeps its lowercase keys. |
| §3.3 timestamps | **Epoch milliseconds, UTC, integer** | `DateTime.fromMillisecondsSinceEpoch(int)` works as-is. |
| §3.1 envelope | Payload in `data` on **every** response, auth included | One parser for the whole surface. |
| §9.6 order shape | Full booking object | ⚠️ **Not "reuse `Booking.fromJson`" as written earlier.** That parser exists but today reads only `reference`, `stage`, `cancellable`, `createdAt`, `pickup`, `slotId` and four destination fields. It ignores `status`, `miler`, `deliveryAgent`, `fare`, `amountPaid`, `deliveredAt`, `history`, `verification`, `details`, per-destination `stage`, `routeKm` and `expectedDelivery`. The server shape is right; the client parser has to be finished. Appendix B of the requirements said so and this document should not have glossed it. |
| §9.3 `miler.phone` | Proxy when `MILER_CALL_PROXY` is set, real number otherwise | **Currently unset ⇒ real number.** See §10.2. |
| Identifier format | Sequence-backed `DM-######` / `DMX########` | Matches the designs. |
### 1.3 Timestamps — the defect that would have shipped silently
This database stores **IST wall-clock digits** in its timestamp columns (the DSN
sets `TimeZone=Asia/Kolkata`; `utils.DBNow` exists because of it). Calling
`.UnixMilli()` on a value read back from those columns is **off by 5h30m** — the
same class of fault as a naive local string with a `Z` on it, which already
produced "yesterday's work shown as today" on the Miler app.
Every timestamp leaving `/customer/*` goes through `utils.EpochMillis`, which
reinterprets the wall clock in IST first. It is correct for **both** shapes the
driver can produce (UTC-tagged-with-IST-digits, and correctly `+05:30`-tagged),
so a future column-type change cannot silently shift the tracking screen.
Asserted in `utils/epoch_test.go`.
---
## 2. The structural change: one pickup → many orders
`pickupbookings` carried exactly **one** delivery address in its own columns.
There was nowhere to put a second, so the "one visit, three orders" model in §1
of the requirements was not expressible at all.
```
Pickup booking DM-482913
├── destination 0 : Chennai, TN · 2 packages
├── destination 1 : Ernakulam, KL · 1 package
└── destination 2 : Bengaluru Urban, KA· 1 package
│ miler collects everything in ONE visit
▼
pickup-complete fans out → 3 consignments, 3 tracking numbers
DMX10482913 DMX10559120 DMX10662004
```
**The compatibility rule that makes this 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-created express booking and every pre-existing row — produces exactly one
consignment through the same loop, byte-for-byte as before.
Single-destination is one leg, not a special case, in either direction.
---
## 3. File-by-file: what changed and why
### 3.1 New files (23)
| File | Lines | Purpose |
|---|---|---|
| `models/customer_app.go` | 241 | The 9 new tables. `BookingDestination` is the one that makes multi-destination expressible. |
| `internal/cxstage/stage.go` | 405 | **The core of §8.3.** Records one append-only stage row per transition, in the caller's transaction. Owns the slowest-order rollup and the `Release` walk-back. |
| `controllers/cxBookingView.go` | 744 | The canonical §9.3 booking object. Every read that returns a booking goes through `renderCxBooking`; `loadCxBundle` batches so a tracking poll is a fixed number of queries. |
| `controllers/cxBookingController.go` | 819 | §9.1–9.6 — create, list, detail, cancel, patch, order-by-tracking-id. |
| `controllers/cxAuthController.go` | 614 | §4 — OTP over phone/email, rotation-backed refresh, E.164 normalisation. |
| `controllers/cxCatalogueController.go` | 516 | §5 — serviceability, capacity-aware slots, limits, ETag support. |
| `controllers/cxPlacesController.go` | 350 | §6 — geocoder proxy + Redis cache. |
| `controllers/cxFareController.go` | 266 | §7 — estimate range, multi-stop uplift. |
| `controllers/cxPickupFanout.go` | 251 | Splits a booking into the journeys created at pickup-complete. Decides single-vs-fan-out; nothing downstream needs to know which. |
| `controllers/cxOpsController.go` | 160 | §11 QA stage override, double-gated. |
| `controllers/cxConsignmentHooks.go` | 100 | Maps a consignment status onto a customer stage for one order. |
| `controllers/cxDeviceController.go` | 85 | §10 push registration. |
| `controllers/cxIdentifiers.go` | 80 | Sequence-backed `DM-######` / `DMX########`. |
| `utils/epoch.go` | 103 | IST→epoch conversion. **Read §1.3.** |
| `utils/response_cx.go` | 97 | The customer envelope. Separate from `utils.OK/Fail` on purpose. |
| `internal/sms/sms.go` | 105 | The seam for an SMS gateway. **No provider is wired in.** |
| `middlewares/requestid.go` | 33 | `X-Request-Id` echo. |
| `seed_customer_app.sql` | 153 | Staging data mirroring the client mock. |
| `controllers/cxHttp_test.go` | 606 | HTTP status-code + envelope contract tests. |
| `controllers/cxCustomerApp_test.go` | 473 | Pure-logic unit tests. |
| `routes/routes_customer_test.go` | 323 | Routing + **the regression guard proving miler/console are untouched.** |
| `internal/cxstage/stage_test.go` | 126 | Stage rollup and cancellation-window tests. |
| `utils/epoch_test.go` | 116 | Timestamp conversion tests. |
### 3.2 Modified files (20)
| File | Δ | What changed | Why |
|---|---|---|---|
| `controllers/customerController.go` | +98 / −593 | PIN auth + single-destination booking handlers **deleted**; profile and locations kept, moved to the customer envelope | Replaced by the new surface; the old request shape is no longer a valid booking |
| `controllers/milerController.go` | +593 | `BookingPickupComplete` rewritten for fan-out; `BookingParcelConfirm` accepts photos + writes transactionally; stage hooks added to accept/reached/cancel | §8.3 derivation and the §1 fan-out |
| `controllers/milerAppController.go` | +410 | `MilerGetMyBookings` emits one stop per order after collection; `MilerDeliverConsignment` + `MilerStartDelivery` lookups fixed; stage hooks | **Without this, orders 2..N are undeliverable** — see §5.1 |
| `routes/routes.go` | +82 | Customer routes replaced: 19 → 28 | The new contract |
| `models/booking.go` | +60 | 10 columns on `PickupBooking`, 1 on `BookingParcel`, `Destinations` relation | Storage for the customer projection |
| `constants/constants.go` | +71 | 9 stage keys, 3 statuses, 4 actor types, rank/cancellable helpers | The wire contract; never inline these |
| `migrations/migrate.go` | +55 | 9 tables, 2 sequences, 2 indexes | Schema |
| `internal/storage/spaces.go` | +74 | `PresignGet` | Signed parcel-photo URLs |
| `dto/auth.go` | −41 | Retired customer PIN DTOs removed | Dead after the auth replacement |
| `middlewares/logger.go` | +38 | Logs `client`, `platform`, `requestid` | §3 "accept and log" |
| `middlewares/idempotency.go` | +34 | Anonymous-caller scoping | **Security fix** — see §5.4 |
| `controllers/logisticsHandoverController.go` | +24 | Consignment→booking lookup fixed; `in_transit` recorded | Fan-out correctness |
| `middlewares/city_gate.go` | +18 | Exported `PincodeInOperatingCity` | **Security fix** — see §5.3 |
| `internal/assignment/crm_assignment.go` | +17 | `assigned` recorded on auto-assign | Most B2C bookings get a rider this way |
| `controllers/booking_assignment_service.go` | +16 | `assigned` recorded on manual assign | Console assignment must reach the customer too |
| `config/config.go` | +13 | `GEOCODER_URL`, `GEOCODER_EMAIL` | §6 proxy |
| `utils/helper.go` | +13 | `GenerateTokenWithTTL` | 1h access tokens for customers only |
| `main.go` | +7 | `RequestID()` registered | §3 |
| `controllers/otpController.go` | −128 | **Deleted** | Its two routes were customer-only and are superseded |
| `CLAUDE.md` | +147 | Project memory updated | Continuity |
---
## 4. Stage derivation (§8.3)
The operational writes already existed and were already correct. What did not
exist was any record of *when* a parcel reached a stage in the vocabulary the
customer sees. A consignment status says where a parcel is **now**; it cannot say
when it got there.
| Existing Miler write | Customer stage produced |
|---|---|
| `assignMilerTx` / `commitAssignment` | `assigned` |
| `POST /miler/assignments/{id}/accept` | `on_the_way` (re-asserts `assigned`; deduped) |
| `POST /miler/bookings/{id}/reached` | `arrived` — **cancellation closes here**, recorded in the same transaction as the arrival fact so there is no window to cancel through |
| `POST /miler/bookings/{id}/parcel` | populates `verification` (weight per destination + photos) |
| `POST /miler/bookings/{id}/pickup-complete` | `picked_up`, then `order_created` **per destination** |
| `POST /miler/consignments/{id}/inward-at-hub` | `in_transit`, per order |
| `POST /miler/consignments/{id}/start-delivery` | `out_for_delivery`, per order |
| `POST /miler/consignments/{id}/deliver` | `delivered`, per order |
| `POST /miler/bookings/{id}/cancel` | **`Release`**, not cancel |
**Four rules worth knowing as the app developer:**
1. **Nothing is backfilled.** A booking that predates this work has a short
history. A short honest history beats a long invented one — the customer
cannot tell which entries were guessed.
2. **A booking rolls up from its SLOWEST order.** Taking the maximum would show
"Delivered" while one of three parcels was still at a hub.
3. **`on_the_way` comes from accept, not the GPS stream.** Deriving it from
location pings means re-deriving on every ping. Distance and ETA are still
live from the rider's Redis position.
4. **`Release` is the one place a stage moves backwards.** A miler cancelling
returns the pickup to the pool. Without walking the stage back, the customer
keeps seeing a rider card for someone who is not coming. The `assigned` /
`on_the_way` history rows stay — those things happened.
### §8.4 gaps — all five closed
| Gap in the requirements | Status |
|---|---|
| No per-destination consignment id | ✅ `bookingdestinations.consignmentid` + `trackingno` |
| No COD amount on the booking | ✅ `bookingdestinations.codamount` → `consignments.codamount`. **Per destination**, because COD is collected per delivery |
| No per-stop attribution for stages 6–8 | ✅ Every per-order stage records against its destination |
| No customer-side cancel | ✅ Server-enforced through `arrived` |
| Parcel weight/photos not exposed | ✅ `verification` block, photos as signed URLs |
---
## 5. Bugs found and fixed (not requested — found because the fan-out forced every consignment lookup to be re-read)
### 5.1 🔴 The rider could never deliver orders 2..N
`GET /miler/bookings` emitted one row per booking, keyed on
`pickupbookings.consignmentid` — a column that names only the **first** order.
On a three-destination pickup, two parcels would sit in the rider's bag with no
stop, no deliver button and no way to close them.
**Without this fix the entire multi-destination feature would have created orders
nobody could deliver.** `milerStopsForBooking` now emits one stop per order after
collection (and still exactly one visit before it — the rider goes to the door
once). Tested in `controllers/cxCustomerApp_test.go`.
### 5.2 🔴 Three endpoints broken for orders 2..N (same root cause)
- **`MilerDeliverConsignment`** returned `404 "assigned consignment not found"` —
the rider **could not complete the delivery at all**.
- **`MilerStartDelivery`** sent no push and no receiver OTP.
- **`MilerInwardConsignmentAtHub`** left the assignment open (so the rider could
not go off duty — `MilerEndDuty` refuses on an open assignment) and recorded
the leg's distance and earnings as **zero**.
All three now resolve through `cxDestinationForConsignment`.
### 5.3 🟠 `CityGateMiddleware` was a no-op for customer bookings
It sniffs the request body for `pickuppincode`. The new request shape does not
carry one — its pickup is a `title/sub/lat/lng` from the place search — so
**every customer booking sailed past the operating-city gate.** Now checked in
the handler against the pincode resolved from the coordinates; a pickup outside
an operating city returns `422 unserviceable`.
### 5.4 🔴 Idempotency key collision (introduced by me, caught in review)
Keys were namespaced by user id. On the **unauthenticated**
`POST /customer/auth/otp/verify` that id is `0` — so two customers who happened
to pick the same `Idempotency-Key` would collide and **the second would be handed
the first's access token, refresh token and customer record.**
Fixed: anonymous callers are scoped by request path + body hash. The
authenticated key format is left byte-identical, so no in-flight rider key is
orphaned at deploy.
### 5.9 🟠 Fan-out row audit: three gaps closed
Found by auditing every row-level field the rider app reads, rather than
assuming the fan-out was complete:
- **`collectionamt` was never emitted** — only `codamount`. Added alongside it,
not as a rename, so the deployed build keeps working.
- **COD fell back to 0** when a consignment row did not load, instead of to the
destination's own figure. A rider shown ₹0 collects nothing.
- **Stop ordering depended on the caller's `ORDER BY`.** Now guaranteed inside
`milerStopsForBooking`, because the ordering is the route.
Also hardened at the same time: `CreateCxBooking` now rejects an absurd
destination count **before** any database work (`cxAbsoluteMaxDestinations`),
because the district lookup built a `WHERE districtcode IN (...)` from unbounded
caller input; and `cxDefaultMaxDestinations` dropped from 5 to **1**, so a
missing configuration row resolves to the safest cap rather than the most
permissive — a gate that opens when its config is absent is not a gate.
### 5.10 🟠 Two different mistakes shared one error message
`POST /customer/bookings` answered an **empty** `destinations` array with
*"Every destination needs a serviceable state and district"* — the same string
it uses when a destination is present but missing its state or district.
The contract renders `message` verbatim, so a customer who had added nothing was
told to go and fix details on destinations they did not have. The message
described a problem they did not have and hid the one they did. Both cases also
carried the same `error.code`, so the app could not tell them apart either.
Now *"Add at least one destination"*, matching what `/customer/fare/estimate`
already said for the identical mistake. `TestEmptyDestinationsSaysAddOneNotFixTheirDetails`
asserts the two endpoints agree and that the message names the actual fix.
Found by reading the messages rather than the status codes — every one of them
is customer-facing copy, and a 400 being *correct* says nothing about whether it
is *useful*.
### 5.5 🟡 Pre-existing, flagged not fixed
`BookingPickupComplete`'s comment says a hyperlocal parcel keeps the rider
`Picked_Up`; the code sets them `Available`. This is the default path today.
Changing it alters live rider availability and the dispatch pool — **ops
decision.**
---
### 5.6 🔴 Rider earnings would have read ₹0 on every customer job (introduced by me, caught while mapping screens)
`CreateCxBooking` stored the estimate in its own new columns and did **not**
create a `BookingServiceOption` row — but three places still read one:
- `MilerDeliverConsignment` and `MilerInwardConsignmentAtHub` copy
`Estimatedprice` onto `BookingAssignment.ridercharges` when a leg closes.
- `GET /miler/earnings` sums that column.
- The admin console's Orders list renders `serviceoptions[0].estimatedprice` as
its **Price** column.
So every customer-app job a rider completed would have recorded **zero earnings**
on their Earnings screen, and the admin Orders list would have shown **"N/A"**
for the price of every customer booking. Fixed: the booking now creates a service
option carrying the midpoint of the band the customer was shown, which is what
`lookupDoormilePrice` returns everywhere else in this codebase.
### 5.7 🔴 An ops cancellation never reached the customer
`AdminCancelBooking`, `AdminBulkCancelBookings` and `AdminUpdateBookingStatus`
all cancel by writing `pickupbookings.status` directly. None of them knows the
customer projection exists — and because `customerstatus` is written as
`"active"` at booking time, the projection's empty-string fallback never fired.
**A customer whose pickup ops cancelled would have kept seeing it as active and
cancellable, indefinitely.** They would also still have received stage
notifications for it.
Fixed in two places, deliberately: the projection now treats the **operational**
status as the authority on cancellation, which covers all three paths and any
added later; and the two real cancel paths call `cxstage.Cancel` so the customer
gets a reason and the audit trail records which ops user did it. Both are no-ops
for console-created bookings.
### 5.8 🟠 An expired slot was reported as a full one
`CreateCxBooking` answered a slot whose window had already passed with
`409 "That pickup window just filled up"`. That is untrue and points the
customer at the wrong recovery — they need to re-fetch the slot list, not retry
for a place in a queue. A client that caches slots for a session and is left open
across midnight hits this on the first booking of the day.
Now `400 invalid` with *"That pickup time has passed — pick a new slot"*, which
is what the contract already maps to "Pick a pickup slot". A stale **date** is
also now caught by `CxSlotDateIsPast` before any database access, since the slot
id carries its own date — the cheapest validation in the path, and the one that
actually fires. `409` is reserved for the genuine capacity race.
Found by a reviewer noticing that §5.3 and §7.7 of this document could not both
be comfortable at once. They were right.
## 6. Tests
`go build ./...`, `go vet ./...`, `go test ./...` all pass.
**305 passing assertions/subtests**, all runnable with no database.
| File | What it proves |
|---|---|
| `controllers/cxHttp_test.go` | **Status codes and envelopes through a real router.** Every validation path answers 4xx with a machine-readable `error.code` and customer-safe English — never 500, never a router 404. Malformed JSON is 400. All 9 contract error codes map to the right status. `CxInternal` never leaks the cause. The QA override is invisible unless both gates are open. |
| `routes/routes_customer_test.go` | All **20** authenticated routes exist and return **401 without a token** (not 404, not 500). All refuse roles 1/3/4/5/6 with **403**. Pre-auth routes are reachable and answer from the handler. Retired PIN routes no longer mint a credential. **`TestMilerSurfaceIsUnchanged` / `TestConsoleSurfaceIsUnchanged` are the regression guard for §0.** |
| `controllers/cxCustomerApp_test.go` | Phone normalisation (7 spellings → one stored value, so a build change cannot create a duplicate account); the fan-out stop split; timeline dedup and per-order timestamping; `deliveredAt` waiting for the last parcel; pickup title/sub never empty. |
| `internal/cxstage/stage_test.go` | Slowest-order rollup; stage ordering as a wire contract; the cancellation window closing exactly after `arrived`. |
| `utils/epoch_test.go` | IST→epoch for both driver taggings; null stays null; slot window and day formatting. |
### What the tests do NOT prove
**No happy path is asserted.** A `200` from `CreateCxBooking` needs Postgres,
Redis and NATS. Mocking them would test the mock. Everything from the handler
inwards is still unverified — that is the integration pass in §11.
---
## 7. Answers to the requirements' §13 open questions
*(“§13” here and in §9 means section 13 of the requirements document. Section 13
of THIS document, at the bottom, is the review log.)*
**1. Payment.** v1 is cash/UPI at the door, settled outside the app. No payment
step exists and there is no payment contract to hand you. `amountPaid` is the sum
of `bookingpayments` rows the miler recorded, in whole rupees, present from
`picked_up`. Two different pots of money: the **pickup fee** is Doormile's,
collected once per visit and recorded against the first order; **COD at the
destination** is the customer's own collection, per destination. Doormile is the
carrier, never the seller. ⚠️ *No settlement/remittance flow exists — money can be
collected and recorded, nothing pays it back to the customer.*
**2. Masked calling.** Implemented as a switch: `MILER_CALL_PROXY` returns a proxy
when set, the rider's real number otherwise. **Recommendation: set it before
launch.** A customer handed a rider's personal mobile has it permanently.
⚠️ *Currently unset.*
**3. Partial pickup.** Not modelled, not guessed at. Pickup-complete is
all-or-nothing. The fan-out makes it *expressible* (legs are built per
destination), but there is no stage key, no miler endpoint and no screen.
⚠️ *Product decision needed.*
**4. Failed delivery / reattempt.** Exists operationally —
`POST /miler/consignments/{id}/skip` increments `attemptcount` and leaves the
parcel `Out_for_Delivery`. **Deliberately not mapped to a customer stage**: there
is no key for it in the nine, and an unknown key renders as `booked` on the
client. So today a failed attempt is **invisible to the customer** — their parcel
stays "Out for delivery". ⚠️ *This is the most customer-visible gap in v1. Needs a
tenth stage key + a client release.*
**5. Saved addresses / notification preferences / payment methods.** Saved
addresses **built** (capped at 10, same `title`/`sub` shape as a place search
result). Notification preferences **not built**. Payment methods **not built**,
correctly, while v1 settles at the door.
**6. Support.** Not built for the customer. `/miler/support` exists and the shape
would extend. ⚠️ *A phone number is the cheapest thing that is not a dead end.*
**7. Slot capacity semantics.** **Per zone.** A window's remaining capacity counts
live bookings whose `preferredpickupfrom` falls inside it, within 12km of the
pickup point, excluding cancelled and completed. The list read is advisory and
cached 30s; capacity is **re-checked at confirm** and a lost race returns `409`.
**The client does not need to re-fetch before confirming.**
**8. Pricing.** Reuses the existing `doormile_pricing` slab (zone × service type ×
weight band → min/max), with a distance-and-weight fallback for unpriced lanes.
Additional destinations carry a **+35% uplift each**. Weight assumed 3kg/package
until weighed. ⚠️ *The 35% is a placeholder, and there is **no breakdown when
`amountPaid` exceeds `fare.max`** — the receipt can show `amountPaid − fare.min`
as a weight adjustment, but nothing explains an overage. Product owns both.*
**9. Cancellation fee.** **Confirmed free through `arrived`.** No fee is computed,
charged or recorded anywhere in the cancel path. The UI copy is accurate. From
`picked_up` onward the server returns `409`.
**10. Retention.** **Not implemented — no policy exists to implement.** Parcel
photos stored indefinitely, PII stored indefinitely, `bookingstageevents` never
pruned. The 30-minute signed-URL TTL limits *link* lifetime, not object lifetime.
⚠️ *This is the answer most likely to matter to a regulator and it is currently
blank.*
**11. Tenancy.** **Logistics only in v1.** Customer JWTs carry `tenantid: 0` and
B2C bookings leave `pickupbookings.tenantid` null. The customer app does not
switch mode on `tenantid` the way the Miler app does. ⚠️ *Decide before revenue
reporting depends on it.*
---
## 8. Non-functional (§3.5)
- **Idempotency** — `POST /customer/bookings` and `POST /customer/auth/otp/verify`.
24h replay.
- **Caching** — serviceability `ETag`/`If-None-Match` → 304, `max-age=300`. Slots
`max-age=30`. Booking detail supports 304 (it is polled).
- **Rate limits** — OTP ≤5 per identifier per hour, ≤3 verify attempts per code,
30s resend cooldown that does **not** consume one of the five. Plus the shared
10/minute per-IP credential budget.
- **Pagination** — `?limit=&cursor=`, default 20, max 50. **Keyset**, so a new
booking landing mid-scroll cannot show the same row twice. `total` reflects the
filtered tab.
- **PII** — phone masked in SMS logs; parcel photos as signed URLs.
- **Audit** — actor type, actor id, source endpoint and timestamp on every
transition.
- **Tenancy** — every customer read is scoped by `appcustomerid` in the query
itself, never by a check afterwards.
- ⚠️ **Latency is UNMEASURED.** Reads are batched (`loadCxBundle` is a fixed query
count regardless of page size) and pricing is Redis-warmed — but p95 ≤ 400ms is
an argument, not a measurement.
---
## 9. Deliverables (§11)
| Asked for | Status |
|---|---|
| OpenAPI 3.1 spec | ✅ `openapi-customer.yaml` — 24 paths, 28 operations, validated. Covers every registered route including profile and saved addresses. |
| Postman collection | ❌ Not produced. The OpenAPI file imports directly into Postman. |
| Staging seed mirroring the mock | ✅ TN/KL/KA open, PY serviceable with no open district, Madurai/Kozhikode/Mangaluru unavailable with reasons, slot `t5` at capacity 0 |
| Test accounts + fixed staging OTP | ✅ Two accounts seeded; `CX_STAGING_OTP=1234`, refused when `ENV=production` |
| Force a booking to any stage | ✅ Double-gated; walks every intermediate stage |
| Documented error responses | ✅ Implemented verbatim; asserted in tests |
| Error injection | ❌ Not built. Happy-path states are forceable; failure states are not. |
| Written answer to the requirements' §13 | ✅ §7 above |
---
## 10. Environment variables
```bash
GEOCODER_URL=https://nominatim.openstreetmap.org # place search / reverse geocode upstream
GEOCODER_EMAIL=ops@doormile.com # Nominatim policy requires a contact
MILER_CALL_PROXY= # ⚠️ empty exposes the rider's real number
CX_STAGING_OTP=1234 # refused when ENV=production
CX_ID_SCRAMBLE_KEY= # keys the identifier permutation; a working default ships
CX_ALLOW_STAGE_OVERRIDE=false # also requires ENV != production
```
---
## 11. Before this goes to production — read this
**Blockers, stated plainly:**
1. 🔴 **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.** Real customer
sign-in is blocked on this. It is the same wall the previous end-to-end test
hit. `sms.Configured()` reports it.
2. 🔴 **No integration test has touched any of this.** The 305 passing tests cover
pure logic, routing, status codes and envelopes. Nothing has run against a
real Postgres, Redis or NATS. **A clean compile says the code is well-formed,
not that it works.**
3. 🟠 **The migration has not run against a real database.** It is additive
(`AutoMigrate` + `CREATE SEQUENCE`/`CREATE INDEX IF NOT EXISTS`) and Postgres
11+ adds nullable/defaulted columns without a table rewrite — but *should be
safe* is not *has been observed*. Run it on staging first and watch
`pickupbookings`.
4. 🟠 **`MILER_CALL_PROXY` is unset**, so the tracking screen will show riders'
personal mobile numbers.
5. 🟠 **No retention policy** for parcel photos or PII.
6. 🟡 **Failed delivery is invisible to the customer** (§7.4).
7. 🟡 **Latency unmeasured** (§8).
8. 🔴 **Multi-destination pickups must stay capped at 1 destination** until a
rider build keying on `consignmentid` ships (§0.6). The cap is a database
value, not code — verify it, do not assume it.
**Suggested deploy order:**
1. Staging: deploy, let `AutoMigrate` run, apply `seed_customer_app.sql`, set
`CX_STAGING_OTP=1234`.
2. **Confirm `maxdestinations = 1`** in `customerbookinglimits` (the seed sets
it). This is the gate that keeps the fan-out dormant until the rider app can
handle it — see §0.6. Nothing else prevents a stranded parcel.
```sql
SELECT applocationid, maxpackages, maxdestinations FROM customerbookinglimits;
```
3. Smoke the customer surface end to end — this is the pass that has never
happened.
4. **Regression-test the Miler app against staging**, specifically: pickup-complete
on a single-destination booking, deliver, inward-at-hub, and off-duty. Those
are the paths §0 items 3–5 and §5.2 touch.
5. Plug in the SMS gateway before any production customer traffic.
6. **Only after a rider build keying on `consignmentid` is live**, raise the cap
and test a multi-destination pickup — the genuinely new path:
```sql
UPDATE customerbookinglimits SET maxdestinations = 5 WHERE applocationid IS NULL;
```
---
## 11.5 Client-side prerequisites — none of §12 works until these land
§12 below is a correct set of instructions for a client that can make requests.
Today it cannot, and these are outstanding on the app side (all four are in
Appendix B of the requirements, and this document should have carried them
forward rather than assuming them done):
1. **`pubspec.yaml` has no `http` and no `shared_preferences`.** Every rule about
headers, idempotency keys and token rotation presumes a network layer that is
not yet added.
2. **`Booking.fromJson` parses roughly a third of §9.3.** See the §9.6 row above.
3. **`PickupSlot.fromJson` drops `tag`, `milersNearby` and `caption`.** The
server sends all three; the picker will not show any of them.
4. **`AppState.loadSlots` caches for the whole session and `startBooking` clears
only `districtCache`** (`lib/state/app_state.dart:89`). An app left open
across midnight books against yesterday's slot. The server now answers that
with `400` and *"That pickup time has passed — pick a new slot"* rather than
the misleading "just filled up" it used to (§5.8) — but the client fix is to
clear `slotsCache` in `startBooking` so the customer never reaches that error.
---
## 12. Quick reference for the app developer
- **Base:** `https://api.doormile.com/api/v1`
- **Auth:** `Authorization: Bearer <accessToken>` on everything except
`/customer/auth/otp/request`, `/auth/signup`, `/auth/otp/verify`,
`/auth/refresh`, and the four catalogue reads.
- **Access token 1h, refresh 60 days, rotated on every use.** A revoked refresh
token replayed kills the whole chain — always store the newest one.
- **Send `X-Client: doormile-cx/<version>+<build>` and `X-Platform: android|ios`.**
They are logged and are the only way to tell one build's failures from another's.
- **Send `Idempotency-Key` on booking create and OTP verify.**
- **Every timestamp is epoch milliseconds, UTC, integer.**
- **Every string in `message` is safe to render verbatim.** Branch on
`error.code`, never on the message text.
- **`pickup`, `slotId`, `destinations[].stateName` and `districtName` are never
null.** The rest of `details` may be absent — that is the "Not added" state, not
an error.
- **`trackingId` and `stage` on a destination are null until `order_created`.**
There is no order to track before the parcels are collected.
- **An unknown stage key renders as `booked`.** If you see that on a moving
parcel, the backend sent a key this build does not know — tell us; it means a
new stage shipped without a client release.
---
## 13. Review log
This document has been reviewed once, by a reviewer with access to the two
Flutter apps but **not** to this Go backend. That split matters and is why the
review was useful: every claim it made about the apps was checkable by them and
not by me, and every claim about this repo was checkable by me and not by them.
The findings below are recorded verbatim in substance, with what was done about
each.
**Standing caveat the reviewer stated and this document repeats:** the 305 tests,
the fan-out, the idempotency fix and the epoch conversion are unverified from
their side. Nothing in §11 has changed — this code has still never run against a
real database.
### 13.1 Findings — all upheld
| # | Finding | Status | What changed |
|---|---|---|---|
| 1 | **The rider-app action item is materially understated.** The app's identity key is `orderid` (`api_config.dart:321`, `'orderid': ref ?? id`), not `bookingid`. Both are booking-level, so all three fan-out rows collapse — in `accepted_store.dart:527` (SharedPreferences-backed, survives restart) and in `work_repository.dart:314,321` / `order_events.dart`. Keying on `consignmentid` touches four files. | **Upheld.** Consistent with what this server sends: the three rows share `bookingid` *and* `bookingreference`. | New **§0.6** replaces the one-line framing. §0 item 3 now points at it. Mitigation added: `maxdestinations` seeded at **1**, and a deploy-checklist step to verify it. |
| 2 | `destinationseq` / `destinationcount` are invisible — `pickupFromBooking` builds a fixed map, so a field the adapter does not name cannot reach the app. | **Upheld.** | Recorded in §0.6. Adding fields server-side does not help until the adapter names them. |
| 3 | **"Reuse `Booking.fromJson`" is optimistic.** It parses only `reference`, `stage`, `cancellable`, `createdAt`, `pickup`, `slotId` and four destination fields — ignoring `status`, `miler`, `deliveryAgent`, `fare`, `amountPaid`, `deliveredAt`, `history`, `verification`, `details`, per-destination `stage`, `routeKm`, `expectedDelivery`. | **Upheld, and it was avoidable** — Appendix B of the requirements said exactly this and this document glossed it. | §1.2's §9.6 row rewritten to state the gap instead of assuming the parser. |
| 4 | **Slot staleness is a trap handed to the client.** §5.3 and §7.7 could not both be comfortable: `AppState.loadSlots` caches for a session, `startBooking` clears only `districtCache` (`app_state.dart:89`), so an app open across midnight books yesterday's slot. | **Upheld — and it exposed a server defect.** The server answered `409 "That pickup window just filled up"`, which is untrue and points at the wrong recovery. | **Code fixed** (§5.8): now `400` with *"That pickup time has passed — pick a new slot"*, plus a new DB-free `CxSlotDateIsPast` guard that runs before any query. Test added. Client-side fix (clear `slotsCache`) recorded in §11.5. |
| 5 | §12's header/idempotency/rotation rules assume a client that can make requests; `pubspec.yaml` has no `http`, no `shared_preferences`. | **Upheld.** | New **§11.5** lists the client prerequisites ahead of §12. |
| 6 | `PickupSlot.fromJson` drops `tag`, `milersNearby`, `caption` — the server now sends all three. | **Upheld.** | §11.5 item 3. |
| 7 | §3.2 contains a literal unfilled placeholder: `customerController.go │ −691/+?`. | **Upheld.** | Corrected to the real churn, **+98 / −593**. |
| 8 | The counts do not reconcile: 19→28 routes, 8 exempt from auth ⇒ 20 authenticated, but §6 asserts 22. | **Upheld.** Verified: 28 registered = 8 pre-auth + **20** authenticated; the routing test has exactly 20 entries. | §6 corrected to 20. §1.1 now states the arithmetic explicitly so the three numbers reconcile on the page. |
| 9 | The OpenAPI file's 22 operations imply `/customer/profile` and `/customer/locations` are absent, though §1.1 relies on them to answer §13.5. | **Upheld, and the worst of the three** — the app developer works from the spec and would not have found saved addresses. | **6 endpoints added to the spec** with `SavedAddress` / `SavedAddressInput` schemas. Now **24 paths / 28 operations**, matching the 28 registered routes exactly. |
### 13.2 What the review confirmed as correct
Recorded because it is the half that did not change, and it is the half the app
is built against:
- camelCase throughout — no mapping layer needed on the client.
- Epoch-millis UTC — `DateTime.fromMillisecondsSinceEpoch` works as-is.
- Payload always in `data`, auth included.
- `pickup`, `slotId`, `stateName`, `districtName` non-null — the client types
these non-nullable and would throw otherwise.
- "An unknown stage key renders as `booked`" is accurate against `models.dart`
(`JourneyStage.fromKey` falls back to `booked`).
- The §1.3 IST timestamp defect is real and is the bug that already bit the
Miler app.
- §8.4's five gaps match independently.
- The two reverts — rider availability, and the 404→403 change — were the right
call.
- §11's honesty about what is unverified was called the most useful section here.
It has not been softened.
### 13.3 Reviewer's verdict, unedited in substance
> The backend contract is sound and answers the requirements; the impact
> assessment is accurate in direction but under-scopes the rider-app work, and
> its "reuse the existing parser" claims describe the client we intend to have,
> not the one in the repo. Nothing here contradicts the requirements doc.
Both criticisms are now addressed in the text: §0.6 for the rider-app scope,
§11.5 and the §9.6 row for the parser claims.
### 13.4 What still cannot be checked from either side
- Nobody has run this against a real Postgres, Redis or NATS.
- The reviewer could not verify the Go changes; this document's author could not
open the Flutter apps. **Nothing in §0.6 or §11.5 has been executed** — those
file and line references come from the review, not from this repo.
- The single integration pass in §11 remains the thing that would close both
gaps at once.

View File

@@ -251,10 +251,25 @@ delivered, riderkms, ridercharges, dutyminutes }`.
| Method | Path | Notes |
|---|---|---|
| GET | `/admin/bookings` | tenant-scoped list |
| GET | `/admin/bookings` | tenant-scoped list, **newest first** (`bookingid DESC`) |
| POST | `/admin/expressbooking` | create one — passes CityGate |
| POST | `/admin/expressbooking/bulk` | `{ "bookings": [ ... ] }`, max 200, per-row results |
| GET | `/admin/bookings/:id` | 404 if outside your tenant |
`GET /admin/bookings` is ordered `bookingid DESC` — page 1 is always the newest
page. The order is guaranteed, not incidental: `bookingid` is the primary key
and unique, so `OFFSET` paging over it is stable and two requests for the same
page return the same rows. Before this was explicit the result order was
unspecified (Postgres heap order, in practice oldest first), which put the
newest booking on the LAST page — a client draining a bounded number of pages
could never reach a just-created booking.
It also accepts `?status=` as an EXACT, case-sensitive, single-value match
against the stored enum. The stored values are capitalised
(`Pending_Pickup`, `Converted_To_Consignment`); `?status=pending_pickup`
matches nothing and returns an empty list rather than an error. There is no
multi-status or date filter, so a client grouping several statuses into one tab
still has to filter its own rows.
| GET | `/admin/bookings/:id/track` | **the tracking screen** — see below |
| POST | `/admin/bookings/:id/assign-miler` | |
| POST | `/admin/bookings/:id/assign-vehicle` | |
@@ -393,7 +408,12 @@ rather than making the console reconcile two stores.
- **Envelope**: `{ "success": true, "data": ... }` on success,
`{ "success": false, "message": "..." }` on failure. Lists add `total`,
paginated lists add `page`.
- **Pagination**: `?pageno=1&pagesize=100`. Default 500, cap 1000.
- **Pagination**: `?pageno=1&pagesize=100`. Default 20, cap 100. Both numbers
are enforced server-side (`min(100, max(1, pagesize))`); a request for a
larger page silently receives 100 rows and the response's own `pagesize`
field reports 100. This entry previously read "default 500, cap 1000", which
was wrong on both counts — clients sizing a page budget off it fetched a
twelfth of what they expected.
- **Rate limits**: 300/min per IP globally, 10/min shared across all credential
endpoints. Behind the ingress this keys on the proxy IP unless
`TRUSTED_PROXIES` is set.

View File

@@ -0,0 +1,360 @@
# Logistics pickup-source and base-handover flow
The backend contract for requests 25–31 on the Miler logistics line. Written as
the answer to that register: what shipped, what the wire values are, and the
state transitions for each of the three journeys.
**Vocabulary.** The wire says *hub* — `inward_at_hub`, `Inwarded_at_Hub`,
`next_hub`, `pickup_source_type: "hub"`. The rider app renders that as *Base*.
Nothing here changes a wire value to match the app's wording, and the app's
wording never leaks back into this API. Console and backend keep saying hub.
---
## The flag
`MILER_HUB_HANDOVER_ENABLED` (env, read per request, **default off**).
| | off (today) | on |
|---|---|---|
| A hub-routed parcel at pickup-complete | `Inwarded_at_Hub` immediately | `Created` — collected, in the rider's hands |
| `next_action` returned | `handed_to_hub` | `inward_at_hub` |
| Rider's assignment | closed at pickup-complete | closed at the handover |
| Rider availability after pickup | `Available` | `Picked_Up` (still carrying) |
| Base sees it on `/hub/inbound/expected` | no — it is already received | yes |
Off is not a placeholder: it is what the currently deployed rider app expects. A
build that cannot call the handover endpoint would, with the flag on, collect an
intercity parcel and have no way to advance it — the parcel would sit on
`Created` in the rider's queue and appear in no base's received list. Turn it on
when a rider build that calls `inward-at-hub` is live:
```bash
kubectl -n doormile set env statefulset/doormile MILER_HUB_HANDOVER_ENABLED=true
```
Write it into `/opt/kubernetes/manifests/doormile/miletruth.yaml` at the same
time, or the next `kubectl apply` reverts it (see `DEV_ONBOARDING.md` §2.3).
**Everything else below is ungated** and live regardless of the flag: `next_hub`,
the handover endpoint, `next_action`/`next_hub` on the queue read,
`pickup_source_type`, base master data, inbound visibility, reconciliation and
the routing block.
---
## State transitions — the three journeys
`consignmentstatus` is the consignment's own state; `booking.status` moves to
`Converted_To_Consignment` at pickup-complete in all three and stops there.
### Base/Hub H1 → Customer
Pickup source is a base; the parcel then goes to a person. Routing is decided by
pincode, exactly as for any other pickup — a base-origin booking delivering into
the same postal area is hyperlocal.
| Step | Call | `consignmentstatus` | `next_action` |
|---|---|---|---|
| assigned | — | (no consignment yet) | `pickup` |
| collected at the base | `POST /miler/bookings/:id/pickup-complete` | `Collected_By_Miler` | `start_delivery` |
| heading out | `POST /miler/consignments/:id/start-delivery` | `Out_for_Delivery` | `deliver` |
| delivered | `POST /miler/consignments/:id/deliver` | `Delivered` | `none` |
The booking row carries `pickup_source_type: "hub"` and `sourceid` /
`pickuplocationid` = the base id, so Home names the base as the pickup source
rather than the rider's own office.
With `MILER_COLLECTED_STATE_ENABLED` off, pickup-complete goes straight to
`Out_for_Delivery` / `deliver` and there is no start-delivery step. That flag is
already `true` in production.
### Customer → Customer (hyperlocal)
Identical to the table above from pickup-complete onward; the only difference is
`pickup_source_type: "customer"` and `sourceid: null`, with the sender's own name
and address on the row.
### Customer → Base (intercity / interstate)
| Step | Call | `consignmentstatus` | `next_action` | `next_hub` |
|---|---|---|---|---|
| assigned | — | (no consignment yet) | `pickup` | null |
| collected | `POST /miler/bookings/:id/pickup-complete` | `Created` | `inward_at_hub` | the base, six fields |
| handed over at the base | `POST /miler/consignments/:id/inward-at-hub` | `Inwarded_at_Hub` | `handed_to_hub` | null |
After `Inwarded_at_Hub` the parcel is the network's problem, not the rider's —
tripsheet, transit, and a final-mile rider at the other end.
**With the flag off**, the middle row does not exist: pickup-complete returns
`Inwarded_at_Hub` / `handed_to_hub` directly, still with `next_hub` populated so
the app can name the base. `inward-at-hub` called against such a parcel answers
200 with the state that stands and `already_inwarded: true`, rather than failing.
---
## What changed, request by request
### 25 — `next_hub` on pickup-complete
`POST /miler/bookings/:bookingid/pickup-complete` now returns `next_hub` whenever
the parcel's next leg is a base, with all six fields:
```jsonc
{
"tracking_no": "DM...",
"consignment_id": 4821, // always present
"consignmentstatus": "Created",
"status": "Created", // alias, same value
"booking_no": "BK...",
"booking_status": "Converted_To_Consignment",
"next_action": "inward_at_hub",
"next_hub": {
"id": 1,
"name": "Coimbatore Hub",
"address": "14 Avinashi Road, Peelamedu, Coimbatore",
"pincode": "641004",
"latitude": 11.0272,
"longitude": 76.9905
}
}
```
`next_hub` is absent for a hyperlocal parcel — there is no base leg.
**Which base.** `resolveHandoverHub`, in order: the base the booking was routed
to (`nearesthubid`, nothing populates this column today — it is checked first so
that it wins the moment something does), then the collecting rider's own base
(the operational default), then the nearest **active** base to the pickup point,
then any base at all. The app never chooses; it navigates to what it is given.
The nearest-active-base step replaced a fallback that took whichever hub row came
back first from an unordered query.
### 26 — the handover mutation
```
POST /miler/consignments/:id/inward-at-hub
Idempotency-Key: <optional, same middleware as pickup-complete>
{ "hub_id": 1, "latitude": 11.0272, "longitude": 76.9905 }
```
`hubid` is accepted as an alias for `hub_id`; `lat`/`lon` for
`latitude`/`longitude`. The whole body is optional — with nothing sent, the parcel
is handed into the base it was already routed to.
```jsonc
{
"consignmentid": 4821,
"trackingno": "DM...",
"consignmentstatus": "Inwarded_at_Hub",
"inwardedat": "2026-09-02T14:22:10Z",
"hub": { "id": 1, "name": "...", "address": "...", "pincode": "...",
"latitude": 11.0272, "longitude": 76.9905 },
"next_action": "handed_to_hub",
"already_inwarded": false
}
```
It names the resulting state, per the rule request 15 exists for. Idempotent
twice over: the route carries the shared `Idempotency-Key` middleware, and a
parcel already inwarded answers 200 with `already_inwarded: true` rather than a
4xx — a retry after a dropped response confirms instead of erroring.
Side effects, all in one transaction: status and `currenthubid` set, `inwardedat`
stamped, a `consignmenthistory` row written with the rider's coordinates, the
`BookingAssignment` closed as `Completed` with `riderkms` (pickup → base gate) and
`ridercharges`, and the rider returned to `Available`. Before this, an intercity
rider's every job reported zero distance and zero value on `/miler/earnings`.
Errors: `CONSIGNMENT_NOT_FOUND` (404), `CONSIGNMENT_NOT_ASSIGNED` (403),
`HUB_REQUIRED` / `HUB_NOT_FOUND` (400/404), `INVALID_STATE` (400) for a parcel
already out for delivery or past this leg.
### 27 — `next_action` and `next_hub` on the queue read
Every row of `GET /miler/bookings` now carries both, derived from server state on
each read by the same helper pickup-complete uses — the pivot's answer and the
poll's answer cannot drift.
| consignment state | `next_action` | `next_hub` |
|---|---|---|
| no consignment yet | `pickup` | null |
| `Created` | `inward_at_hub` | the base |
| `Collected_By_Miler` | `start_delivery` | null |
| `Out_for_Delivery` | `deliver` | null |
| `Inwarded_at_Hub` | `handed_to_hub` | null |
| anything terminal | `none` | null |
`GET /miler/consignments/:consignmentid` carries the same pair, plus
`can_inward_at_hub` and `inwardedat`, so a single-parcel refresh is as
authoritative as a full poll.
### 28 — `pickup_source_type` on the booking row
On the row, never on a location master — a customer-door pickup has no location
id at all, so a type held against locations could never classify one.
```jsonc
{
"bookingid": 4821,
"pickup_source_type": "hub", // hub | customer | merchant | store
"sourceid": 1, // null for a customer door
"pickuplocationid": 1, // alias, same value
"pickup_source_name": "Coimbatore Hub",
"pickupaddress": "14 Avinashi Road, Peelamedu, Coimbatore",
"pickuppincode": "641004",
"deliverypincode": "600001"
}
```
Sent on every booking, with `"customer"` as a value rather than an omission.
`pickuplocationid` on this row is the source id — not the `pickuplocationid`
column on `pickupbookings`, which foreign-keys to `appcustomerlocations` and is a
different concept. The booking row never carried either spelling before, so
nothing is being redefined out from under a reader.
Storage is the new `pickupbookings.pickupsourcetype` column, written at creation.
Rows created before it existed are classified on read: names a base → `hub`,
names a client site → `merchant`, otherwise → `customer`. A stored value always
wins. An unrecognised type is dropped at write rather than stored, so the column
never holds a word the app has no meaning for.
### 29 — base master data
`Hub` already carried all six fields; what was missing was a route a rider token
could read. `/admin/tenants/:id/locations` is a different dataset — a client's own
sites, not bases — and `/admin/*` requires roles 1/3/4 while a rider is role 5, so
that 401 is by design, not an oversight.
```
GET /miler/bases ?status=Active (default) &applocationid=
```
Returns `{id, name, address, pincode, latitude, longitude}` per base, plus
`distance_km` and nearest-first ordering when the rider has reported a position.
`GET /admin/hubs` (console) already returns full hub rows and is unchanged.
### 30 — inbound visibility and receiving
```
GET /hub/inbound/expected on the way in, not yet handed over
POST /hub/inbound/:id/reconcile { "received": true|false, "remarks": "..." }
```
`expected` lists consignments on `Created` whose current base is this one —
rider, source and source type, customer, pickup and destination address,
destination pincode, current state, `inbound_status: "expected"`. Tenant-scoped:
partner-tenant staff see only their own client's parcels
(`scopeConsignmentsToOwnTenant`, the consignment counterpart of the existing
booking scoping).
`reconcile` is the receiving side. `received: true` inwards the parcel and is
idempotent — staff working through a pile will hit rows twice. `received: false`
is the dispute path: it does **not** quietly move the parcel backwards, it raises
an open `ConsignmentException` naming the discrepancy, so a parcel a rider swears
was handed over and staff never saw becomes a tracked item rather than an
argument nobody owns.
The exception type is `Lost` — the closest value the `consignmentexceptions`
CHECK constraint already permits. A dedicated `Handover_Not_Received` type would
need that constraint widened first (see `DEV_ONBOARDING.md` §2.5).
The pre-existing console inwarding path (`POST /hub/bookings/:id/inbound`) still
works and now stamps `inwardedat` too.
### 31 — the routing decision on booking detail
`GET /admin/bookings/:id` keeps every field it returned and adds `routing`
alongside them:
```jsonc
"routing": {
"pickup_source_type": "customer",
"pickup_source_id": null,
"pickup_source_name": "Anitha R",
"from_address": "12 Race Course Road, Coimbatore",
"from_pincode": "641018",
"to_address": "44 Mount Road, Chennai",
"destination_pincode": "600002",
"is_hyperlocal": false,
"consignment_state": "Created",
"next_action": "inward_at_hub",
"next_hub": { "id": 1, "name": "Coimbatore Hub", ... },
"inwardedat": null,
"decided": true
}
```
`decided` is false before pickup, when the routing result is a projection from
the captured from/to rather than a decision that has been taken. `is_hyperlocal`
is computed by the same helper pickup-complete uses, so the shown reason cannot
disagree with the actual routing.
---
## Route sequencing knows about the base
`internal/routing` orders a rider's active stops via the Route Optimization API.
It read `pickupbookings.deliverylatitude` for every assignment, with no idea
whether the parcel was hub-routed — so a Coimbatore → Chennai booking told the
optimizer the rider was riding 430 km to the receiver, when the real next stop is
a base a few kilometres away. One such destination in a rider's set also drags
the ordering of every genuine local stop beside it, because the solver is
optimising a journey nobody is going to make.
`dropForLeg` now decides where THIS rider's leg ends: the receiver for a
hyperlocal parcel, the base for a hub-routed one. The base comes from the same
order of preference as `resolveHandoverHub` — the booking's `nearesthubid` if
anything set it, otherwise the rider's own base — joined in by the stop query. A
hub-routed stop with no usable base coordinates is left unsequenced rather than
pointed at the receiver: one missing stop is better than a skewed route.
The final destination is not lost. It is simply not this leg — it belongs to
whoever carries the parcel out of the base.
**`internal/legs`** exists for this. The hyperlocal rule is needed by
`controllers` (which state a consignment lands in) and by `internal/routing`
(where the leg ends), and `controllers` already imports `internal/routing`, so
routing cannot import back. Rather than keep a second copy of the rule — the
shape that has bitten this codebase before — it lives in a package both import.
`controllers.haversineKM`, `isHyperlocal` and `isHyperlocalBooking` are now thin
delegates, so their existing call sites and tests are unchanged.
---
## Schema
Three additive, nullable columns, applied by `AutoMigrate` on the next deploy. No
CHECK constraint needed widening — `Created` was already permitted on
`consignments`.
| Table | Column | Why |
|---|---|---|
| `pickupbookings` | `pickupsourcetype varchar(20)` | request 28 |
| `pickupbookings` | `pickuphubid int` | base-origin pickups; distinct from `nearesthubid`, which is the base a parcel is routed **to** |
| `consignments` | `inwardedat timestamp` | the physical-receipt fact, distinct from `updatedat`, which moves on every write |
---
## Still open on this line
- **15** — `reached` persists the arrival fact (`arrivedat`, returned as
`reachedat` on the booking row) but the booking status does not move to
`Arrived_At_Pickup`. Half done; not touched by this work.
- **16** — console rendering for `Arrived_At_Pickup` and `Collected_By_Miler`.
- **14** — a failed-delivery outcome; `skip` still leaves the consignment
`Out_for_Delivery`.
- **`At_Customer` — answered.** It means **arrived at the pickup**. It is a
`milerprofiles.availabilitystatus` value, not a booking or consignment state,
so it says where the *rider* is rather than where the *parcel* is, and the only
thing that writes it is `POST /miler/bookings/:bookingid/reached`
(`BookingReachedCustomer`, `milerController.go`) — the pickup-arrival action.
Nothing sets it on a delivery leg; a rider heading to a receiver goes
`On_Delivery`. The name is misleading and predates the current lifecycle.
- **`reject` — answered.** `RejectMilerAssignment` accepts the reason **either
way**: it parses the JSON body first and falls back to `?reason=`, defaulting to
"Rejected by rider" if neither is present. The doc/deployed disagreement was
settled by accepting both, so the app can keep sending both.

View File

@@ -1,6 +1,6 @@
# Doormile Miler App — API reference
The rider-app surface only (`/miler/*`). 38 routes: 3 auth + 35 authenticated.
The rider-app surface only (`/miler/*`). 45 routes: 3 auth + 42 authenticated.
Base URL `https://api.doormile.com/api/v1`.
This supersedes "Miler App API Contract v1.0" where the two disagree — several
@@ -48,13 +48,14 @@ Credential endpoints share a **10/min** rate limit.
---
## Profile & device
## Profile, device & uploads
| Method | Path | Body |
|---|---|---|
| GET | `/miler/profile` | |
| GET | `/miler/profile` | Exposes `tenantname`, profile details, vehicle info |
| PUT | `/miler/profile` | `{ displayname, profilephotourl, defaultvehicletype, phone }` |
| PUT | `/miler/device-token` | `{ "device_token": "..." }` — snake_case |
| POST | `/miler/uploads/sign` | `{ "content_type": "image/jpeg", "kind": "pod" }` → `{ "upload_url": "...", "key": "..." }` |
## Location & availability
@@ -126,6 +127,44 @@ prefix it's hyperlocal and the consignment goes straight to `Out_for_Delivery`
in the rider's hands. Otherwise it routes via the hub. The consignment inherits
the **booking's** tenant, not the rider's.
It returns `next_action` and, when the next leg is a base, `next_hub` with all
six fields (`id, name, address, pincode, latitude, longitude`) — the app never
picks a base itself. `consignment_id` is always present.
```jsonc
{ "consignment_id": 4821, "consignmentstatus": "Created",
"next_action": "inward_at_hub",
"next_hub": { "id": 1, "name": "Coimbatore Hub",
"address": "14 Avinashi Road, Peelamedu, Coimbatore",
"pincode": "641004", "latitude": 11.0272, "longitude": 76.9905 } }
```
## The base handover
```
POST /miler/consignments/:id/inward-at-hub Idempotency-Key supported
{ "hub_id": 1, "latitude": 11.0272, "longitude": 76.9905 }
→ { consignmentstatus: "Inwarded_at_Hub", inwardedat, hub, next_action,
already_inwarded }
```
The authoritative record that a rider handed a parcel in at a base. The body is
optional (it defaults to the base the parcel is routed to); `hubid`, `lat` and
`lon` are accepted as aliases. Answers with the resulting state, never a bare
200. A parcel already inwarded answers 200 with `already_inwarded: true`.
```
GET /miler/bases ?status=Active &applocationid=
```
Base master data on a rider token — the six fields per base, plus `distance_km`
and nearest-first ordering once the rider has reported a position.
`/admin/tenants/:id/locations` is a different dataset (a client's own sites) and
is closed to role 5 by design.
**Wording:** the wire says hub, the rider app says Base. Full contract and state
transitions in [`logistics-base-handover.md`](logistics-base-handover.md).
## Delivery
| Method | Path | Body |
@@ -151,6 +190,20 @@ the **booking's** tenant, not the rider's.
| GET | `/miler/bookings` | `?status=&date=YYYY-MM-DD` |
| GET | `/miler/earnings` | `?period=daily\|weekly\|monthly&date=YYYY-MM-DD` |
Every `/miler/bookings` row carries the leg and the pickup source, rebuilt from
server state on each read, so a poll or a cold restart needs no local cache:
| Field | Values |
|---|---|
| `next_action` | `pickup`, `inward_at_hub`, `start_delivery`, `deliver`, `handed_to_hub`, `none` |
| `next_hub` | the six base fields, or null when the next leg isn't a base |
| `pickup_source_type` | `hub`, `customer`, `merchant`, `store` — always sent, `customer` is a value not an omission |
| `sourceid` / `pickuplocationid` | the base or client-site id; null for a customer door |
| `pickup_source_name` | the base/site name, or the sender's name for a door pickup |
An unrecognised `pickup_source_type` should be treated as a generic pickup — new
values may be added.
`bonuspoints` stays zero — nothing writes it yet. That's known and deliberate.
## Telemetry (Redis-backed, high frequency)
@@ -235,4 +288,9 @@ build a UI that depends on it.
decided.
2. `bonuspoints` is never written.
3. `assignments/:id/reject` and `bookings/:id/vehicle-required` have never had a
real request against them.
real request against them. (`reject` accepts its reason in the body *or* as
`?reason=`, preferring the body — both spellings are honoured.)
4. `At_Customer` on `milerprofiles.availabilitystatus` means **arrived at the
pickup** — it is written only by `POST /miler/bookings/:bookingid/reached`.
The name predates the current lifecycle; a rider heading to a receiver is
`On_Delivery`.

1495
docs/openapi-customer.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +1,10 @@
package dto
type CustomerRegisterRequest struct {
Firstname string `json:"firstname" xml:"firstname" form:"firstname"`
Lastname string `json:"lastname" xml:"lastname" form:"lastname"`
Phone string `json:"phone" xml:"phone" form:"phone"`
Email string `json:"email" xml:"email" form:"email"`
Pin string `json:"pin" xml:"pin" form:"pin"` // 4 or 6 digit PIN
Configid int `json:"configid"`
}
type CustomerLoginRequest struct {
Phone string `json:"phone" xml:"phone" form:"phone"`
Configid int `json:"configid"`
}
type CustomerPinVerifyRequest struct {
Phone string `json:"phone" xml:"phone" form:"phone"`
Pin string `json:"pin" xml:"pin" form:"pin"`
Configid int `json:"configid"`
DeviceToken string `json:"device_token"`
}
type CustomerResetPinRequest struct {
Phone string `json:"phone" xml:"phone" form:"phone"`
NewPin string `json:"new_pin" xml:"new_pin" form:"new_pin"`
Configid int `json:"configid"`
}
type SendEmailOtpRequest struct {
Email string `json:"email" xml:"email" form:"email"`
Phone string `json:"phone" xml:"phone" form:"phone"`
}
type VerifyEmailOtpRequest struct {
Email string `json:"email" xml:"email" form:"email"`
Otp string `json:"otp" xml:"otp" form:"otp"`
}
// The customer app has no password and no PIN: it authenticates on a 4-digit
// code sent to a phone or an email address, and its request shapes are declared
// inline in controllers/cxAuthController.go alongside the handlers that read
// them. The PIN register/login/verify/reset request types that used to live
// here went with that flow.
type MilerLoginRequest struct {
Phone string `json:"phone" xml:"phone" form:"phone"`

8
go.mod
View File

@@ -3,13 +3,17 @@ module doormile
go 1.25.0
require (
firebase.google.com/go/v4 v4.20.0
github.com/gofiber/fiber/v2 v2.52.10
github.com/gofiber/websocket/v2 v2.2.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.12.3
github.com/nats-io/nats.go v1.31.0
github.com/redis/go-redis/v9 v9.16.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.51.0
google.golang.org/api v0.279.0
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
)
@@ -25,7 +29,6 @@ require (
cloud.google.com/go/longrunning v1.0.0 // indirect
cloud.google.com/go/monitoring v1.29.0 // indirect
cloud.google.com/go/storage v1.62.1 // indirect
firebase.google.com/go/v4 v4.20.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect
@@ -41,7 +44,6 @@ require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gofiber/websocket/v2 v2.2.1 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
@@ -58,7 +60,6 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/nats-io/nats.go v1.31.0 // indirect
github.com/nats-io/nkeys v0.4.5 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
@@ -86,7 +87,6 @@ require (
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.279.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect

View File

@@ -8,6 +8,7 @@ import (
"doormile/constants"
"doormile/db"
"doormile/internal/cxstage"
"doormile/internal/routing"
"doormile/models"
"doormile/utils"
@@ -227,8 +228,24 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate,
return fmt.Errorf("update MilerProfile availability: %w", err)
}
// The customer's "Miler assigned" milestone, in the same transaction as the
// assignment it describes. The auto-assignment path is how most B2C
// bookings get a rider, so without this the customer app's timeline would
// only ever advance for manually assigned pickups.
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
Stage: constants.CxStageAssigned,
ActorType: constants.CxActorSystem,
Source: "internal/assignment.commitAssignment",
}); err != nil {
tx.Rollback()
return fmt.Errorf("record assigned stage: %w", err)
}
tx.Commit()
go cxstage.Notify(booking.Bookingid, nil, constants.CxStageAssigned)
utils.Info("CRMAssignment: assigned",
"booking_id", booking.Bookingid,
"miler_id", milerUserID,

425
internal/cxstage/stage.go Normal file
View File

@@ -0,0 +1,425 @@
// Package cxstage derives the customer app's view of a pickup from the writes
// the miler app already makes.
//
// This is the part of the customer API that is not a new endpoint. Every
// operational write it reacts to — accept, reached, parcel, pickup-complete,
// inward-at-hub, start-delivery, deliver, cancel — already existed and was
// already correct. What did not exist was any record of when a parcel reached a
// stage, in the vocabulary the customer is shown. A consignment status says
// where a parcel is now; it cannot say when it got there, and a timeline
// assembled from "now" plus guesses is the thing this package exists to avoid.
//
// So: one append-only row per stage actually reached, with the real time and
// the real actor, and the booking's current stage kept alongside it so a
// tracking poll is one row rather than a replay. Nothing here is backfilled.
// A booking that predates this package has a short history, and a short honest
// history beats a long invented one — the customer cannot tell which entries
// were guessed.
package cxstage
import (
"fmt"
"strconv"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/notify"
"doormile/models"
"doormile/utils"
"gorm.io/gorm"
)
// Event is one stage transition to record.
type Event struct {
BookingID int
// DestinationID is set for the per-order stages (in_transit onward), which
// may differ between destinations of the same booking, and nil for the
// booking-level stages that apply to the whole pickup.
DestinationID *int
Stage string
ActorType string // constants.CxActor*
ActorID *int
// Source names the write that caused this — the endpoint path or the job.
// It is what makes an audit trail answerable a year later.
Source string
Remarks string
// At is when it actually happened. Zero means now.
At time.Time
}
// Record appends the event and advances the stored stage, inside the caller's
// transaction.
//
// Pass the same *gorm.DB the operational write is using. A stage event that
// commits while the write it describes rolls back is a customer being told
// their parcel was collected when it was not — so the two share a transaction
// or neither happens.
//
// Recording is idempotent per (booking, destination, stage): a rider tapping
// "arrived" twice on bad signal produces one timeline entry, not two.
func Record(tx *gorm.DB, in Event) error {
if tx == nil || in.BookingID == 0 || in.Stage == "" {
return fmt.Errorf("cxstage: incomplete event")
}
if constants.CxStageRank(in.Stage) < 0 {
return fmt.Errorf("cxstage: unknown stage %q", in.Stage)
}
at := in.At
if at.IsZero() {
at = utils.DBNow()
}
if in.ActorType == "" {
in.ActorType = constants.CxActorSystem
}
// Only a booking the customer app is watching gets a projection.
//
// This guard is about blast radius, not tidiness. Record runs inside the
// caller's transaction and returns its error, so a failure here fails the
// operational write — and the operational writes it hooks into
// (assignMilerTx, commitAssignment, pickup-complete) also serve
// console-created express bookings, which no customer app has ever seen.
// Without this, a problem in the customer projection could refuse a hub
// staff member's manual assignment of a booking that has no customer
// attached to it at all.
var booking models.PickupBooking
if err := tx.Select("bookingid, bookingsource, customerstage, customerstatus, status").
First(&booking, in.BookingID).Error; err != nil {
return fmt.Errorf("cxstage: load booking: %w", err)
}
if booking.Bookingsource != constants.BookingSourceCustomerApp {
return nil
}
var existing int64
q := tx.Model(&models.BookingStageEvent{}).
Where("bookingid = ? AND stage = ?", in.BookingID, in.Stage)
if in.DestinationID != nil {
q = q.Where("bookingdestinationid = ?", *in.DestinationID)
} else {
q = q.Where("bookingdestinationid IS NULL")
}
if err := q.Count(&existing).Error; err != nil {
return fmt.Errorf("cxstage: count existing: %w", err)
}
if existing == 0 {
event := models.BookingStageEvent{
Bookingid: in.BookingID,
Bookingdestinationid: in.DestinationID,
Stage: in.Stage,
Actortype: in.ActorType,
Actorid: in.ActorID,
Source: in.Source,
Remarks: in.Remarks,
Occurredat: at,
}
if err := tx.Create(&event).Error; err != nil {
return fmt.Errorf("cxstage: append event: %w", err)
}
}
if in.DestinationID != nil {
if err := advanceDestination(tx, *in.DestinationID, in.Stage, at); err != nil {
return err
}
}
return advanceBooking(tx, &booking, in.Stage)
}
// advanceDestination moves one order forward. Never backwards: a re-delivery
// attempt that re-issues out_for_delivery must not un-deliver a sibling, and a
// late-arriving event must not rewind a parcel the customer has already been
// told arrived.
func advanceDestination(tx *gorm.DB, destinationID int, stage string, at time.Time) error {
var dest models.BookingDestination
if err := tx.First(&dest, destinationID).Error; err != nil {
return fmt.Errorf("cxstage: load destination: %w", err)
}
if constants.CxStageRank(stage) <= constants.CxStageRank(dest.Stage) {
return nil
}
updates := map[string]interface{}{"stage": stage, "updatedat": utils.DBNow()}
if stage == constants.CxStageDelivered {
updates["deliveredat"] = at
}
return tx.Model(&models.BookingDestination{}).
Where("bookingdestinationid = ?", destinationID).Updates(updates).Error
}
// advanceBooking recomputes the booking's own stage and status.
//
// For stages 0-5 that is simply "the furthest stage reached". For 6-8 it is the
// LEAST-advanced destination, because a booking is only out for delivery when
// everything in it is, and only delivered when the last parcel lands. Taking
// the maximum instead would show a customer "Delivered" while one of their
// three parcels was still at a hub.
func advanceBooking(tx *gorm.DB, booking *models.PickupBooking, stage string) error {
bookingID := booking.Bookingid
if booking.Customerstatus == constants.CxStatusCancelled {
// A cancelled pickup is terminal. Late telemetry from a rider who was
// already stood down must not resurrect it.
return nil
}
next := stage
if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStageInTransit] {
var destinations []models.BookingDestination
if err := tx.Select("bookingdestinationid, stage").
Where("bookingid = ?", bookingID).Find(&destinations).Error; err != nil {
return fmt.Errorf("cxstage: load destinations: %w", err)
}
next = slowestDestinationStage(destinations, stage)
}
if constants.CxStageRank(next) <= constants.CxStageRank(booking.Customerstage) {
return nil
}
status := constants.CxStatusActive
if next == constants.CxStageDelivered {
status = constants.CxStatusCompleted
}
return tx.Model(&models.PickupBooking{}).
Where("bookingid = ?", bookingID).
Updates(map[string]interface{}{
"customerstage": next,
"customerstatus": status,
"updatedat": utils.DBNow(),
}).Error
}
// slowestDestinationStage returns the least-advanced stage across a booking's
// orders, which is the booking's real progress.
func slowestDestinationStage(destinations []models.BookingDestination, fallback string) string {
if len(destinations) == 0 {
return fallback
}
slowest := ""
for _, d := range destinations {
s := d.Stage
if s == "" {
// An order that has not reported anything yet holds the booking at
// order_created — it exists, it just has not moved.
s = constants.CxStageOrderCreated
}
if slowest == "" || constants.CxStageRank(s) < constants.CxStageRank(slowest) {
slowest = s
}
}
if slowest == "" {
return fallback
}
return slowest
}
// Release walks a booking BACK to booked because its rider let it go.
//
// A miler cancelling or skipping does not cancel the pickup — it returns it to
// the pool for reassignment. Without this the customer keeps seeing "Miler
// assigned" against a booking that has no miler, and the screen shows a rider
// card for someone who is no longer coming. Stage is the only thing that moves
// backwards: the history entries for assigned and on_the_way stay, because
// those things did happen, and when a new rider accepts, Record's dedupe means
// no second copy is appended.
//
// This is the one exception to "stages only advance", and it is deliberate:
// the alternative is showing the customer a rider who is not on their way.
func Release(tx *gorm.DB, bookingID int, reason, actorType string, actorID *int, source string) error {
if !isCustomerBooking(tx, bookingID) {
return nil
}
now := utils.DBNow()
if err := tx.Model(&models.PickupBooking{}).
Where("bookingid = ?", bookingID).
Updates(map[string]interface{}{
"customerstage": constants.CxStageBooked,
"customerstatus": constants.CxStatusActive,
"updatedat": now,
}).Error; err != nil {
return fmt.Errorf("cxstage: release booking: %w", err)
}
event := models.BookingStageEvent{
Bookingid: bookingID,
Stage: constants.CxStageBooked,
Actortype: actorType,
Actorid: actorID,
Source: source,
Remarks: "released: " + reason,
Occurredat: now,
}
if err := tx.Create(&event).Error; err != nil {
return fmt.Errorf("cxstage: record release: %w", err)
}
return nil
}
// Cancel marks a whole pickup cancelled and records who did it.
//
// Cancellation is whole-pickup: there is no partial cancel in v1, so every
// destination goes with it. The reason is stored because a customer who did not
// press the button — a miler skip, an ops stand-down — is otherwise told their
// pickup vanished with no explanation.
func Cancel(tx *gorm.DB, bookingID int, reason, actorType string, actorID *int, source string) error {
if !isCustomerBooking(tx, bookingID) {
return nil
}
now := utils.DBNow()
if err := tx.Model(&models.PickupBooking{}).
Where("bookingid = ?", bookingID).
Updates(map[string]interface{}{
"status": constants.BookingCancelled,
"customerstatus": constants.CxStatusCancelled,
"cancelreason": reason,
"updatedat": now,
}).Error; err != nil {
return fmt.Errorf("cxstage: cancel booking: %w", err)
}
// Recorded on the timeline as an event with a real actor, not as a silent
// status flip. "cancelled" is not one of the nine stages the client parses,
// so it rides the event log for the audit trail and the client reads
// status/cancelReason for the display.
event := models.BookingStageEvent{
Bookingid: bookingID,
Stage: constants.CxStageBooked,
Actortype: actorType,
Actorid: actorID,
Source: source,
Remarks: "cancelled: " + reason,
Occurredat: now,
}
// Deliberately not deduped against the booked event: this row is the
// cancellation record, distinguished by its remarks, and losing it would
// leave the cancellation unattributed.
if err := tx.Create(&event).Error; err != nil {
return fmt.Errorf("cxstage: record cancellation: %w", err)
}
return nil
}
// ── Notification ─────────────────────────────────────────────────────────────
// Notify pushes a stage change to the customer's devices. Call it AFTER the
// transaction commits — a notification for a rolled-back write cannot be
// recalled.
//
// Only customer-visible milestones are sent. on_the_way and order_created roll
// up on the timeline rather than earning their own notification: a customer who
// gets a buzz for every operational transition stops reading them, and then
// misses the one that mattered.
func Notify(bookingID int, destinationID *int, stage string) {
title, body, send := milestoneCopy(stage)
if !send {
return
}
var booking models.PickupBooking
if err := db.DB.Select("bookingid, bookingno, appcustomerid").
First(&booking, bookingID).Error; err != nil {
utils.Warn("cxstage: could not load booking for notification", "booking_id", bookingID, "error", err)
return
}
trackingID := ""
if destinationID != nil {
var dest models.BookingDestination
if err := db.DB.Select("trackingno").First(&dest, *destinationID).Error; err == nil {
trackingID = dest.Trackingno
}
}
payload := map[string]string{
"type": "stage_change",
"reference": booking.Bookingno,
"stage": stage,
"title": title,
"body": body,
// Sent from day one even though the client has no intent filter yet —
// the app starts honouring it in a later build, and a notification
// already in someone's tray should still open the right screen then.
"deepLink": "doormile://track/" + booking.Bookingno,
}
if trackingID != "" {
payload["trackingId"] = trackingID
payload["deepLink"] = "doormile://track/" + trackingID
}
payload["booking_id"] = strconv.Itoa(bookingID)
for _, token := range deviceTokens(booking.Appcustomerid) {
if err := notify.SendToDevice(token, title, body, payload); err != nil {
utils.Warn("cxstage: push failed", "booking_id", bookingID, "error", err)
}
}
}
// milestoneCopy maps a stage to the notification the customer reads, and says
// whether it earns one at all.
func milestoneCopy(stage string) (title, body string, send bool) {
switch stage {
case constants.CxStageAssigned:
return "Miler assigned", "Your Miler is on the way to collect your packages.", true
case constants.CxStageArrived:
return "Your Miler has arrived", "They are at your pickup address now.", true
case constants.CxStagePickedUp:
return "Packages collected", "Your Miler has collected and weighed your packages.", true
case constants.CxStageInTransit:
return "In transit", "Your package is on its way to the destination.", true
case constants.CxStageOutForDelivery:
return "Out for delivery", "Arriving today at the delivery address.", true
case constants.CxStageDelivered:
return "Delivered", "Your package has been handed over.", true
default:
// booked (the customer just made it), on_the_way and order_created all
// roll up on the timeline.
return "", "", false
}
}
// deviceTokens returns every device the customer is signed in on. One row per
// device, not one column on the customer, so a phone and a tablet both get the
// update instead of only whichever registered last.
func deviceTokens(customerID int) []string {
var devices []models.CustomerDevice
if err := db.DB.Select("token").Where("appcustomerid = ?", customerID).
Find(&devices).Error; err != nil {
utils.Warn("cxstage: device lookup failed", "customer_id", customerID, "error", err)
return nil
}
tokens := make([]string, 0, len(devices))
for _, d := range devices {
if d.Token != "" {
tokens = append(tokens, d.Token)
}
}
return tokens
}
// RecordAndNotify is the convenience the operational handlers use: record
// inside the transaction, then push once it has committed. The caller is
// responsible for calling the returned function only after a successful commit.
func RecordAndNotify(tx *gorm.DB, in Event) (afterCommit func(), err error) {
if err := Record(tx, in); err != nil {
return func() {}, err
}
bookingID, destinationID, stage := in.BookingID, in.DestinationID, in.Stage
return func() { go Notify(bookingID, destinationID, stage) }, nil
}
// isCustomerBooking reports whether a booking is one the customer app is
// watching. Cancel and Release are called from paths that also serve
// console-created express bookings, and those have no customer projection to
// keep in step — writing one would put stage rows against bookings nobody will
// ever read them for, and would fail an ops action if that write failed.
func isCustomerBooking(tx *gorm.DB, bookingID int) bool {
var booking models.PickupBooking
if err := tx.Select("bookingid, bookingsource").First(&booking, bookingID).Error; err != nil {
utils.Warn("cxstage: could not read booking source", "booking_id", bookingID, "error", err)
return false
}
return booking.Bookingsource == constants.BookingSourceCustomerApp
}

View File

@@ -0,0 +1,126 @@
package cxstage
import (
"testing"
"doormile/constants"
"doormile/models"
)
// A booking's own stage, once its parcels have split into separate orders, is
// the LEAST-advanced of them. Taking the maximum instead would tell a customer
// "Delivered" while one of their three parcels was still sitting at a hub —
// which is the single most damaging thing this projection could get wrong.
func TestSlowestDestinationStageHoldsAtTheLaggingOrder(t *testing.T) {
destinations := []models.BookingDestination{
{Bookingdestinationid: 1, Stage: constants.CxStageDelivered},
{Bookingdestinationid: 2, Stage: constants.CxStageInTransit},
{Bookingdestinationid: 3, Stage: constants.CxStageOutForDelivery},
}
got := slowestDestinationStage(destinations, constants.CxStageDelivered)
if got != constants.CxStageInTransit {
t.Errorf("slowestDestinationStage = %q, want %q — a booking is only as far along as its slowest parcel",
got, constants.CxStageInTransit)
}
}
func TestSlowestDestinationStageCompletesOnlyWhenAllLand(t *testing.T) {
destinations := []models.BookingDestination{
{Bookingdestinationid: 1, Stage: constants.CxStageDelivered},
{Bookingdestinationid: 2, Stage: constants.CxStageDelivered},
}
if got := slowestDestinationStage(destinations, constants.CxStageDelivered); got != constants.CxStageDelivered {
t.Errorf("slowestDestinationStage = %q, want %q", got, constants.CxStageDelivered)
}
}
func TestSlowestDestinationStageTreatsSilentOrderAsOrderCreated(t *testing.T) {
// An order that exists but has reported nothing yet holds the booking at
// order_created. An empty stage must never sort as "furthest along".
destinations := []models.BookingDestination{
{Bookingdestinationid: 1, Stage: constants.CxStageOutForDelivery},
{Bookingdestinationid: 2, Stage: ""},
}
got := slowestDestinationStage(destinations, constants.CxStageOutForDelivery)
if got != constants.CxStageOrderCreated {
t.Errorf("slowestDestinationStage with a silent order = %q, want %q", got, constants.CxStageOrderCreated)
}
}
func TestSlowestDestinationStageFallsBackWithNoDestinations(t *testing.T) {
// A console-created booking has no destination rows at all. It must keep
// the stage the caller was recording, not collapse to order_created.
got := slowestDestinationStage(nil, constants.CxStageOutForDelivery)
if got != constants.CxStageOutForDelivery {
t.Errorf("slowestDestinationStage(nil) = %q, want the fallback %q", got, constants.CxStageOutForDelivery)
}
}
// The nine stage keys are a wire contract: the client parses them verbatim and
// silently falls back to `booked` on anything it does not recognise, so a
// renamed or reordered key makes a moving parcel look un-started.
func TestStageRankOrderingIsTheContract(t *testing.T) {
ordered := []string{
constants.CxStageBooked,
constants.CxStageAssigned,
constants.CxStageOnTheWay,
constants.CxStageArrived,
constants.CxStagePickedUp,
constants.CxStageOrderCreated,
constants.CxStageInTransit,
constants.CxStageOutForDelivery,
constants.CxStageDelivered,
}
for i := 1; i < len(ordered); i++ {
if constants.CxStageRank(ordered[i]) <= constants.CxStageRank(ordered[i-1]) {
t.Errorf("stage %q does not rank after %q", ordered[i], ordered[i-1])
}
}
// An unknown stage must sort strictly BEHIND booked, not tie with it — a
// booking written before this surface existed has no stage, and a tie would
// let advanceBooking refuse to move it off nothing.
if constants.CxStageRank("") >= constants.CxStageRank(constants.CxStageBooked) {
t.Errorf("an empty stage ranks %d, which is not behind booked (%d)",
constants.CxStageRank(""), constants.CxStageRank(constants.CxStageBooked))
}
if constants.CxStageRank("teleported") != -1 {
t.Errorf("an unknown stage ranks %d, want -1", constants.CxStageRank("teleported"))
}
}
// Cancellation is allowed up to and including arrived, and refused from
// picked_up onward. The UI mirrors this to hide the button; the server is the
// authority, so the boundary is asserted here rather than trusted to a comment.
func TestCancellationClosesAfterArrived(t *testing.T) {
cancellable := []string{
"", // a pre-surface booking is still cancellable
constants.CxStageBooked,
constants.CxStageAssigned,
constants.CxStageOnTheWay,
constants.CxStageArrived,
}
for _, stage := range cancellable {
if !constants.CxCancellable(stage) {
t.Errorf("stage %q should still be cancellable", stage)
}
}
closed := []string{
constants.CxStagePickedUp,
constants.CxStageOrderCreated,
constants.CxStageInTransit,
constants.CxStageOutForDelivery,
constants.CxStageDelivered,
}
for _, stage := range closed {
if constants.CxCancellable(stage) {
t.Errorf("stage %q must not be cancellable — the parcel is already collected", stage)
}
}
}

71
internal/legs/legs.go Normal file
View File

@@ -0,0 +1,71 @@
// Package legs answers one question, in one place: does a parcel go straight to
// its receiver, or does it go via a base first?
//
// It exists because two packages need that answer and neither can import the
// other. controllers decides it at pickup-complete (which state the consignment
// lands in, and which base the rider is sent to); internal/routing needs it when
// it sequences a rider's stops, because a hub-routed parcel's next stop is the
// BASE, not the address on the booking. controllers already imports
// internal/routing, so routing cannot import back — and a second copy of the
// rule in routing is exactly the kind of duplication this codebase has been
// bitten by before (the rival status table in dispatchShared, the batch windows
// that drifted between two pages).
//
// So the rule lives here and both call it. Neither owns it.
package legs
import "math"
// HaversineKM is the straight-line distance between two points, in kilometres.
// One definition for the whole codebase — controllers.haversineKM delegates to
// it rather than keeping a second copy.
func HaversineKM(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadiusKM = 6371.0
toRad := func(deg float64) float64 { return deg * math.Pi / 180 }
dLat := toRad(lat2 - lat1)
dLon := toRad(lon2 - lon1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}
// MaxHyperlocalKM bounds the straight-line pickup→delivery distance under which
// a booking with a missing or unusable pincode is still treated as hyperlocal.
// It is ONLY consulted when the pincode rule cannot decide: console-created
// kitchen→customer bookings frequently carry accurate coordinates and no
// delivery pincode, and must not be routed through a base because of it.
const MaxHyperlocalKM = 30.0
// SamePostalArea reports whether two pincodes fall in the same 3-digit postal
// area. Pincodes shorter than 3 characters are treated as unknown rather than
// matching, so bad data falls back to the safe hub route instead of quietly
// claiming two parcels belong together.
func SamePostalArea(pickupPincode, deliveryPincode string) bool {
if len(pickupPincode) < 3 || len(deliveryPincode) < 3 {
return false
}
return pickupPincode[:3] == deliveryPincode[:3]
}
// IsHyperlocal decides whether a parcel can skip the base and be carried
// straight to the receiver by the collecting rider.
//
// The pincode-prefix rule decides outright when both pincodes are present — a
// matching prefix is hyperlocal, a differing one is genuinely inter-area, and
// distance does not get a vote. Pollachi is 40km from Coimbatore, closer than
// plenty of runs treated as local, and it is still a different postal area.
//
// Only when a pincode is missing or too short does the straight-line distance
// decide instead. With neither pincodes nor coordinates the answer is false,
// which routes via a base — the safe direction to be wrong in, because a parcel
// that reaches a base can still be forwarded, while one handed to a rider who
// cannot reach the receiver is stuck.
func IsHyperlocal(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng float64) bool {
if len(pickupPincode) >= 3 && len(deliveryPincode) >= 3 {
return SamePostalArea(pickupPincode, deliveryPincode)
}
if pLat != 0 && pLng != 0 && dLat != 0 && dLng != 0 {
return HaversineKM(pLat, pLng, dLat, dLng) <= MaxHyperlocalKM
}
return false
}

117
internal/legs/legs_test.go Normal file
View File

@@ -0,0 +1,117 @@
package legs
import "testing"
// The one definition of "does this parcel go via a base?".
//
// It lives in its own package because two callers need it and neither can
// import the other: controllers decides it at pickup-complete, internal/routing
// needs it to know where a rider's leg ends. A second copy in routing was the
// alternative, and this codebase has been bitten by that shape before — a rival
// status table that drifted until the map and the list showed the same parcel
// two different colours.
//
// So the rule is tested here, once, and both callers inherit it.
func TestSamePostalArea(t *testing.T) {
cases := []struct {
name string
pickup string
delivery string
want bool
}{
{"same 3-digit area is one zone", "641012", "641004", true},
{"an identical pincode is trivially one zone", "641012", "641012", true},
{"Coimbatore to Chennai is not", "641012", "600001", false},
{"Coimbatore to Pollachi is not, though it is close", "641012", "642001", false},
{"a short pincode proves nothing", "64", "641004", false},
{"an empty pincode proves nothing", "", "641004", false},
{"two empty pincodes do not match each other", "", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := SamePostalArea(tc.pickup, tc.delivery); got != tc.want {
t.Errorf("SamePostalArea(%q, %q) = %v, want %v", tc.pickup, tc.delivery, got, tc.want)
}
})
}
}
func TestIsHyperlocalPrefersThePincodeRule(t *testing.T) {
// Coimbatore pickup, Pollachi delivery: ~40 km apart, which is inside the
// distance fallback — but they are different postal areas, and when both
// pincodes are present the prefix rule decides outright. Distance does not
// get a vote, or a parcel would be routed one way on Tuesday and another on
// Wednesday because a geocoder moved a pin.
if IsHyperlocal("641025", "642001", 11.0168, 76.9558, 10.658, 77.008) {
t.Error("a different postal area must route via a base even when it is close")
}
// The mirror: same area, far apart. A 3-digit area can be large, and the
// prefix still decides.
if !IsHyperlocal("641025", "641999", 11.0168, 76.9558, 11.6, 77.5) {
t.Error("the same postal area must stay hyperlocal even when it is a long way across")
}
}
func TestIsHyperlocalFallsBackToDistance(t *testing.T) {
// Console-created bookings frequently carry accurate coordinates and no
// delivery pincode. Routing those through a base because of a missing field
// would send a kitchen-to-doorstep lunch order on a tour of the network.
if !IsHyperlocal("641025", "", 11.0168, 76.9558, 11.05, 77.01) {
t.Error("a short hop with no pincode should stay hyperlocal on the distance fallback")
}
if IsHyperlocal("641025", "", 11.0168, 76.9558, 13.0827, 80.2707) {
t.Error("a 430km haul with no pincode is not hyperlocal whatever the fallback")
}
}
func TestIsHyperlocalIsFalseWhenNothingCanBeProven(t *testing.T) {
// No usable pincodes and no usable coordinates. False routes via a base,
// which is the safe direction to be wrong in: a parcel that reaches a base
// can still be forwarded, while one handed to a rider who cannot reach the
// receiver is simply stuck.
if IsHyperlocal("", "", 0, 0, 0, 0) {
t.Error("with nothing to decide on, the answer must be the safe one")
}
if IsHyperlocal("64", "60", 0, 0, 0, 0) {
t.Error("unusable pincodes and no coordinates must not resolve to hyperlocal")
}
}
func TestIsHyperlocalTreatsAMissingCoordinateAsMissing(t *testing.T) {
// 0,0 is in the Gulf of Guinea. Treating it as a real position would make
// every un-geocoded booking look like a 7,000 km haul — or, worse, let two
// of them look like neighbours.
if IsHyperlocal("641025", "", 11.0168, 76.9558, 0, 0) {
t.Error("a 0,0 delivery point is an unset value, not a location near anything")
}
}
func TestHaversineKM(t *testing.T) {
// A known pair: Coimbatore to Chennai is roughly 430 km great-circle.
km := HaversineKM(11.0168, 76.9558, 13.0827, 80.2707)
if km < 400 || km > 460 {
t.Errorf("Coimbatore→Chennai = %.0f km, expected roughly 430", km)
}
if d := HaversineKM(11.0168, 76.9558, 11.0168, 76.9558); d != 0 {
t.Errorf("a point is %v km from itself, want 0", d)
}
// Symmetric, or distance-based decisions would depend on argument order.
a := HaversineKM(11.0168, 76.9558, 12.9716, 77.5946)
b := HaversineKM(12.9716, 77.5946, 11.0168, 76.9558)
if a != b {
t.Errorf("distance is not symmetric: %v vs %v", a, b)
}
}
func TestMaxHyperlocalKMBoundary(t *testing.T) {
// The fallback threshold is a real operational number, not a magic
// constant — a rider is expected to carry a parcel this far, and not
// further. Guarding it stops a silent widening.
if MaxHyperlocalKM != 30.0 {
t.Errorf("MaxHyperlocalKM = %v; changing it changes which parcels riders carry end to end", MaxHyperlocalKM)
}
}

View File

@@ -0,0 +1,141 @@
package routing
import (
"testing"
"doormile/internal/legs"
)
// Where a rider's leg actually ends.
//
// The bug these tests exist to prevent, found while testing the base-handover
// flow with a bulk sheet of intercity orders: the sequencer read
// pickupbookings.deliverylatitude for every active assignment, with no idea
// whether the parcel was hub-routed. For a Coimbatore → Chennai booking that
// told the route optimizer the rider was riding 430 km to the receiver, when
// the rider's real next stop is a base a few kilometres away.
//
// Wrong on its own — a 430 km "stop" is not a stop anyone makes — and worse in
// company: one such destination in a rider's set drags the ordering of every
// genuine local stop beside it, because the solver is optimising a journey
// nobody is going to make.
// Coimbatore pickup, and the local base a rider hands parcels to.
const (
pickPin = "641025"
pickLat, pickLng = 11.0168, 76.9558
baseLat, baseLng = 11.0272, 76.9905
localPin = "641004"
localLat, localLng = 11.029, 76.993
chennaiPin = "600001"
chennaiLat, chennaiLng = 13.091, 80.285
)
func TestDropForLeg(t *testing.T) {
cases := []struct {
name string
deliveryPin string
dLat, dLng float64
baseLat, baseLng float64
wantLat, wantLng float64
wantOK bool
why string
}{
{
name: "a hyperlocal parcel ends at the receiver",
deliveryPin: localPin, dLat: localLat, dLng: localLng,
baseLat: baseLat, baseLng: baseLng,
wantLat: localLat, wantLng: localLng, wantOK: true,
why: "same postal area — the collecting rider carries it all the way",
},
{
name: "an intercity parcel ends at the base, not in Chennai",
deliveryPin: chennaiPin, dLat: chennaiLat, dLng: chennaiLng,
baseLat: baseLat, baseLng: baseLng,
wantLat: baseLat, wantLng: baseLng, wantOK: true,
why: "this is the whole fix — the rider rides to the base",
},
{
name: "a nearby but different postal area still ends at the base",
deliveryPin: "642001", dLat: 10.658, dLng: 77.008,
baseLat: baseLat, baseLng: baseLng,
wantLat: baseLat, wantLng: baseLng, wantOK: true,
why: "Pollachi is 40km away; the prefix rule decides, not the distance",
},
{
name: "no pincode and a short hop still ends at the receiver",
deliveryPin: "", dLat: 11.05, dLng: 77.01,
baseLat: baseLat, baseLng: baseLng,
wantLat: 11.05, wantLng: 77.01, wantOK: true,
why: "the distance fallback puts it inside 30km",
},
{
name: "no pincode and a long haul ends at the base",
deliveryPin: "", dLat: chennaiLat, dLng: chennaiLng,
baseLat: baseLat, baseLng: baseLng,
wantLat: baseLat, wantLng: baseLng, wantOK: true,
why: "the distance fallback puts it far outside 30km",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
lat, lng, ok := dropForLeg(pickPin, tc.deliveryPin,
pickLat, pickLng, tc.dLat, tc.dLng, tc.baseLat, tc.baseLng)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v — %s", ok, tc.wantOK, tc.why)
}
if lat != tc.wantLat || lng != tc.wantLng {
t.Errorf("drop = (%v, %v), want (%v, %v) — %s", lat, lng, tc.wantLat, tc.wantLng, tc.why)
}
})
}
}
func TestDropForLegNeverSendsARiderInterstate(t *testing.T) {
// The regression guard, stated as the thing that actually matters rather
// than as a coordinate comparison: whatever the destination, the drop the
// sequencer is given must be somewhere a rider can plausibly ride to.
for _, d := range []struct {
name string
pin string
lat, lng float64
}{
{"Chennai", "600001", 13.091, 80.285},
{"Hyderabad", "500081", 17.44, 78.3489},
{"Bengaluru", "560066", 12.9698, 77.75},
{"Madurai", "625001", 9.9195, 78.119},
} {
t.Run(d.name, func(t *testing.T) {
lat, lng, ok := dropForLeg(pickPin, d.pin, pickLat, pickLng, d.lat, d.lng, baseLat, baseLng)
if !ok {
t.Fatal("a stop with a usable base must still be sequenced")
}
if lat == d.lat && lng == d.lng {
t.Fatalf("%s was passed to the optimizer as the rider's own drop", d.name)
}
if km := legs.HaversineKM(pickLat, pickLng, lat, lng); km > 50 {
t.Errorf("drop is %.0f km from the pickup — no rider is making that leg", km)
}
})
}
}
func TestDropForLegSkipsAHubRoutedStopWithNoBase(t *testing.T) {
// Falling back to the receiver here would be the original bug wearing a
// different hat: better to leave one stop unsequenced than to skew the
// ordering of every other stop the rider is carrying.
_, _, ok := dropForLeg(pickPin, chennaiPin, pickLat, pickLng, chennaiLat, chennaiLng, 0, 0)
if ok {
t.Error("a hub-routed stop with no base coordinates must be left out, not pointed at the receiver")
}
}
func TestDropForLegKeepsAHyperlocalStopWithNoBase(t *testing.T) {
// A hyperlocal parcel never needed a base, so a missing one is irrelevant
// to it and must not cost the rider a stop.
lat, lng, ok := dropForLeg(pickPin, localPin, pickLat, pickLng, localLat, localLng, 0, 0)
if !ok || lat != localLat || lng != localLng {
t.Errorf("hyperlocal stop = (%v, %v, %v), want the receiver and ok", lat, lng, ok)
}
}

View File

@@ -17,6 +17,7 @@ import (
"doormile/constants"
"doormile/db"
"doormile/internal/legs"
"doormile/models"
"doormile/utils"
)
@@ -183,13 +184,31 @@ func loadActiveStops(milerUserID int) ([]stop, error) {
Pickuplongitude float64
Deliverylatitude float64
Deliverylongitude float64
Pickuppincode string
Deliverypincode string
// The base this rider would hand a hub-routed parcel to: the one the
// booking was routed to if anything set it, otherwise the rider's own.
// Same order of preference as controllers.resolveHandoverHub.
Baselatitude float64
Baselongitude float64
}
// A rider's leg does NOT always end at the address on the booking. For an
// intercity parcel the rider carries it to a base and hands it over there;
// the receiver is somebody else's problem, on another vehicle, days later.
// The base coordinates are joined in here so the sequencer can use them as
// the real end of the leg — see the substitution below.
if err := db.DB.Table("bookingassignments AS ba").
Select(`ba.bookingassignmentid, ba.bookingid, b.bookingno,
b.pickuplatitude, b.pickuplongitude,
b.deliverylatitude, b.deliverylongitude`).
b.deliverylatitude, b.deliverylongitude,
b.pickuppincode, b.deliverypincode,
COALESCE(bh.latitude, rh.latitude, 0) AS baselatitude,
COALESCE(bh.longitude, rh.longitude, 0) AS baselongitude`).
Joins("JOIN pickupbookings AS b ON b.bookingid = ba.bookingid").
Joins("LEFT JOIN hubs AS bh ON bh.hubid = b.nearesthubid AND bh.deletedat IS NULL").
Joins("LEFT JOIN milerprofiles AS mp ON mp.userid = ba.mileruserid").
Joins("LEFT JOIN hubs AS rh ON rh.hubid = mp.hubid AND rh.deletedat IS NULL").
Where("ba.mileruserid = ? AND ba.assignmentstatus IN ?",
milerUserID,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
@@ -209,19 +228,60 @@ func loadActiveStops(milerUserID int) ([]stop, error) {
"assignment_id", r.Bookingassignmentid, "booking_id", r.Bookingid)
continue
}
dropLat, dropLng, ok := dropForLeg(
r.Pickuppincode, r.Deliverypincode,
r.Pickuplatitude, r.Pickuplongitude,
r.Deliverylatitude, r.Deliverylongitude,
r.Baselatitude, r.Baselongitude)
if !ok {
// Routed to a base, but no base has usable coordinates. Ordering it
// against the far-away receiver would distort every other stop, so it
// is left out of the route rather than allowed to skew it.
utils.Warn("routing: hub-routed stop has no base coordinates, leaving it unsequenced",
"assignment_id", r.Bookingassignmentid, "booking_id", r.Bookingid)
continue
}
stops = append(stops, stop{
AssignmentID: r.Bookingassignmentid,
BookingID: r.Bookingid,
BookingNo: r.Bookingno,
PickupLat: r.Pickuplatitude,
PickupLng: r.Pickuplongitude,
DeliveryLat: r.Deliverylatitude,
DeliveryLng: r.Deliverylongitude,
DeliveryLat: dropLat,
DeliveryLng: dropLng,
})
}
return stops, nil
}
// dropForLeg is where THIS rider's leg ends — which is not always the address
// on the booking.
//
// A hyperlocal parcel ends at the receiver. A hub-routed one ends at a base: the
// rider hands it over there and the receiver is somebody else's leg, on another
// vehicle, possibly days later. Sequencing a hub-routed parcel against the
// receiver's coordinates asks the optimizer to plan a ride to another state —
// wrong on its own terms, since a 430km "stop" is not a stop a rider makes, and
// worse in company: one intercity destination in the set drags the ordering of
// every genuine local stop beside it, because the solver is optimising a journey
// nobody is going to make.
//
// The final destination is not lost. It is simply not this leg.
//
// ok is false when the parcel is hub-routed and no base has usable coordinates.
// The caller drops the stop rather than falling back to the receiver, because a
// stop in the wrong country is more damaging to the route than a missing one.
func dropForLeg(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng, baseLat, baseLng float64) (lat, lng float64, ok bool) {
if legs.IsHyperlocal(pickupPincode, deliveryPincode, pLat, pLng, dLat, dLng) {
return dLat, dLng, true
}
if baseLat != 0 || baseLng != 0 {
return baseLat, baseLng, true
}
return 0, 0, false
}
// optimize calls the Route Optimization API and maps its answer back onto our
// assignment ids.
func optimize(stops []stop) ([]Result, error) {

191
internal/sms/http_sender.go Normal file
View File

@@ -0,0 +1,191 @@
package sms
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"doormile/utils"
)
// A provider-agnostic HTTP gateway, and the wiring that installs it.
//
// This package called itself "the seam, not the integration", with a logging
// sink standing in until a gateway was plugged in. The sink was never replaced:
// sms.Register had no callers anywhere in the tree, so every customer
// verification code since this surface shipped has gone to the application log
// and nowhere else. Two consequences, both live in production:
//
// 1. No customer can complete sign-in without somebody reading the server log
// to them. That is the single blocker on the customer app, and it is why
// the app's offline dev mode became the only practical way in — which in
// turn is why bookings "made" in it never reached the admin console.
// 2. Every OTP ever issued is sitting in log storage as plaintext. A
// credential in a log file is a credential in the wrong place.
//
// Rather than hard-coding one vendor, this posts to whatever gateway the
// deployment names. The Indian providers this would plausibly use — MSG91,
// Gupshup, Textlocal, Fast2SMS — all accept an authenticated POST carrying a
// destination and a body, so one templated request covers them and swapping
// vendors is configuration rather than a release.
//
// Configuration, all read once at startup. An absent SMS_GATEWAY_URL leaves the
// log sink exactly where it is, so this change cannot break a deployment that
// has not been configured yet:
//
// SMS_GATEWAY_URL endpoint to POST to; absent means "stay on the log sink"
// SMS_GATEWAY_METHOD HTTP method, default POST
// SMS_GATEWAY_AUTH Authorization header value, for vendors that use one
// SMS_GATEWAY_HEADER one extra "Name: value" header, for vendors with their own key header
// SMS_GATEWAY_BODY body template; {{phone}}, {{message}} and {{sender}} are substituted
// SMS_GATEWAY_TYPE content type, default application/json
// SMS_SENDER_ID the registered sender id, substituted as {{sender}}
const gatewayTimeout = 10 * time.Second
type httpSender struct {
url string
method string
auth string
headerName string
headerValue string
bodyTmpl string
contentType string
senderID string
client *http.Client
}
func (httpSender) Name() string { return "http-gateway" }
func (s httpSender) Send(phone, message string) error {
body := s.bodyTmpl
body = strings.ReplaceAll(body, "{{phone}}", phone)
body = strings.ReplaceAll(body, "{{message}}", jsonEscape(message))
body = strings.ReplaceAll(body, "{{sender}}", s.senderID)
ctx, cancel := context.WithTimeout(context.Background(), gatewayTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, s.method, s.url, bytes.NewReader([]byte(body)))
if err != nil {
return fmt.Errorf("sms: build gateway request: %w", err)
}
req.Header.Set("Content-Type", s.contentType)
if s.auth != "" {
req.Header.Set("Authorization", s.auth)
}
if s.headerName != "" {
req.Header.Set(s.headerName, s.headerValue)
}
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("sms: gateway unreachable: %w", err)
}
defer resp.Body.Close()
// Read a bounded slice of the response for the log. The gateway's reason
// for refusing — "insufficient balance", "DLT template not approved" — is
// the entire diagnosis, and it only ever appears in the body.
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// The number is masked and the message is NOT logged: the message
// contains the code, which is the thing this whole file exists to keep
// out of the log.
utils.Error("sms: gateway rejected the send",
"status", resp.StatusCode,
"phone", maskPhone(phone),
"response", strings.TrimSpace(string(snippet)))
return fmt.Errorf("sms: gateway returned %d", resp.StatusCode)
}
utils.Info("sms: code delivered", "phone", maskPhone(phone))
return nil
}
// jsonEscape makes a message safe to interpolate into a JSON body template.
// The OTP text carries no quotes today, but a template is a template and the
// next message to go through here will not be this one.
func jsonEscape(s string) string {
var b strings.Builder
for _, r := range s {
switch r {
case '"':
b.WriteString(`\"`)
case '\\':
b.WriteString(`\\`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
default:
b.WriteRune(r)
}
}
return b.String()
}
// Configure installs a real gateway when one is configured, and says plainly
// which transport the process ended up with.
//
// Called once from main() after config load. Deliberately loud in both
// directions: a deployment that believes it can send texts and cannot is the
// exact failure that has been live in this service since the customer surface
// shipped, so it must not be possible to start without the answer appearing in
// the boot log.
func Configure() {
url := strings.TrimSpace(os.Getenv("SMS_GATEWAY_URL"))
if url == "" {
utils.Warn("SMS: no gateway configured (SMS_GATEWAY_URL is unset). " +
"Verification codes are written to THIS LOG and no text is sent. " +
"Customer sign-in cannot complete unless somebody reads the code out " +
"of here, or CX_STAGING_OTP is set on a non-production deployment.")
return
}
method := strings.ToUpper(strings.TrimSpace(os.Getenv("SMS_GATEWAY_METHOD")))
if method == "" {
method = http.MethodPost
}
bodyTmpl := os.Getenv("SMS_GATEWAY_BODY")
if strings.TrimSpace(bodyTmpl) == "" {
bodyTmpl = `{"to":"{{phone}}","message":"{{message}}","sender":"{{sender}}"}`
}
contentType := strings.TrimSpace(os.Getenv("SMS_GATEWAY_TYPE"))
if contentType == "" {
contentType = "application/json"
}
var headerName, headerValue string
if raw := strings.TrimSpace(os.Getenv("SMS_GATEWAY_HEADER")); raw != "" {
if name, value, ok := strings.Cut(raw, ":"); ok {
headerName = strings.TrimSpace(name)
headerValue = strings.TrimSpace(value)
} else {
utils.Warn("SMS: SMS_GATEWAY_HEADER is not in 'Name: value' form and was ignored",
"value", raw)
}
}
Register(httpSender{
url: url,
method: method,
auth: strings.TrimSpace(os.Getenv("SMS_GATEWAY_AUTH")),
headerName: headerName,
headerValue: headerValue,
bodyTmpl: bodyTmpl,
contentType: contentType,
senderID: strings.TrimSpace(os.Getenv("SMS_SENDER_ID")),
client: &http.Client{Timeout: gatewayTimeout},
})
}

View File

@@ -0,0 +1,186 @@
package sms
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// The gateway that closes the sign-in blocker.
//
// sms.Register had no callers anywhere in the tree, so logSender was never
// replaced and every customer verification code went to the application log
// instead of to a phone. That is why nobody could sign in, why the app's
// offline dev mode became the only practical way in, and why bookings "made"
// in that mode never reached the admin console.
func restoreSender(t *testing.T) {
t.Helper()
previous := active
t.Cleanup(func() { active = previous })
}
func testSender(url, bodyTmpl string) httpSender {
if bodyTmpl == "" {
bodyTmpl = `{"to":"{{phone}}","message":"{{message}}","sender":"{{sender}}"}`
}
return httpSender{
url: url,
method: http.MethodPost,
bodyTmpl: bodyTmpl,
contentType: "application/json",
senderID: "DRMILE",
client: &http.Client{Timeout: 5 * time.Second},
}
}
// The destination and the code have to actually reach the gateway.
func TestGatewaySendsPhoneAndCode(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
got = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
restoreSender(t)
Register(testSender(srv.URL, ""))
if err := SendOTP("+919876543210", "4821"); err != nil {
t.Fatalf("SendOTP: %v", err)
}
for _, want := range []string{"+919876543210", "4821", "DRMILE"} {
if !strings.Contains(got, want) {
t.Errorf("gateway body %q is missing %q", got, want)
}
}
}
// A refused send — no balance, unapproved DLT template — must surface as an
// error. Swallowing it tells the customer a code is on its way when it is not,
// which is precisely the failure logSender has been producing all along.
func TestGatewayRefusalIsReported(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusPaymentRequired)
_, _ = w.Write([]byte(`{"error":"insufficient balance"}`))
}))
defer srv.Close()
restoreSender(t)
Register(testSender(srv.URL, ""))
if err := SendOTP("+919876543210", "4821"); err == nil {
t.Error("a rejected send reported success — the customer would wait for a " +
"text that is never coming")
}
}
// An unreachable gateway is an error, not a silent no-op.
func TestUnreachableGatewayIsReported(t *testing.T) {
restoreSender(t)
Register(testSender("http://127.0.0.1:1/unreachable", ""))
if err := SendOTP("+919876543210", "4821"); err == nil {
t.Error("an unreachable gateway reported success")
}
}
// A quote in the message must not break a JSON body template.
func TestMessageIsEscapedForJSON(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
got = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
restoreSender(t)
Register(testSender(srv.URL, ""))
if err := active.Send("+919876543210", `say "hello"`); err != nil {
t.Fatalf("send: %v", err)
}
if strings.Contains(got, `say "hello"`) {
t.Errorf("an unescaped quote reached the JSON body: %q", got)
}
if !strings.Contains(got, `say \"hello\"`) {
t.Errorf("the message was not escaped as expected: %q", got)
}
}
// Vendors differ; the template is what makes one sender cover all of them.
func TestBodyTemplateIsVendorAgnostic(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
got = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
restoreSender(t)
Register(testSender(srv.URL, "mobiles={{phone}}&message={{message}}&sender={{sender}}"))
if err := SendOTP("+919876543210", "4821"); err != nil {
t.Fatalf("send: %v", err)
}
if !strings.HasPrefix(got, "mobiles=+919876543210&message=") {
t.Errorf("form-encoded template not honoured: %q", got)
}
}
// Configure is what gives sms.Register its first caller. With no URL it must
// leave the log sink exactly where it is, so this change cannot break a
// deployment that has not been configured yet.
func TestConfigureLeavesTheLogSinkWhenUnset(t *testing.T) {
restoreSender(t)
active = logSender{}
setEnv(t, "SMS_GATEWAY_URL", "")
Configure()
if Configured() {
t.Error("Configure installed a gateway with no SMS_GATEWAY_URL set")
}
if Transport() != "log" {
t.Errorf("Transport() = %q, want \"log\"", Transport())
}
}
func TestConfigureInstallsTheGatewayWhenSet(t *testing.T) {
restoreSender(t)
active = logSender{}
setEnv(t, "SMS_GATEWAY_URL", "https://sms.example.invalid/send")
Configure()
if !Configured() {
t.Fatal("Configure did not install a gateway despite SMS_GATEWAY_URL being set")
}
if Transport() != "http-gateway" {
t.Errorf("Transport() = %q, want \"http-gateway\"", Transport())
}
}
// In production the log sink must refuse rather than write a live credential
// to the log and report success.
func TestLogSenderRefusesInProduction(t *testing.T) {
restoreSender(t)
active = logSender{}
setEnv(t, "ENV", "production")
if err := SendOTP("+919876543210", "4821"); err == nil {
t.Error("with no gateway in production, SendOTP reported success — the code " +
"went to the log and the customer was told it was sent")
}
setEnv(t, "ENV", "development")
if err := SendOTP("+919876543210", "4821"); err != nil {
t.Errorf("outside production the log sink must still work for QA: %v", err)
}
}

116
internal/sms/sms.go Normal file
View File

@@ -0,0 +1,116 @@
// Package sms delivers one-time codes to a phone number.
//
// There is no SMS provider wired into this backend yet — the miler app
// authenticates on a PIN and the console on a password, so nothing has ever
// needed to send a text. The customer app's only credential is a code sent to a
// phone, which makes this the one piece of the auth flow that cannot be
// finished from inside this repository.
//
// So this package is the seam, not the integration: a small interface, a
// logging sink that lets the whole flow be exercised end to end without a
// provider, and a fixed-code mode for staging. Plugging in a real gateway
// (MSG91, Gupshup, Twilio) means adding one Sender and selecting it here —
// nothing above this package changes.
package sms
import (
"fmt"
"os"
"strings"
"doormile/utils"
)
// Sender delivers a message to an E.164 phone number.
type Sender interface {
Send(phone, message string) error
// Name identifies the transport in logs and in the readiness probe, so
// "OTP not arriving" can be answered without reading code.
Name() string
}
// logSender writes the code to the application log instead of sending it.
//
// This is what runs until a gateway is configured. It is deliberately loud and
// deliberately marked: an OTP in a log file is a credential in a log file, and
// nobody should be able to reach production with this active and not know.
type logSender struct{}
func (logSender) Name() string { return "log" }
func (logSender) Send(phone, message string) error {
// In production this is a failure, not a fallback. A code that only
// reaches the application log has not been delivered, and returning nil
// reports a send that did not happen: the customer waits for a text that
// is never coming, and the endpoint cheerfully answers sent:true. An
// error at least surfaces as a clear failure on the sign-in screen.
if strings.EqualFold(strings.TrimSpace(os.Getenv("ENV")), "production") {
utils.Error("SMS NOT CONFIGURED in production — refusing to write a live "+
"verification code to the log. Set SMS_GATEWAY_URL.", "phone", maskPhone(phone))
return fmt.Errorf("sms: no gateway configured")
}
utils.Warn("SMS NOT CONFIGURED — code written to the log instead of being sent",
"phone", maskPhone(phone), "message", message)
return nil
}
var active Sender = logSender{}
// Register installs the real gateway. Call it from main() once a provider is
// configured; until then the log sink stays in place.
func Register(s Sender) {
if s == nil {
return
}
active = s
utils.Info("SMS sender registered", "transport", s.Name())
}
// Transport reports which sender is active, for the readiness probe.
func Transport() string { return active.Name() }
// Configured reports whether a real gateway is in place. False means codes are
// only reaching the log.
func Configured() bool { return active.Name() != "log" }
// SendOTP delivers a login code.
func SendOTP(phone, code string) error {
if strings.TrimSpace(phone) == "" {
return fmt.Errorf("sms: empty phone number")
}
msg := fmt.Sprintf("%s is your Doormile verification code. It expires in 5 minutes. Do not share it with anyone.", code)
return active.Send(phone, msg)
}
// maskPhone keeps the country code and the last two digits so a log line can be
// matched to a support call without recording the number itself.
func maskPhone(phone string) string {
if len(phone) < 5 {
return "***"
}
return phone[:3] + strings.Repeat("*", len(phone)-5) + phone[len(phone)-2:]
}
// StagingCode returns the fixed verification code for non-production
// environments, or "" when none is set.
//
// Automated tests and design QA cannot receive a real text, and the previous
// end-to-end attempt on this system stalled for exactly that reason: customer
// login needed an OTP on a real handset and could not be scripted. CX_STAGING_OTP
// closes that.
//
// It is refused outright when ENV is production, because a fixed code is a
// permanent skeleton key for every account on the platform.
func StagingCode() string {
code := strings.TrimSpace(os.Getenv("CX_STAGING_OTP"))
if code == "" {
return ""
}
if strings.EqualFold(strings.TrimSpace(os.Getenv("ENV")), "production") {
utils.Error("CX_STAGING_OTP is set in a production environment and has been ignored — " +
"a fixed verification code would accept a login for every account on the platform")
return ""
}
return code
}

140
internal/sms/sms_test.go Normal file
View File

@@ -0,0 +1,140 @@
package sms
import (
"os"
"strings"
"testing"
)
// StagingCode is a permanent skeleton key for every account on the platform if
// it ever reaches production. The guard against that is the only thing standing
// between a convenience for QA and a total auth bypass, so it is tested rather
// than trusted.
func setEnv(t *testing.T, key, value string) {
t.Helper()
previous, had := os.LookupEnv(key)
if value == "" {
_ = os.Unsetenv(key)
} else {
_ = os.Setenv(key, value)
}
t.Cleanup(func() {
if had {
_ = os.Setenv(key, previous)
} else {
_ = os.Unsetenv(key)
}
})
}
// A fixed OTP is refused in production however the environment is spelt.
func TestStagingCodeIsRefusedInProduction(t *testing.T) {
for _, env := range []string{"production", "PRODUCTION", "Production", " production "} {
setEnv(t, "CX_STAGING_OTP", "1234")
setEnv(t, "ENV", env)
if got := StagingCode(); got != "" {
t.Errorf("ENV=%q returned the fixed code %q — that is a skeleton key "+
"for every account on the platform", env, got)
}
}
}
// And is available everywhere else, which is what unblocks automated sign-in.
func TestStagingCodeIsAvailableOutsideProduction(t *testing.T) {
for _, env := range []string{"development", "staging", ""} {
setEnv(t, "CX_STAGING_OTP", "1234")
setEnv(t, "ENV", env)
if got := StagingCode(); got != "1234" {
t.Errorf("ENV=%q returned %q, want the configured staging code", env, got)
}
}
}
// Unset means unset — no accidental default.
func TestStagingCodeIsEmptyWhenNotConfigured(t *testing.T) {
setEnv(t, "ENV", "development")
setEnv(t, "CX_STAGING_OTP", "")
if got := StagingCode(); got != "" {
t.Errorf("StagingCode() = %q with nothing configured, want empty", got)
}
}
// Until a gateway is registered, Configured() must report false. Shipping while
// this quietly said true would mean nobody noticed OTP codes were only reaching
// the application log.
func TestTransportReportsThatNoGatewayIsWired(t *testing.T) {
if Configured() {
t.Error("Configured() = true with no gateway registered — " +
"the log sink must never claim to be a real transport")
}
if Transport() != "log" {
t.Errorf("Transport() = %q, want \"log\"", Transport())
}
}
// Registering a gateway flips both, and Register(nil) is ignored rather than
// silently disabling delivery.
func TestRegisterInstallsAGatewayAndIgnoresNil(t *testing.T) {
original := active
t.Cleanup(func() { active = original })
Register(nil)
if Transport() != "log" {
t.Errorf("Register(nil) changed the transport to %q", Transport())
}
fake := &recordingSender{}
Register(fake)
if !Configured() || Transport() != "test" {
t.Fatalf("after Register: configured=%v transport=%q", Configured(), Transport())
}
if err := SendOTP("+919876543210", "4821"); err != nil {
t.Fatalf("SendOTP: %v", err)
}
if fake.phone != "+919876543210" {
t.Errorf("phone = %q, want the number passed in", fake.phone)
}
if !strings.Contains(fake.message, "4821") {
t.Errorf("message %q does not carry the code", fake.message)
}
if !strings.Contains(fake.message, "Do not share") {
t.Errorf("message %q is missing the do-not-share warning", fake.message)
}
}
// An empty number is refused rather than handed to a gateway that will bill for
// it and fail.
func TestSendOTPRefusesAnEmptyNumber(t *testing.T) {
if err := SendOTP(" ", "4821"); err == nil {
t.Error("SendOTP accepted an empty phone number")
}
}
// The log sink masks the number. An OTP in a log file is already bad enough
// without the number it belongs to sitting beside it.
func TestMaskPhoneHidesTheSubscriberDigits(t *testing.T) {
got := maskPhone("+919876543210")
if strings.Contains(got, "9876543") {
t.Errorf("maskPhone = %q, still exposes the subscriber digits", got)
}
if !strings.HasSuffix(got, "10") {
t.Errorf("maskPhone = %q, should keep the last two digits so a support "+
"call can be matched", got)
}
}
type recordingSender struct {
phone string
message string
}
func (r *recordingSender) Name() string { return "test" }
func (r *recordingSender) Send(phone, message string) error {
r.phone, r.message = phone, message
return nil
}

View File

@@ -223,3 +223,77 @@ func awsEncode(s string, encodeSlash bool) string {
}
return b.String()
}
// PresignGet issues a short-lived, signed GET URL for one object.
//
// Parcel photographs are shown to the customer on the receipt, and a parcel
// photograph frames the inside of someone's doorway. A permanent CDN link to
// one is a permanent link anybody who ever saw it can keep, so the customer
// surface serves these through a signature that expires instead.
//
// Falls back to the plain CDN URL when the bucket credentials are not
// configured: an unsigned photo the customer can see beats a receipt with a
// missing image, and the objects are currently written public-read anyway.
// Once parcel photos are switched to a private ACL this becomes the only way
// to read one — which is the point of routing them through here now.
func PresignGet(objectKey string, expiry time.Duration) (string, error) {
cfg := loadSpacesConfig()
if cfg.accessKey == "" || cfg.secretKey == "" || cfg.bucket == "" {
if cfg.cdnBase != "" {
return cfg.cdnBase + "/" + objectKey, nil
}
return "", fmt.Errorf("object storage not configured")
}
const (
service = "s3"
algorithm = "AWS4-HMAC-SHA256"
)
host := cfg.bucket + "." + cfg.endpoint
now := time.Now().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
expSecs := int(expiry.Seconds())
if expSecs <= 0 {
expSecs = 900
}
canonicalURI := "/" + encodePath(objectKey)
credentialScope := dateStamp + "/" + cfg.region + "/" + service + "/aws4_request"
credential := cfg.accessKey + "/" + credentialScope
signedHeaders := "host"
q := [][2]string{
{"X-Amz-Algorithm", algorithm},
{"X-Amz-Credential", credential},
{"X-Amz-Date", amzDate},
{"X-Amz-Expires", fmt.Sprintf("%d", expSecs)},
{"X-Amz-SignedHeaders", signedHeaders},
}
canonicalQuery := canonicalizeQuery(q)
canonicalHeaders := "host:" + host + "\n"
canonicalRequest := strings.Join([]string{
"GET",
canonicalURI,
canonicalQuery,
canonicalHeaders,
signedHeaders,
"UNSIGNED-PAYLOAD",
}, "\n")
stringToSign := strings.Join([]string{
algorithm,
amzDate,
credentialScope,
hexSHA256(canonicalRequest),
}, "\n")
signingKey := deriveSigningKey(cfg.secretKey, dateStamp, cfg.region, service)
signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign))
return "https://" + host + canonicalURI + "?" + canonicalQuery +
"&X-Amz-Signature=" + signature, nil
}

View File

@@ -0,0 +1,182 @@
package storage
import (
"net/url"
"os"
"strings"
"testing"
"time"
)
// PresignGet is what serves a customer their parcel photographs. A parcel photo
// frames the inside of someone's doorway, so the properties tested here are
// privacy properties, not formatting ones: the link must expire, and it must
// never carry the bucket's secret key.
func withSpaces(t *testing.T, access, secret string) {
t.Helper()
for _, kv := range [][2]string{
{"DO_SPACES_ACCESS_KEY", access},
{"DO_SPACES_SECRET_KEY", secret},
{"DO_SPACES_REGION", "sgp1"},
{"DO_SPACES_ENDPOINT", "sgp1.digitaloceanspaces.com"},
{"DO_SPACES_BUCKET", "nearle"},
{"DO_SPACES_CDN_BASE", "https://images.nearle.app"},
} {
key, value := kv[0], kv[1]
previous, had := os.LookupEnv(key)
if value == "" {
_ = os.Unsetenv(key)
} else {
_ = os.Setenv(key, value)
}
t.Cleanup(func() {
if had {
_ = os.Setenv(key, previous)
} else {
_ = os.Unsetenv(key)
}
})
}
}
// The signed URL must never contain the secret key. A leaked secret is the
// whole bucket, not one photo — and this is exactly the mistake the legacy
// rider app made by shipping the key inside the binary.
func TestPresignGetNeverLeaksTheSecretKey(t *testing.T) {
const secret = "s3cr3t-do-not-emit-this-anywhere"
withSpaces(t, "AKIAEXAMPLE", secret)
signed, err := PresignGet("pv/booking-70/parcel-1.jpg", 30*time.Minute)
if err != nil {
t.Fatalf("PresignGet: %v", err)
}
if strings.Contains(signed, secret) {
t.Fatal("the signed URL contains the secret key")
}
if strings.Contains(strings.ToLower(signed), "secret") {
t.Errorf("suspicious content in the signed URL: %s", signed)
}
// The ACCESS key is expected — it identifies the caller, it is not a
// credential on its own.
if !strings.Contains(signed, "AKIAEXAMPLE") {
t.Error("the signed URL carries no credential scope, so it cannot authenticate")
}
}
// A link that does not expire is a permanent link, which defeats the point of
// signing it at all.
func TestPresignGetCarriesAnExpiry(t *testing.T) {
withSpaces(t, "AKIAEXAMPLE", "shhh")
signed, err := PresignGet("pv/abc.jpg", 30*time.Minute)
if err != nil {
t.Fatalf("PresignGet: %v", err)
}
parsed, err := url.Parse(signed)
if err != nil {
t.Fatalf("the signed URL does not parse: %v", err)
}
q := parsed.Query()
if got := q.Get("X-Amz-Expires"); got != "1800" {
t.Errorf("X-Amz-Expires = %q, want 1800 (30 minutes)", got)
}
for _, param := range []string{"X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-SignedHeaders", "X-Amz-Signature"} {
if q.Get(param) == "" {
t.Errorf("missing %s — the URL would be rejected by the object store", param)
}
}
if q.Get("X-Amz-Algorithm") != "AWS4-HMAC-SHA256" {
t.Errorf("unexpected algorithm %q", q.Get("X-Amz-Algorithm"))
}
}
// A non-positive expiry must fall back to a real one rather than minting a link
// that is already dead, or worse, one the store treats as unbounded.
func TestPresignGetDefaultsAZeroExpiry(t *testing.T) {
withSpaces(t, "AKIAEXAMPLE", "shhh")
signed, err := PresignGet("pv/abc.jpg", 0)
if err != nil {
t.Fatalf("PresignGet: %v", err)
}
parsed, _ := url.Parse(signed)
if got := parsed.Query().Get("X-Amz-Expires"); got != "900" {
t.Errorf("X-Amz-Expires = %q on a zero expiry, want the 900s default", got)
}
}
// Two different objects must produce different signatures. A signature that
// does not cover the key would let one link fetch any file in the bucket.
func TestPresignGetSignatureCoversTheObjectKey(t *testing.T) {
withSpaces(t, "AKIAEXAMPLE", "shhh")
a, err := PresignGet("pv/booking-70/parcel-1.jpg", time.Hour)
if err != nil {
t.Fatalf("PresignGet: %v", err)
}
b, err := PresignGet("pv/booking-99/parcel-4.jpg", time.Hour)
if err != nil {
t.Fatalf("PresignGet: %v", err)
}
sigA, _ := url.Parse(a)
sigB, _ := url.Parse(b)
if sigA.Query().Get("X-Amz-Signature") == sigB.Query().Get("X-Amz-Signature") {
t.Fatal("two different objects produced the same signature — the key is not signed")
}
if !strings.Contains(a, "booking-70") || !strings.Contains(b, "booking-99") {
t.Error("the object key is missing from the URL path")
}
}
// With no credentials configured it degrades to the plain CDN link rather than
// failing. An unsigned photo the customer can see beats a receipt with a broken
// image — and the objects are currently written public-read anyway.
func TestPresignGetFallsBackToTheCdnWhenUnconfigured(t *testing.T) {
withSpaces(t, "", "")
got, err := PresignGet("pv/abc.jpg", time.Hour)
if err != nil {
t.Fatalf("PresignGet should degrade, not fail: %v", err)
}
if got != "https://images.nearle.app/pv/abc.jpg" {
t.Errorf("fallback URL = %q, want the plain CDN link", got)
}
}
// Configured() gates the presign path; it must not claim to be configured on a
// half-set environment.
func TestConfiguredRequiresBothKeys(t *testing.T) {
withSpaces(t, "AKIAEXAMPLE", "")
if Configured() {
t.Error("Configured() = true with no secret key")
}
withSpaces(t, "", "shhh")
if Configured() {
t.Error("Configured() = true with no access key")
}
withSpaces(t, "AKIAEXAMPLE", "shhh")
if !Configured() {
t.Error("Configured() = false with both keys present")
}
}
// Object keys reach this from user-influenced paths, so the encoder has to
// survive spaces and reserved characters without breaking the signature.
func TestEncodePathHandlesAwkwardKeys(t *testing.T) {
cases := []struct{ in, want string }{
{"pv/abc.jpg", "pv/abc.jpg"},
{"pv/a b.jpg", "pv/a%20b.jpg"},
{"pv/a+b.jpg", "pv/a%2Bb.jpg"},
{"pv/sub dir/x.jpg", "pv/sub%20dir/x.jpg"},
}
for _, tc := range cases {
if got := encodePath(tc.in); got != tc.want {
t.Errorf("encodePath(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}

71
main.go
View File

@@ -2,6 +2,7 @@ package main
import (
"errors"
"net/url"
"os"
"os/signal"
"strings"
@@ -14,6 +15,7 @@ import (
"doormile/internal/assignment"
"doormile/internal/notify"
"doormile/internal/routing"
"doormile/internal/sms"
"doormile/internal/worker"
"doormile/middlewares"
"doormile/migrations"
@@ -31,6 +33,30 @@ import (
// turned into an error by the recover middleware — into the same
// {success, message} envelope the utils helpers emit, so clients never receive
// Fiber's default plain-text error body.
// isLoopbackOrigin reports whether an Origin header names this machine, on any
// port. It exists for Flutter Web, whose dev server picks a fresh random port
// on every launch — no fixed allowlist can name it in advance.
//
// Deliberately strict about what counts as loopback: the host must be exactly
// localhost, 127.0.0.1 or [::1]. A prefix match would admit
// http://localhost.attacker.com, which is a different machine entirely and is
// precisely the mistake this kind of check usually makes.
func isLoopbackOrigin(origin string) bool {
u, err := url.Parse(origin)
if err != nil {
return false
}
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
switch u.Hostname() {
case "localhost", "127.0.0.1", "::1":
return true
default:
return false
}
}
func errorHandler(c *fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
msg := "internal server error"
@@ -67,6 +93,13 @@ func main() {
db.InitNATS(cfg)
notify.InitFCM()
// Install the SMS gateway. Until this call existed, sms.Register had no
// callers anywhere in the tree, so every customer verification code was
// written to this log and no text was ever sent — the single blocker on
// customer sign-in, and the reason bookings were being made in the app's
// offline mode and never reaching the console.
sms.Configure()
// 3. Run GORM migrations for logistics tables
if db.DB != nil {
err := migrations.Migrate(db.DB)
@@ -107,14 +140,48 @@ func main() {
// nil-pointer dereference in any handler takes the whole process down.
app.Use(recover.New(recover.Config{EnableStackTrace: true}))
// CORS policy
// CORS policy.
//
// The named list is production and the well-known dev-server ports. It cannot
// cover local development on its own: `flutter run -d chrome` binds a RANDOM
// high port on every launch (65256 one run, something else the next), so a
// fixed allowlist misses it every time and the browser rejects the request
// with PreflightMissingAllowOriginHeader before the handler is ever reached.
//
// AllowOriginsFunc is consulted only when the static list has already missed,
// so it widens nothing in production — it just admits loopback origins on any
// port while developing. It is NOT enabled when ENV=production: a live API
// that accepts credentialed requests from any localhost page is a real, if
// modest, hole — a developer visiting a hostile page served from their own
// machine would have that page able to call this API as them.
//
// A wildcard is not an option regardless: AllowCredentials with
// AllowOrigins "*" is rejected by the CORS spec, and Fiber panics on it.
allowLoopbackOrigins := !strings.EqualFold(cfg.Env, "production")
if allowLoopbackOrigins {
utils.Info("CORS: loopback origins on any port are allowed (non-production)", "env", cfg.Env)
}
app.Use(cors.New(cors.Config{
AllowHeaders: "Origin,Content-Type,Accept,Authorization",
// Idempotency-Key is sent by the rider app on pickup-complete, payment
// and the base handover. A browser client that could not send it would
// lose retry safety on exactly the calls that most need it.
AllowHeaders: "Origin,Content-Type,Accept,Authorization,Idempotency-Key",
AllowOrigins: "http://localhost:5173,http://localhost:5174,http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:8080,http://localhost:8081,https://doormile.com,https://www.doormile.com,https://admin.doormile.com,https://api.doormile.com,https://crm.doormile.com,https://console.doormile.com,https://app.doormile.com,https://hub.doormile.com",
AllowOriginsFunc: func(origin string) bool {
return allowLoopbackOrigins && isLoopbackOrigin(origin)
},
AllowCredentials: true,
AllowMethods: "GET,POST,PUT,DELETE,PATCH,OPTIONS",
}))
// Echo X-Request-Id on every response, errors included, minting one when the
// caller did not send it. The customer app funnels every failure into a
// single retryable error state, so without this a support conversation
// about "it failed" has nothing to correlate against the logs. Registered
// before the logger so the id is available to it.
app.Use(middlewares.RequestID())
// Structured Zap logger middleware
app.Use(middlewares.ZapLogger())

View File

@@ -39,3 +39,21 @@ func CityGateMiddleware(c *fiber.Ctx) error {
"code": "CITY_NOT_SUPPORTED",
})
}
// PincodeInOperatingCity reports whether a pincode falls in a city Doormile
// runs in, and names it.
//
// Exported because the customer app's booking request does not carry a
// pickuppincode at all — its pickup point is a title/sub/lat/lng from the place
// search, so CityGateMiddleware's body sniff finds nothing and waves it
// through. A middleware that silently no-ops on the one caller it matters most
// for is worse than no middleware, so the customer handler resolves the pickup
// point to a pincode itself and asks this directly.
func PincodeInOperatingCity(pincode string) (string, bool) {
pincode = strings.TrimSpace(pincode)
if len(pincode) < 3 {
return "", false
}
city, ok := operatingCityPrefixes[pincode[:3]]
return city, ok
}

View File

@@ -2,6 +2,8 @@ package middlewares
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
@@ -37,8 +39,7 @@ func Idempotency() fiber.Handler {
if key == "" || db.Rdb == nil {
return c.Next()
}
uid, _ := c.Locals("userid").(int)
base := fmt.Sprintf("idem:%d:%s", uid, key)
base := "idem:" + idempotencyScope(c) + ":" + key
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
@@ -69,10 +70,27 @@ func Idempotency() fiber.Handler {
return err
}
// Cache only deterministic outcomes (2xx/4xx). A 5xx is transient — the
// retry should get a genuine second attempt, not a cached failure.
// Cache SUCCESS only.
//
// This used to store any status below 500, on the reasoning that a 4xx
// is deterministic. A 4xx is not deterministic — it is a refusal made
// against state that moves. POST /customer/auth/otp/verify returns 401
// when the submitted code does not match the one in Redis, and the whole
// point of that screen is that the customer then gets the code right.
// With the refusal cached for 24 hours, the retry that should have
// worked replayed the old 401 instead — confirmed live against
// api.doormile.com, where the second attempt came back carrying
// Idempotent-Replay: true. One typo locked a customer out for a day.
// The same shape applies to 403 after a permission is granted, 404 after
// a record is created, and 429 after a window rolls over.
//
// Nothing is lost by narrowing it. This middleware exists to stop a retry
// repeating a SIDE EFFECT — a second pickup, a second COD collection, a
// second session. A request that ended 4xx performed no side effect, so
// re-executing it is exactly as safe as the first attempt was, and
// strictly more correct than replaying a stale no.
status := c.Response().StatusCode()
if status < 500 {
if isCacheableStatus(status) {
body := string(c.Response().Body())
db.Rdb.Set(context.Background(), base, strconv.Itoa(status)+sep+body, ttl)
}
@@ -80,3 +98,38 @@ func Idempotency() fiber.Handler {
return nil
}
}
// isCacheableStatus reports whether a response may be stored and replayed to
// a later request carrying the same key. Only a 2xx may — see above.
func isCacheableStatus(status int) bool {
return status >= 200 && status < 300
}
// idempotencyScope namespaces a key so one caller's stored response can never
// be replayed to another.
//
// For an authenticated request the caller's own user id is the scope, which is
// what this middleware has always used.
//
// An UNAUTHENTICATED request has no user id, and defaulting to 0 would put
// every anonymous caller in one namespace: two customers who happened to pick
// the same Idempotency-Key on POST /customer/auth/otp/verify would collide, and
// the second would be handed the first's access token, refresh token and
// customer record. So an anonymous request is scoped by the request path and a
// hash of its body instead — the same body replays, a different body does not,
// and one person's session can never be served to another.
// The authenticated form is byte-identical to what this middleware has always
// produced ("idem:<uid>:<key>"). Changing it would orphan every in-flight key
// in Redis at deploy time, and a rider retrying a pickup-complete across that
// boundary would execute it a second time instead of replaying — the exact
// double-collection this middleware exists to prevent.
func idempotencyScope(c *fiber.Ctx) string {
if uid, ok := c.Locals("userid").(int); ok && uid != 0 {
return fmt.Sprintf("%d", uid)
}
// Anonymous callers get their own namespace shape, which no previous key
// can collide with: every key written before this change had a numeric
// scope, and this one never is.
sum := sha256.Sum256(append([]byte(c.Path()+"\x00"), c.Body()...))
return "anon-" + hex.EncodeToString(sum[:16])
}

View File

@@ -0,0 +1,172 @@
package middlewares
import (
"net/http/httptest"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
)
// The idempotency key namespace is a security boundary, not bookkeeping.
//
// POST /customer/auth/otp/verify is UNAUTHENTICATED, so c.Locals("userid") is
// absent there. Scoping on it anyway put every anonymous caller in one
// namespace: two customers picking the same Idempotency-Key would collide and
// the second would be handed the first's access token, refresh token and
// customer record. These tests pin the fix.
// scopeFor runs idempotencyScope inside a real request and returns what it
// produced.
func scopeFor(t *testing.T, authedUserID int, path, body string) string {
t.Helper()
app := fiber.New(fiber.Config{DisableStartupMessage: true})
app.Post("/*", func(c *fiber.Ctx) error {
if authedUserID != 0 {
c.Locals("userid", authedUserID)
}
return c.SendString(idempotencyScope(c))
})
req := httptest.NewRequest("POST", path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, 5000)
if err != nil {
t.Fatalf("test request: %v", err)
}
defer resp.Body.Close()
buf := make([]byte, 256)
n, _ := resp.Body.Read(buf)
return string(buf[:n])
}
// Two anonymous callers sending DIFFERENT bodies must never share a namespace,
// even with an identical Idempotency-Key. This is the session-handover bug.
func TestAnonymousCallersNeverShareAnIdempotencyNamespace(t *testing.T) {
alice := scopeFor(t, 0, "/customer/auth/otp/verify",
`{"identifier":"+919876543210","code":"1111"}`)
bob := scopeFor(t, 0, "/customer/auth/otp/verify",
`{"identifier":"+919000000001","code":"2222"}`)
if alice == bob {
t.Fatalf("two different anonymous requests share the scope %q — "+
"one customer's session could be replayed to another", alice)
}
if alice == "" || bob == "" {
t.Fatal("empty scope: every request must land in some namespace")
}
}
// The same anonymous caller repeating the SAME request must replay — that is
// the whole point of idempotency.
func TestIdenticalAnonymousRequestReplays(t *testing.T) {
body := `{"identifier":"+919876543210","code":"4821"}`
first := scopeFor(t, 0, "/customer/auth/otp/verify", body)
again := scopeFor(t, 0, "/customer/auth/otp/verify", body)
if first != again {
t.Errorf("the same request produced two scopes (%q, %q) — a retry would "+
"re-execute instead of replaying", first, again)
}
}
// The authenticated format is byte-identical to what this middleware has always
// produced. Changing it would orphan every in-flight key in Redis at deploy
// time, and a rider retrying a pickup-complete across that boundary would
// collect COD twice instead of replaying.
func TestAuthenticatedScopeFormatIsUnchanged(t *testing.T) {
got := scopeFor(t, 42, "/miler/bookings/7/pickup-complete", `{}`)
if got != "42" {
t.Errorf("authenticated scope = %q, want %q — the stored key format must "+
"not change across a deploy", got, "42")
}
}
// An anonymous scope can never collide with an authenticated one: the old
// format is always numeric, the new one never is.
func TestAnonymousScopeCannotCollideWithAnAuthenticatedOne(t *testing.T) {
anon := scopeFor(t, 0, "/customer/auth/otp/verify", `{"code":"1"}`)
if !strings.HasPrefix(anon, "anon-") {
t.Errorf("anonymous scope = %q, want an 'anon-' prefix so it cannot look "+
"like a user id", anon)
}
}
// The operating-city gate, exported because the customer booking shape carries
// no pincode for CityGateMiddleware to sniff.
func TestPincodeInOperatingCity(t *testing.T) {
cases := []struct {
pincode string
wantCity string
wantOK bool
}{
{"641012", "Coimbatore", true},
{"600001", "Chennai", true},
{"560034", "Bengaluru", true},
{"500081", "Hyderabad", true},
{"629001", "Nagercoil", true},
{"110001", "", false}, // Delhi — not an operating city
{"12", "", false}, // too short to classify
{"", "", false},
}
for _, tc := range cases {
city, ok := PincodeInOperatingCity(tc.pincode)
if ok != tc.wantOK || city != tc.wantCity {
t.Errorf("PincodeInOperatingCity(%q) = (%q, %v), want (%q, %v)",
tc.pincode, city, ok, tc.wantCity, tc.wantOK)
}
}
}
// What may be replayed from the idempotency cache.
//
// The middleware exists to stop a retry repeating a SIDE EFFECT — a second
// pickup, a second COD collection, a second session. It used to cache every
// status below 500, which quietly extended that to refusals.
//
// POST /customer/auth/otp/verify is where it bit: a wrong code returns 401, and
// the whole purpose of the screen is that the customer then gets it right. With
// the 401 cached for 24 hours, the retry that should have worked replayed the
// old refusal. Confirmed live against api.doormile.com — the second attempt
// came back carrying `Idempotent-Replay: true`.
func TestOnlySuccessfulResponsesAreCacheable(t *testing.T) {
cases := []struct {
status int
want bool
why string
}{
{200, true, "a completed mutation is exactly what must not run twice"},
{201, true, "a created booking must not be created again"},
{204, true, "a completed no-content mutation still ran"},
{400, false, "a malformed body performed no side effect; re-running is free"},
{401, false, "the code was wrong; the retry is meant to be right"},
{403, false, "a permission can be granted between attempts"},
{404, false, "the record can exist by the time of the retry"},
{409, false, "a conflict can clear"},
{422, false, "a district can reopen"},
{429, false, "the rate-limit window rolls over"},
{500, false, "transient; the retry deserves a genuine second attempt"},
{503, false, "the dependency can come back"},
}
for _, tc := range cases {
if got := isCacheableStatus(tc.status); got != tc.want {
t.Errorf("status %d cacheable = %v, want %v — %s", tc.status, got, tc.want, tc.why)
}
}
}
// The specific regression, stated as itself: a failed sign-in must never be
// replayed to a customer who has since typed the right code.
func TestAFailedOtpVerifyIsNotCached(t *testing.T) {
if isCacheableStatus(fiber.StatusUnauthorized) {
t.Fatal("a 401 from /customer/auth/otp/verify would be cached for 24 hours, " +
"so the retry with the correct code replays the refusal instead of running")
}
}

View File

@@ -19,21 +19,33 @@ func ZapLogger() fiber.Handler {
method := c.Method()
path := c.Path()
// Client identity and the request id travel with every log line.
//
// X-Client / X-Platform are what the customer app sends
// ("doormile-cx/1.0.0+12", "android"), and they are the only way to
// answer "is this failing on one build or everywhere" — which is the
// first question asked when an app-side report arrives. The request id
// is what correlates a customer saying "it failed" with the line that
// recorded the real error behind the customer-safe message.
//
// Recorded as empty rather than omitted when absent, so a caller that
// sends nothing is visibly a caller that sends nothing.
fields := []interface{}{
"method", method,
"path", path,
"status", status,
"latency", latency,
"client", c.Get("X-Client"),
"platform", c.Get("X-Platform"),
}
if id, ok := c.Locals("requestid").(string); ok && id != "" {
fields = append(fields, "requestid", id)
}
if err != nil {
utils.Error("API request error",
"method", method,
"path", path,
"status", status,
"latency", latency,
"error", err.Error(),
)
utils.Error("API request error", append(fields, "error", err.Error())...)
} else {
utils.Info("API request success",
"method", method,
"path", path,
"status", status,
"latency", latency,
)
utils.Info("API request success", fields...)
}
return err

33
middlewares/requestid.go Normal file
View File

@@ -0,0 +1,33 @@
package middlewares
import (
"crypto/rand"
"encoding/hex"
"github.com/gofiber/fiber/v2"
)
// RequestID echoes the caller's X-Request-Id on every response, errors
// included, and mints one when the caller did not send it. The customer app
// funnels all failures into a single retryable error state, so a support
// conversation about "it failed" has nothing to correlate on unless the id the
// client already holds comes back on the failing response too.
//
// Also stashed in c.Locals("requestid") so handlers can log it alongside the
// real error they are hiding behind a customer-safe message.
func RequestID() fiber.Handler {
return func(c *fiber.Ctx) error {
id := c.Get("X-Request-Id")
if id == "" {
b := make([]byte, 8)
if _, err := rand.Read(b); err == nil {
id = hex.EncodeToString(b)
}
}
if id != "" {
c.Locals("requestid", id)
c.Set("X-Request-Id", id)
}
return c.Next()
}
}

View File

@@ -47,6 +47,19 @@ func Migrate(db *gorm.DB) error {
&models.MilerDutyLog{},
&models.MilerBreakLog{},
&models.MilerSupportTicket{},
// Customer app (doormile_cx). All additive: new tables plus new
// nullable/zero-default columns on pickupbookings, so an existing row
// and every console-created booking stay valid with no backfill.
&models.ServiceableState{},
&models.ServiceableDistrict{},
&models.PickupSlotTemplate{},
&models.CustomerBookingLimit{},
&models.BookingDestination{},
&models.BookingParcelPhoto{},
&models.BookingStageEvent{},
&models.CustomerRefreshToken{},
&models.CustomerDevice{},
)
if err != nil {
@@ -94,5 +107,47 @@ func Migrate(db *gorm.DB) error {
utils.Info("✅ consignments_status_check constraint includes Collected_By_Miler")
}
// Human-facing identifiers for the customer app: DM-###### for a pickup
// booking, DMX######## for an order. Sequence-backed rather than random,
// because both columns are UNIQUE and a random short id collides long
// before a short id runs out — a collision here is a failed booking at the
// moment of payment, not a retry.
//
// No CYCLE and no MAXVALUE on purpose: past 999999 the format simply grows
// a digit (DM-1000000) instead of wrapping onto an id that already exists.
// Existing rows keep their old DM-BK-/DM-TRK- strings; nothing parses
// either format, so the two coexist safely.
if res := db.Exec(`CREATE SEQUENCE IF NOT EXISTS cx_booking_reference_seq START 100000 INCREMENT 1`); res.Error != nil {
utils.Error("❌ Failed to create cx_booking_reference_seq", "error", res.Error)
} else {
utils.Info("✅ cx_booking_reference_seq ready")
}
if res := db.Exec(`CREATE SEQUENCE IF NOT EXISTS cx_tracking_seq START 10000000 INCREMENT 1`); res.Error != nil {
utils.Error("❌ Failed to create cx_tracking_seq", "error", res.Error)
} else {
utils.Info("✅ cx_tracking_seq ready")
}
// One booking must not hold two destinations at the same position: the
// customer addresses a destination by index in
// PATCH /customer/bookings/{ref}/destinations/{index}, so a duplicate seq
// makes that route ambiguous and would let an edit land on the wrong
// address.
if res := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bookingdestinations_booking_seq
ON bookingdestinations (bookingid, seq)`); res.Error != nil {
utils.Error("❌ Failed to create bookingdestinations (bookingid, seq) unique index", "error", res.Error)
} else {
utils.Info("✅ bookingdestinations (bookingid, seq) unique index ready")
}
// The timeline is read as "every event for this booking, oldest first" on
// every tracking poll, which is the hottest customer read there is.
if res := db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookingstageevents_booking_time
ON bookingstageevents (bookingid, occurredat)`); res.Error != nil {
utils.Error("❌ Failed to create bookingstageevents (bookingid, occurredat) index", "error", res.Error)
} else {
utils.Info("✅ bookingstageevents (bookingid, occurredat) index ready")
}
return nil
}

View File

@@ -71,6 +71,12 @@ type Consignment struct {
Returninitiatedat *time.Time `json:"returninitiatedat" gorm:"column:returninitiatedat"`
Returndeliveredat *time.Time `json:"returndeliveredat" gorm:"column:returndeliveredat"`
Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"`
// Inwardedat is when the parcel was physically received at a base — written
// by the rider handover (POST /miler/consignments/:id/inward-at-hub) and by
// the console inbound scan. Distinct from Updatedat, which moves on every
// write: the handover time is a business fact staff reconcile against, so it
// needs a column of its own. Null until the parcel is actually received.
Inwardedat *time.Time `json:"inwardedat" gorm:"column:inwardedat"`
Condition string `json:"condition" gorm:"column:condition;size:50"` // recorded at hub inbound scan: Good, Damaged, etc.
Shelf string `json:"shelf" gorm:"column:shelf;size:50"` // hub storage location assigned at inbound scan
// Deliveryotp is issued when the consignment goes out for delivery and is

View File

@@ -25,6 +25,19 @@ type PickupBooking struct {
// different table entirely; writing a tenantlocations id into it violates
// that foreign key. This is what per-site reporting groups by.
Tenantlocationid *int `json:"tenantlocationid" gorm:"column:tenantlocationid;index"`
// Pickupsourcetype says what KIND of place this booking is collected from —
// one of constants.PickupSource*. It is stored on the booking row, not looked
// up from a location master, because a customer-door pickup has no location
// id at all: only the row can tell "no location because it is a front door"
// apart from "no location because nobody filled it in". Empty on rows written
// before this column existed; derivePickupSourceType classifies those from
// what they do carry, so the API never returns a blank type.
Pickupsourcetype string `json:"pickup_source_type" gorm:"column:pickupsourcetype;size:20"`
// Pickuphubid is set only when Pickupsourcetype is "hub" — the base the
// parcel is collected FROM (Base → Customer). It is a separate column from
// Nearesthubid, which is the base a parcel is routed TO. Conflating them
// would make a base-origin booking look like a base-destination one.
Pickuphubid *int `json:"pickuphubid" gorm:"column:pickuphubid;index"`
Pickupaddress string `json:"pickupaddress" gorm:"column:pickupaddress;not null"`
Pickuppincode string `json:"pickuppincode" gorm:"column:pickuppincode;not null"`
Pickuplatitude float64 `json:"pickuplatitude" gorm:"column:pickuplatitude;not null"`
@@ -61,13 +74,79 @@ type PickupBooking struct {
// Out_for_Delivery / Delivered). omitempty keeps it out of every other
// PickupBooking response that does not populate it.
Consignmentstatus string `json:"consignmentstatus,omitempty" gorm:"-"`
// Trackingno is not a column either — it lives on the consignment, which only
// exists once the parcel is collected. It is filled in by handlers that serve
// a customer, because without it the customer app has no way to reach
// GET /customer/track/:trackingno at all: a booking is addressed by id, a
// shipment by tracking number, and nothing joined the two.
Trackingno string `json:"trackingno,omitempty" gorm:"-"`
// Destinationcount and Totalpackagecount are not columns either. A
// customer-app pickup is ONE booking carrying N destinations, and the admin
// list has to be able to say "3 destinations · 4 packages" without shipping
// the whole destination array on every row — the console drains up to 12
// pages of 100 bookings and does not use the array in the list. Filled in by
// the admin bookings list from one grouped query per page (see
// applyDestinationCounts). Deliberately NOT omitempty: a console-created
// booking has no bookingdestinations rows at all and must report 0, which
// the console reads as "no destinations recorded" — omitting the field would
// make it indistinguishable from a single-destination booking.
Destinationcount int `json:"destinationcount" gorm:"-"`
Totalpackagecount int `json:"totalpackagecount" gorm:"-"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
// ── Customer-app (doormile_cx) columns ──────────────────────────────────
// All additive and nullable/zero-valued, so every existing row and every
// console-created booking stays valid without a backfill.
// Slotid is the pickup window the customer chose, as the opaque id the app
// sent back ("slot_20260905_t1"). Preferredpickupfrom/to carry the same
// window as real times for the assignment engine; this keeps the customer's
// own choice recorded verbatim, so a slot definition edited later cannot
// silently rewrite what they picked.
Slotid string `json:"slotid" gorm:"column:slotid;size:40"`
// Customerstage is the operational stage the customer app renders, one of
// the nine keys in constants.CxStage*. Distinct from Status, which is the
// booking lifecycle the console and the miler app read: Status stops moving
// at Converted_To_Consignment while the parcels keep going, and the two
// vocabularies are not a renaming of each other. Derived from the same
// writes, stored so a read is one row rather than a replay of the history.
Customerstage string `json:"customerstage" gorm:"column:customerstage;size:24"`
// Customerstatus is active / completed / cancelled. Derived, but sent
// explicitly — making the client infer it from a stage is how two surfaces
// end up disagreeing about whether an order is finished.
Customerstatus string `json:"customerstatus" gorm:"column:customerstatus;size:16"`
// The estimate range the customer was actually shown at booking time, in
// whole rupees. Kept forever, including after settlement: the receipt
// renders amountPaid − estimate min as the weight adjustment, and a price
// dispute needs the number that was on screen, not a re-run of today's
// pricing rules.
Estimateminrupees int `json:"estimateminrupees" gorm:"column:estimateminrupees;default:0"`
Estimatemaxrupees int `json:"estimatemaxrupees" gorm:"column:estimatemaxrupees;default:0"`
// Routekm is the pickup→destination distance the estimate was priced on;
// it drives the route outline and the receipt.
Routekm float64 `json:"routekm" gorm:"column:routekm;default:0"`
// Pickuptitle/Pickupsub are the two-line pickup label the customer picked
// from the place search ("12 Nehru Street" / "Gandhipuram, Coimbatore
// 641012"). Pickupaddress remains the single flat string the rest of the
// system uses; these keep the split the app renders without it having to
// re-parse one back into two.
Pickuptitle string `json:"pickuptitle" gorm:"column:pickuptitle;size:64"`
Pickupsub string `json:"pickupsub" gorm:"column:pickupsub"`
// Cancelreason is free text or one of the app's five presets. Nullable in
// spirit — an empty string means the customer gave no reason, which is
// allowed.
Cancelreason string `json:"cancelreason" gorm:"column:cancelreason;size:120"`
// Relations
Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"`
ServiceOptions []BookingServiceOption `json:"serviceoptions" gorm:"foreignKey:Bookingid"`
Payments []BookingPayment `json:"payments" gorm:"foreignKey:Bookingid"`
Destinations []BookingDestination `json:"destinations" gorm:"foreignKey:Bookingid"`
}
func (PickupBooking) TableName() string {
@@ -77,6 +156,14 @@ func (PickupBooking) TableName() string {
type BookingParcel struct {
Bookingparcelid int `json:"bookingparcelid" gorm:"primaryKey;column:bookingparcelid"`
Bookingid int `json:"bookingid" gorm:"column:bookingid"`
// Bookingdestinationid says which destination this package is going to.
// Null on console-created and pre-existing bookings, which have exactly one
// delivery address and therefore no ambiguity. On a customer-app booking it
// is what lets the miler weigh three packages for Chennai and one for
// Kochi and have each weight settle against the right order — without it,
// a multi-destination pickup has one pile of parcels and no way to say
// which parcel belongs to which tracking number.
Bookingdestinationid *int `json:"bookingdestinationid" gorm:"column:bookingdestinationid;index"`
Itemcategory string `json:"itemcategory" gorm:"column:itemcategory"`
Itemdescription string `json:"itemdescription" gorm:"column:itemdescription"`
Declaredvalue float64 `json:"declaredvalue" gorm:"column:declaredvalue"`

241
models/customer_app.go Normal file
View File

@@ -0,0 +1,241 @@
package models
import "time"
// Schema for the customer app (doormile_cx) surface.
//
// The one structural idea here is that a customer books a PICKUP, not a
// shipment: one booking fans out to 1..N destinations, and each destination
// becomes its own consignment with its own tracking number when the miler
// completes the pickup. A single-destination booking is the same shape, not a
// special case — pickupbookings keeps its flat delivery* columns mirrored from
// destination 0 so the miler app, the hub console and the routing code keep
// reading the row they already read.
// ServiceableState is a state the customer may choose a destination in. The
// picker hides any state with no available district, but the row stays so ops
// can open one without an app release.
type ServiceableState struct {
Statecode string `json:"statecode" gorm:"primaryKey;column:statecode;size:8"`
Statename string `json:"statename" gorm:"column:statename;not null"`
// Transittag is display copy shown under the state name ("Ultra-fast
// transit", "Opening soon"). Capped at 22 characters by the design.
Transittag string `json:"transittag" gorm:"column:transittag;size:22"`
Displayorder int `json:"displayorder" gorm:"column:displayorder;default:0"`
Status string `json:"status" gorm:"column:status;default:Active"` // Active, InActive
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (ServiceableState) TableName() string { return "serviceablestates" }
// ServiceableDistrict is one district inside a state. Unavailable districts are
// returned too: the picker filters them out but the app shows their names in a
// quiet "coming soon" line, so dropping them would lose real copy.
type ServiceableDistrict struct {
Districtcode string `json:"districtcode" gorm:"primaryKey;column:districtcode;size:16"`
Statecode string `json:"statecode" gorm:"column:statecode;size:8;index;not null"`
Districtname string `json:"districtname" gorm:"column:districtname;not null"`
Available bool `json:"available" gorm:"column:available;default:false"`
// Note says why a district is not available — "Opening soon", "Paused this
// week". Only meaningful when Available is false.
Note string `json:"note" gorm:"column:note;size:60"`
// Hubid is the base that serves this district. Nullable: a district can be
// announced before its hub exists.
Hubid *int `json:"hubid" gorm:"column:hubid"`
// Promise is the delivery promise shown on the destination card,
// "Next-day delivery" / "2-day delivery".
Promise string `json:"promise" gorm:"column:promise;size:40"`
// Centre coordinates price and route a destination before any street
// address exists — only state and district are required at booking time, so
// this is frequently the only geography a destination has.
Centrelatitude float64 `json:"centrelatitude" gorm:"column:centrelatitude"`
Centrelongitude float64 `json:"centrelongitude" gorm:"column:centrelongitude"`
Pincodeprefix string `json:"pincodeprefix" gorm:"column:pincodeprefix;size:6"`
Displayorder int `json:"displayorder" gorm:"column:displayorder;default:0"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (ServiceableDistrict) TableName() string { return "serviceabledistricts" }
// PickupSlotTemplate is a recurring daily pickup window. The customer-facing
// slot id is minted per date from the template code, so a slot always resolves
// back to a real preferredpickupfrom/to on the booking — a slot ops cannot
// staff is worse than no slot.
type PickupSlotTemplate struct {
Slottemplateid int `json:"slottemplateid" gorm:"primaryKey;column:slottemplateid"`
// Code is the stable part of the slot id: "t1" renders as
// "slot_20260905_t1".
Code string `json:"code" gorm:"column:code;size:16;not null"`
Starthour int `json:"starthour" gorm:"column:starthour;not null"`
Startminute int `json:"startminute" gorm:"column:startminute;default:0"`
Endhour int `json:"endhour" gorm:"column:endhour;not null"`
Endminute int `json:"endminute" gorm:"column:endminute;default:0"`
// Capacity is how many pickups this window absorbs in one zone.
Capacity int `json:"capacity" gorm:"column:capacity;default:20"`
// Applocationid scopes a template to one city. Null means every city.
Applocationid *int `json:"applocationid" gorm:"column:applocationid;index"`
// Tag is the single promotional label the design allows on at most one slot
// ("Fastest pickup"). Caption is the softer line under it.
Tag string `json:"tag" gorm:"column:tag;size:40"`
Caption string `json:"caption" gorm:"column:caption;size:60"`
Displayorder int `json:"displayorder" gorm:"column:displayorder;default:0"`
Status string `json:"status" gorm:"column:status;default:Active"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (PickupSlotTemplate) TableName() string { return "pickupslottemplates" }
// CustomerBookingLimit caps a pickup. Kept in the database rather than in the
// app so ops can vary it by city or tier without an app release. A row with a
// null applocationid is the global default.
type CustomerBookingLimit struct {
Limitid int `json:"limitid" gorm:"primaryKey;column:limitid"`
Applocationid *int `json:"applocationid" gorm:"column:applocationid;index"`
Maxpackages int `json:"maxpackages" gorm:"column:maxpackages;default:20"`
Maxdestinations int `json:"maxdestinations" gorm:"column:maxdestinations;default:5"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (CustomerBookingLimit) TableName() string { return "customerbookinglimits" }
// BookingDestination is one place inside a pickup booking, and the row that
// becomes a consignment at pickup completion. This is the table that makes
// "one visit, N orders" expressible: before it a booking carried exactly one
// delivery address in its own columns and there was nowhere to put the second.
type BookingDestination struct {
Bookingdestinationid int `json:"bookingdestinationid" gorm:"primaryKey;column:bookingdestinationid"`
Bookingid int `json:"bookingid" gorm:"column:bookingid;index;not null"`
// Seq is the 0-based position within the booking and is the {index} in
// PATCH /customer/bookings/{ref}/destinations/{index}. Stable for the life
// of the booking — destinations are never reordered, because the customer
// addresses them by position.
Seq int `json:"seq" gorm:"column:seq;not null"`
// Only state and district are required at booking time. Names are stored
// alongside the codes rather than joined at read time: the client renders
// "Chennai, Tamil Nadu" straight from the booking and never looks a code
// up, and a district renamed later must not silently rewrite an address the
// customer already agreed to.
Statecode string `json:"statecode" gorm:"column:statecode;size:8;not null"`
Statename string `json:"statename" gorm:"column:statename;not null"`
Districtcode string `json:"districtcode" gorm:"column:districtcode;size:16;not null"`
Districtname string `json:"districtname" gorm:"column:districtname;not null"`
Packagecount int `json:"packagecount" gorm:"column:packagecount;default:1"`
// Everything below is optional at booking time and may be completed by the
// customer afterwards or by the miler at the door. Empty means "not added
// yet", which the UI states explicitly rather than hiding.
Street string `json:"street" gorm:"column:street"`
Building string `json:"building" gorm:"column:building"`
Landmark string `json:"landmark" gorm:"column:landmark"`
Recipientname string `json:"recipientname" gorm:"column:recipientname"`
Recipientphone string `json:"recipientphone" gorm:"column:recipientphone"`
Instructions string `json:"instructions" gorm:"column:instructions"`
Pinlatitude *float64 `json:"pinlatitude" gorm:"column:pinlatitude"`
Pinlongitude *float64 `json:"pinlongitude" gorm:"column:pinlongitude"`
Pincode string `json:"pincode" gorm:"column:pincode;size:10"`
// Codamount is money the miler collects at this door on the customer's
// behalf. Doormile is the carrier, never the seller — this is the
// customer's own collection, and it is per destination because it is
// collected per delivery.
Codamount float64 `json:"codamount" gorm:"column:codamount;default:0"`
// Set at pickup completion, when this destination becomes an order.
Consignmentid *int `json:"consignmentid" gorm:"column:consignmentid;index"`
Trackingno string `json:"trackingno" gorm:"column:trackingno;size:32;index"`
// Stage is the per-order stage from order_created onward. Empty until the
// order exists; the booking's own stage governs everything before that.
Stage string `json:"stage" gorm:"column:stage;size:24"`
Expecteddeliveryat *time.Time `json:"expecteddeliveryat" gorm:"column:expecteddeliveryat"`
Deliveredat *time.Time `json:"deliveredat" gorm:"column:deliveredat"`
// Verification is what the miler recorded at the door: the weight the price
// actually settled on, and who recorded it. Photos live in
// bookingparcelphotos, one row per package.
Verifiedweightkg *float64 `json:"verifiedweightkg" gorm:"column:verifiedweightkg"`
Verifiedat *time.Time `json:"verifiedat" gorm:"column:verifiedat"`
Verifiedbyuserid *int `json:"verifiedbyuserid" gorm:"column:verifiedbyuserid"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (BookingDestination) TableName() string { return "bookingdestinations" }
// BookingParcelPhoto is a photograph the miler took of a package at the door.
// It is the evidence half of the receipt — a settled weight without the photo
// is a number the customer has no way to check.
type BookingParcelPhoto struct {
Photoid int `json:"photoid" gorm:"primaryKey;column:photoid"`
Bookingid int `json:"bookingid" gorm:"column:bookingid;index;not null"`
Bookingdestinationid *int `json:"bookingdestinationid" gorm:"column:bookingdestinationid;index"`
// Objectkey is the storage key. The customer is served a short-lived signed
// URL derived from it, never a permanent public link — a parcel photo can
// show the inside of someone's doorway.
Objectkey string `json:"objectkey" gorm:"column:objectkey;not null"`
Capturedbyuserid *int `json:"capturedbyuserid" gorm:"column:capturedbyuserid"`
Capturedat time.Time `json:"capturedat" gorm:"column:capturedat;default:CURRENT_TIMESTAMP"`
}
func (BookingParcelPhoto) TableName() string { return "bookingparcelphotos" }
// BookingStageEvent is the append-only audit log every customer timeline is
// built from: one row per stage actually reached, with the real time it was
// reached and who caused it. Nothing here is synthesised or backfilled — a
// timeline with invented timestamps is worse than a short one, because the
// customer cannot tell which entries are real.
type BookingStageEvent struct {
Stageeventid int `json:"stageeventid" gorm:"primaryKey;column:stageeventid"`
Bookingid int `json:"bookingid" gorm:"column:bookingid;index;not null"`
// Bookingdestinationid is null for the booking-level stages (booked through
// order_created) and set for the per-order ones (in_transit onward), which
// may differ between destinations of the same booking.
Bookingdestinationid *int `json:"bookingdestinationid" gorm:"column:bookingdestinationid;index"`
Stage string `json:"stage" gorm:"column:stage;size:24;not null"`
// Actortype/Actorid answer "who did this" — miler, ops user, the customer,
// or the system. Required by the audit rule, and the only way to explain a
// cancellation to a customer who did not make it.
Actortype string `json:"actortype" gorm:"column:actortype;size:16;not null"`
Actorid *int `json:"actorid" gorm:"column:actorid"`
Source string `json:"source" gorm:"column:source;size:64"`
Remarks string `json:"remarks" gorm:"column:remarks"`
Occurredat time.Time `json:"occurredat" gorm:"column:occurredat;index;not null"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
}
func (BookingStageEvent) TableName() string { return "bookingstageevents" }
// CustomerRefreshToken backs the 60-day session. Stored hashed: a leaked
// database row must not itself be a usable credential. Rotation is recorded via
// Replacedbyid so a replayed old token identifies the chain it came from.
type CustomerRefreshToken struct {
Refreshtokenid int `json:"refreshtokenid" gorm:"primaryKey;column:refreshtokenid"`
Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid;index;not null"`
Tokenhash string `json:"-" gorm:"column:tokenhash;size:64;uniqueIndex;not null"`
Expiresat time.Time `json:"expiresat" gorm:"column:expiresat;not null"`
Revokedat *time.Time `json:"revokedat" gorm:"column:revokedat"`
Replacedbyid *int `json:"replacedbyid" gorm:"column:replacedbyid"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
}
func (CustomerRefreshToken) TableName() string { return "customerrefreshtokens" }
// CustomerDevice is a push registration. One row per device token, not per
// customer: a customer with a phone and a tablet must get the notification on
// both, and appcustomers.device_token could only ever hold the last one.
type CustomerDevice struct {
Customerdeviceid int `json:"customerdeviceid" gorm:"primaryKey;column:customerdeviceid"`
Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid;index;not null"`
Token string `json:"token" gorm:"column:token;uniqueIndex;not null"`
Platform string `json:"platform" gorm:"column:platform;size:16"`
Appversion string `json:"appversion" gorm:"column:appversion;size:32"`
Lastseenat time.Time `json:"lastseenat" gorm:"column:lastseenat;default:CURRENT_TIMESTAMP"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
}
func (CustomerDevice) TableName() string { return "customerdevices" }

175
models/customer_app_test.go Normal file
View File

@@ -0,0 +1,175 @@
package models
import (
"reflect"
"strings"
"testing"
)
// Table names and column tags are strings the compiler never checks. A typo in
// either produces a binary that builds cleanly and then fails against the
// database at runtime — which, for the customer app, means the first real
// booking. These are cheap tests for a class of bug nothing else catches.
// Every customer-app model maps to the table AutoMigrate creates and the seed
// fills. A mismatch here means seed_customer_app.sql populates one table while
// the code reads another.
func TestCustomerAppTableNames(t *testing.T) {
cases := []struct {
model interface{ TableName() string }
want string
}{
{ServiceableState{}, "serviceablestates"},
{ServiceableDistrict{}, "serviceabledistricts"},
{PickupSlotTemplate{}, "pickupslottemplates"},
{CustomerBookingLimit{}, "customerbookinglimits"},
{BookingDestination{}, "bookingdestinations"},
{BookingParcelPhoto{}, "bookingparcelphotos"},
{BookingStageEvent{}, "bookingstageevents"},
{CustomerRefreshToken{}, "customerrefreshtokens"},
{CustomerDevice{}, "customerdevices"},
}
for _, tc := range cases {
if got := tc.model.TableName(); got != tc.want {
t.Errorf("%T.TableName() = %q, want %q",
tc.model, got, tc.want)
}
}
}
// columnOf reads the gorm column name off a struct field, the way the driver
// does.
func columnOf(t *testing.T, model interface{}, field string) string {
t.Helper()
f, ok := reflect.TypeOf(model).FieldByName(field)
if !ok {
t.Fatalf("%T has no field %s", model, field)
}
for _, part := range strings.Split(f.Tag.Get("gorm"), ";") {
if strings.HasPrefix(part, "column:") {
return strings.TrimPrefix(part, "column:")
}
}
return ""
}
// The columns the fan-out is built on. bookingdestinations.seq in particular is
// addressed by position in PATCH .../destinations/{index} and is what stop
// ordering sorts by — a renamed column there silently reorders a rider's route.
func TestBookingDestinationColumns(t *testing.T) {
want := map[string]string{
"Bookingdestinationid": "bookingdestinationid",
"Bookingid": "bookingid",
"Seq": "seq",
"Statecode": "statecode",
"Districtcode": "districtcode",
"Packagecount": "packagecount",
"Recipientname": "recipientname",
"Recipientphone": "recipientphone",
"Codamount": "codamount",
"Consignmentid": "consignmentid",
"Trackingno": "trackingno",
"Stage": "stage",
"Verifiedweightkg": "verifiedweightkg",
}
for field, col := range want {
if got := columnOf(t, BookingDestination{}, field); got != col {
t.Errorf("BookingDestination.%s column = %q, want %q", field, got, col)
}
}
}
// The columns added to the live pickupbookings table. These land on 512
// existing rows, so the names have to match what migrate.go creates.
func TestPickupBookingCustomerColumns(t *testing.T) {
want := map[string]string{
"Slotid": "slotid",
"Customerstage": "customerstage",
"Customerstatus": "customerstatus",
"Estimateminrupees": "estimateminrupees",
"Estimatemaxrupees": "estimatemaxrupees",
"Routekm": "routekm",
"Pickuptitle": "pickuptitle",
"Pickupsub": "pickupsub",
"Cancelreason": "cancelreason",
}
for field, col := range want {
if got := columnOf(t, PickupBooking{}, field); got != col {
t.Errorf("PickupBooking.%s column = %q, want %q", field, got, col)
}
}
// The link that lets a parcel know which order it belongs to.
if got := columnOf(t, BookingParcel{}, "Bookingdestinationid"); got != "bookingdestinationid" {
t.Errorf("BookingParcel.Bookingdestinationid column = %q, want %q",
got, "bookingdestinationid")
}
}
// A refresh token is a bearer credential valid for sixty days. It must never be
// serialised outward — a JSON tag of anything but "-" would put it in a
// response body.
func TestRefreshTokenHashIsNeverSerialised(t *testing.T) {
f, ok := reflect.TypeOf(CustomerRefreshToken{}).FieldByName("Tokenhash")
if !ok {
t.Fatal("CustomerRefreshToken has no Tokenhash field")
}
if got := f.Tag.Get("json"); got != "-" {
t.Errorf("Tokenhash json tag = %q, want \"-\" — a stored credential must "+
"never leave the server", got)
}
}
// The delivery OTP is the receiver's proof, not the rider's. Returning it in a
// response would hand the rider the code they are supposed to be told at the
// door.
func TestDeliveryOtpIsNeverSerialised(t *testing.T) {
f, ok := reflect.TypeOf(Consignment{}).FieldByName("Deliveryotp")
if !ok {
t.Fatal("Consignment has no Deliveryotp field")
}
if got := f.Tag.Get("json"); got != "-" {
t.Errorf("Deliveryotp json tag = %q, want \"-\"", got)
}
}
// The customer PIN hash predates the OTP flow and is now unused, but the column
// still exists. It must stay unserialised.
func TestCustomerPinHashIsNeverSerialised(t *testing.T) {
f, ok := reflect.TypeOf(AppCustomer{}).FieldByName("Loginpinhash")
if !ok {
t.Fatal("AppCustomer has no Loginpinhash field")
}
if got := f.Tag.Get("json"); got != "-" {
t.Errorf("Loginpinhash json tag = %q, want \"-\"", got)
}
}
// Two columns on pickupbookings that are computed, not stored. A missing
// gorm:"-" would make AutoMigrate create real columns for them and the driver
// would try to write there.
func TestComputedBookingFieldsAreNotColumns(t *testing.T) {
for _, field := range []string{"Consignmentstatus", "Trackingno"} {
f, ok := reflect.TypeOf(PickupBooking{}).FieldByName(field)
if !ok {
t.Fatalf("PickupBooking has no field %s", field)
}
if got := f.Tag.Get("gorm"); got != "-" {
t.Errorf("PickupBooking.%s gorm tag = %q, want \"-\" — it is filled in "+
"by handlers, not stored", field, got)
}
}
}
// The device token is unique across the whole table, not per customer: a shared
// handset must be REASSIGNED rather than duplicated, or one person's parcel
// updates reach another person's phone.
func TestCustomerDeviceTokenIsGloballyUnique(t *testing.T) {
f, _ := reflect.TypeOf(CustomerDevice{}).FieldByName("Token")
if tag := f.Tag.Get("gorm"); !strings.Contains(tag, "uniqueIndex") {
t.Errorf("CustomerDevice.Token gorm tag = %q, want a uniqueIndex — the "+
"upsert on conflict depends on it", tag)
}
}

View File

@@ -7,6 +7,7 @@ import (
"doormile/config"
"doormile/controllers"
"doormile/db"
"doormile/internal/sms"
"doormile/internal/ws"
"doormile/middlewares"
@@ -73,23 +74,60 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
"checks": fiber.Map{
"postgres": dbStatus,
"redis": redisStatus,
// Reported, but deliberately NOT gating readiness: the miler and
// console surfaces work perfectly without SMS. It is here because
// 'the OTP never arrived' was answerable only by reading code, and
// for this service's whole life the answer has been that no gateway
// was ever registered.
"sms": fiber.Map{
"transport": sms.Transport(),
"configured": sms.Configured(),
},
},
})
})
// --------------------
// CUSTOMER APIS
// CUSTOMER APIS — doormile_cx
// --------------------
// The customer books a PICKUP, not a shipment: one visit, 1..N
// destinations, and a tracking number per destination minted only when the
// miler completes the pickup. Everything under /customer/* answers in the
// customer envelope ({success, data, message}; errors carry error.code) —
// auth included, deliberately unlike /miler/verify-pin, whose payload sits
// outside `data` and cost the miler client a release to discover.
//
// This replaces the PIN-based customer surface (register / login /
// verify-pin / reset-pin, and the single-destination booking create, list,
// detail and cancel). Those were retired rather than moved: a booking with
// one delivery address in its own columns is not a shape the product has
// any more.
customer := api.Group("/customer")
customer.Post("/register", controllers.RegisterCustomer(cfg))
customer.Post("/login", authThrottle, controllers.LoginCustomer)
customer.Post("/verify-pin", authThrottle, controllers.VerifyCustomerPin(cfg))
customer.Post("/reset-pin", authThrottle, controllers.ResetCustomerPin)
customer.Post("/send-email-otp", authThrottle, controllers.SendCustomerEmailOtp(cfg))
customer.Post("/verify-email-otp", authThrottle, controllers.VerifyCustomerEmailOtp())
// Auth — a 4-digit code to a phone or an email address, no password
// anywhere. Throttled on the shared budget with every other credential
// endpoint so an attacker cannot reset it by rotating between them.
customer.Post("/auth/otp/request", authThrottle, controllers.CxRequestOtp(cfg))
customer.Post("/auth/signup", authThrottle, controllers.CxSignup(cfg))
// Idempotent: the client retries on flaky networks, and a replayed verify
// must return the original session rather than mint a second one.
customer.Post("/auth/otp/verify", authThrottle, middlewares.Idempotency(), controllers.CxVerifyOtp(cfg))
customer.Post("/auth/refresh", authThrottle, controllers.CxRefresh(cfg))
// Serviceability and configuration are read before sign-in: the booking
// form is explorable without an account, and gating the state picker behind
// auth would make the app's first screen a login wall.
customer.Get("/serviceability/states", controllers.GetCxStates)
customer.Get("/serviceability/states/:stateCode/districts", controllers.GetCxDistricts)
customer.Get("/pickup-slots", controllers.GetCxPickupSlots)
customer.Get("/config/booking-limits", controllers.GetCxBookingLimits)
// Authenticated Customer App routes
customerAuth := customer.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(9))
customerAuth.Get("/auth/me", controllers.CxMe)
customerAuth.Post("/auth/logout", controllers.CxLogout)
customerAuth.Get("/profile", controllers.GetCustomerProfile)
customerAuth.Put("/profile", controllers.UpdateCustomerProfile)
@@ -98,14 +136,38 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
customerAuth.Put("/locations/:id", controllers.UpdateCustomerLocation)
customerAuth.Delete("/locations/:id", controllers.DeleteCustomerLocation)
customerAuth.Put("/device-token", controllers.SaveCustomerDeviceToken)
// Push registration. One row per device token, not one column per customer:
// a phone and a tablet both have to receive the delivery notification.
customerAuth.Post("/devices", controllers.RegisterCxDevice)
customerAuth.Delete("/devices/:token", controllers.UnregisterCxDevice)
customerAuth.Post("/bookings", middlewares.CityGateMiddleware, controllers.CreateCustomerBooking)
customerAuth.Get("/bookings", controllers.GetCustomerBookings)
customerAuth.Get("/bookings/:bookingid", controllers.GetCustomerBookingDetails)
customerAuth.Post("/bookings/:bookingid/cancel", controllers.CancelCustomerBooking)
customerAuth.Get("/bookings/:bookingid/price", controllers.GetCustomerBookingQuote)
customerAuth.Get("/track/:trackingno", controllers.TrackConsignment)
// Places are proxied, never keyed: the legacy rider app shipped a Maps key
// in the binary and it had to be revoked. The customer app is handed
// results, not credentials.
customerAuth.Get("/places/reverse-geocode", controllers.ReverseGeocodeCx(cfg))
customerAuth.Get("/places/search", controllers.SearchCxPlaces(cfg))
// Called on every route and package-count change, so it is cheap and
// cacheable — and a failed estimate never blocks a booking.
customerAuth.Post("/fare/estimate", controllers.EstimateCxFare)
// Idempotency-Key on create: the client retries over bad networks and a
// duplicate pickup is unacceptable.
customerAuth.Post("/bookings", middlewares.CityGateMiddleware, middlewares.Idempotency(), controllers.CreateCxBooking)
customerAuth.Get("/bookings", controllers.GetCxBookings)
customerAuth.Get("/bookings/:reference", controllers.GetCxBookingDetail)
customerAuth.Post("/bookings/:reference/cancel", controllers.CancelCxBooking)
customerAuth.Patch("/bookings/:reference/destinations/:index", controllers.PatchCxDestination)
// One order by tracking number, for push deep links (doormile://track/…).
customerAuth.Get("/orders/:trackingId", controllers.GetCxOrder)
// QA only. Refused outright unless ENV is non-production AND
// CX_ALLOW_STAGE_OVERRIDE=true — two independent switches, because either
// one being wrong in production would let any customer mark their own
// parcel delivered. It exists so every tracking state is reachable for
// design QA and the app's debug stepper can be deleted.
customerAuth.Post("/ops/bookings/:reference/stage", controllers.ForceCxStage)
// --------------------
// MILER APIS
@@ -193,6 +255,16 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
milerAuth.Post("/consignments/:id/start-delivery", middlewares.Idempotency(), controllers.MilerStartDelivery)
milerAuth.Post("/consignments/:id/deliver", middlewares.Idempotency(), controllers.MilerDeliverConsignment)
milerAuth.Post("/consignments/:id/skip", controllers.MilerSkipDelivery)
// Rider handover at a base. The authoritative record that a hub-routed parcel
// physically changed hands; answers with the resulting state rather than a
// bare 200, and carries the shared idempotency middleware because riders retry
// on bad signal at a loading bay.
milerAuth.Post("/consignments/:id/inward-at-hub", middlewares.Idempotency(), controllers.MilerInwardConsignmentAtHub)
// Base master data on a rider token — id, name, address, pincode and
// coordinates for every active base. /admin/tenants/:id/locations is a
// different dataset (a client's own sites) and is closed to role 5 by design.
milerAuth.Get("/bases", controllers.MilerGetBases)
// Earnings
milerAuth.Get("/earnings", controllers.MilerGetEarnings)
@@ -366,6 +438,10 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
hubAuth.Get("/inbound/today", controllers.GetHubInboundToday)
hubAuth.Get("/inbound", controllers.GetHubInboundRange)
hubAuth.Post("/bookings/:id/inbound", controllers.CreateInboundScan)
// What riders are carrying towards this base but have not handed over yet,
// and the matching receive/dispute action for when they arrive.
hubAuth.Get("/inbound/expected", controllers.GetHubInboundExpected)
hubAuth.Post("/inbound/:id/reconcile", controllers.ReconcileHubInbound)
hubAuth.Post("/bookings/:id/assign-miler", controllers.HubAssignMiler)
hubAuth.Post("/bookings/:id/auto-assign", controllers.HubAutoAssign)
hubAuth.Post("/bookings/batch-assign", controllers.HubBatchAssign)

View File

@@ -0,0 +1,405 @@
package routes_test
import (
"net/http"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
)
// Routing contract for the customer surface, plus a regression guard proving
// the miler, admin and hub surfaces were not touched.
//
// Same boundary as routes_logistics_test.go: everything up to the handler is
// tested here with no database. A wrong path, a route registered under the
// wrong group, a missing auth gate, or a param route shadowing a static one are
// all real bugs that compile perfectly and that unit tests cannot see. A 200 is
// never claimed — that needs Postgres and Redis.
// cxAuthedRoutes is every authenticated route in the customer contract. If a
// path here stops resolving, the app gets a router 404 with no envelope at all
// and the client's error state cannot explain it.
var cxAuthedRoutes = []struct {
method string
path string
body string
}{
{http.MethodGet, "/api/v1/customer/auth/me", ""},
{http.MethodPost, "/api/v1/customer/auth/logout", `{}`},
{http.MethodGet, "/api/v1/customer/profile", ""},
{http.MethodPut, "/api/v1/customer/profile", `{"name":"Joe Oommen"}`},
{http.MethodGet, "/api/v1/customer/locations", ""},
{http.MethodPost, "/api/v1/customer/locations", `{"address":"a","pincode":"641012","latitude":11.0,"longitude":76.9}`},
{http.MethodPut, "/api/v1/customer/locations/1", `{"address":"a"}`},
{http.MethodDelete, "/api/v1/customer/locations/1", ""},
{http.MethodPost, "/api/v1/customer/devices", `{"token":"abc","platform":"android"}`},
{http.MethodDelete, "/api/v1/customer/devices/abc", ""},
{http.MethodGet, "/api/v1/customer/places/reverse-geocode?lat=11.0&lng=76.9", ""},
{http.MethodGet, "/api/v1/customer/places/search?q=brookefields", ""},
{http.MethodPost, "/api/v1/customer/fare/estimate", `{"destinations":[{"stateCode":"TN","districtCode":"TN-MAA","packageCount":1}]}`},
{http.MethodPost, "/api/v1/customer/bookings", `{"slotId":"slot_20991231_t1","destinations":[]}`},
{http.MethodGet, "/api/v1/customer/bookings", ""},
{http.MethodGet, "/api/v1/customer/bookings/DM-482913", ""},
{http.MethodPost, "/api/v1/customer/bookings/DM-482913/cancel", `{"reason":"Package not ready"}`},
{http.MethodPatch, "/api/v1/customer/bookings/DM-482913/destinations/0", `{"street":"12th Main"}`},
{http.MethodGet, "/api/v1/customer/orders/DMX10482913", ""},
{http.MethodPost, "/api/v1/customer/ops/bookings/DM-482913/stage", `{"stage":"delivered"}`},
}
// cxPublicRoutes are reachable without a token on purpose: the booking form is
// explorable before sign-in, and gating the state picker behind auth would make
// the app's first screen a login wall.
var cxPublicRoutes = []struct {
method string
path string
body string
}{
{http.MethodPost, "/api/v1/customer/auth/otp/request", `{"identifier":""}`},
{http.MethodPost, "/api/v1/customer/auth/signup", `{"name":"J"}`},
{http.MethodPost, "/api/v1/customer/auth/otp/verify", `{"identifier":"nope","code":""}`},
{http.MethodPost, "/api/v1/customer/auth/refresh", `{"refreshToken":""}`},
}
// Every authenticated customer route exists and is gated. A missing token must
// produce 401, never 404 (route absent) and never 500 (gate skipped and the
// handler reached a nil database).
func TestCustomerRoutesRequireATokenAndExist(t *testing.T) {
app := newApp()
for _, r := range cxAuthedRoutes {
t.Run(r.method+" "+r.path, func(t *testing.T) {
status, body := do(t, app, r.method, r.path, "", r.body)
if status == fiber.StatusNotFound {
t.Fatalf("route is not registered — the client would get a router 404 with no envelope (body: %s)", body)
}
if status >= 500 {
t.Fatalf("status = %d: the auth gate did not stop this before the handler (body: %s)", status, body)
}
if status != fiber.StatusUnauthorized {
t.Errorf("status = %d without a token, want 401 (body: %s)", status, body)
}
})
}
}
// A customer route must refuse a token from any other surface. Role 9 is the
// customer; 5 is a miler, 1 an admin, 6 hub staff. A miler token opening a
// customer's booking would be a cross-surface data leak.
func TestCustomerRoutesRefuseOtherRoles(t *testing.T) {
app := newApp()
for _, roleID := range []int{1, 3, 4, 5, 6} {
bearer := token(t, 99, roleID)
for _, r := range cxAuthedRoutes {
status, body := do(t, app, r.method, r.path, bearer, r.body)
if status != fiber.StatusForbidden {
t.Errorf("role %d on %s %s: status = %d, want 403 (body: %s)",
roleID, r.method, r.path, status, body)
}
}
}
}
// The pre-auth routes are reachable without a token. They still must not 404
// (route missing) and must not 500 — a malformed identifier is the caller's
// mistake and has to be answered as one.
func TestCustomerPublicRoutesAreReachableWithoutAToken(t *testing.T) {
app := newApp()
for _, r := range cxPublicRoutes {
t.Run(r.method+" "+r.path, func(t *testing.T) {
status, body := do(t, app, r.method, r.path, "", r.body)
if status == fiber.StatusNotFound {
t.Fatalf("route is not registered (body: %s)", body)
}
if status >= 500 {
t.Fatalf("status = %d on a malformed request, want 4xx (body: %s)", status, body)
}
if status < 400 {
t.Errorf("status = %d on a deliberately invalid body, want 4xx (body: %s)", status, body)
}
// The refusal has to come from the HANDLER, in the customer
// envelope, not from the auth middleware. These four routes are
// reached before sign-in, so a bare "authorization header is
// required" here would mean one had been registered after the
// group's Use and silently put behind a login wall.
if !strings.Contains(body, `"error"`) {
t.Errorf("refusal did not come from the handler — this route may have been "+
"registered behind the auth middleware (body: %s)", body)
}
})
}
}
// The retired PIN surface must be gone. Leaving it registered would keep a
// credential endpoint alive that could reset any account from a phone number,
// and would let the old app keep writing single-destination bookings that the
// new tracking screen cannot render.
func TestRetiredCustomerRoutesAreRemoved(t *testing.T) {
app := newApp()
retired := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/customer/register"},
{http.MethodPost, "/api/v1/customer/login"},
{http.MethodPost, "/api/v1/customer/verify-pin"},
{http.MethodPost, "/api/v1/customer/reset-pin"},
{http.MethodPost, "/api/v1/customer/send-email-otp"},
{http.MethodPost, "/api/v1/customer/verify-email-otp"},
{http.MethodGet, "/api/v1/customer/track/DM-TRK-ABCD-123"},
{http.MethodGet, "/api/v1/customer/bookings/42/price"},
{http.MethodPut, "/api/v1/customer/device-token"},
}
for _, r := range retired {
status, body := do(t, app, r.method, r.path, "", `{}`)
// The assertion is "no handler serves this any more", not "404".
//
// Fiber mounts customerAuth via customer.Use(...) on the /customer
// PREFIX, so any path under it that matches no route falls through to
// the auth middleware and answers 401 rather than 404. That is
// pre-existing behaviour of every group in this router (/miler,
// /admin and /hub all do it) and is not something this work changed —
// but it is what the retired PIN endpoints now return, and the app
// developer needs to know that a stale build calling
// POST /customer/login sees 401, not 404.
if status < 400 {
t.Errorf("%s %s: status = %d — a retired route is still being served (body: %s)",
r.method, r.path, status, body)
}
if status >= 500 {
t.Errorf("%s %s: status = %d — a retired route should refuse cleanly, not fault (body: %s)",
r.method, r.path, status, body)
}
// The old PIN handlers answered 200/201 with a token. Nothing here may
// still mint one.
if strings.Contains(body, `"token"`) || strings.Contains(body, `"accessToken"`) {
t.Errorf("%s %s still returns a credential: %s", r.method, r.path, body)
}
}
}
// ── Regression guard: the other surfaces are untouched ───────────────────────
// milerSurface is every miler route, with the role it requires. This exists to
// answer one question in CI rather than by inspection: did the customer work
// change the rider's API? A path disappearing, moving group, or losing its role
// gate fails here.
var milerSurface = []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/miler/profile"},
{http.MethodPut, "/api/v1/miler/profile"},
{http.MethodPut, "/api/v1/miler/device-token"},
{http.MethodPut, "/api/v1/miler/location"},
{http.MethodPut, "/api/v1/miler/availability"},
{http.MethodGet, "/api/v1/miler/assignments"},
{http.MethodGet, "/api/v1/miler/assignments/1"},
{http.MethodPost, "/api/v1/miler/assignments/1/accept"},
{http.MethodPost, "/api/v1/miler/assignments/1/reject"},
{http.MethodPost, "/api/v1/miler/bookings/1/reached"},
{http.MethodPatch, "/api/v1/miler/bookings/1/addresses"},
{http.MethodPost, "/api/v1/miler/bookings/1/parcel"},
{http.MethodPost, "/api/v1/miler/bookings/1/payment"},
{http.MethodPost, "/api/v1/miler/bookings/1/pickup-complete"},
{http.MethodPost, "/api/v1/miler/bookings/1/vehicle-required"},
{http.MethodPost, "/api/v1/miler/bookings/1/cancel"},
{http.MethodPost, "/api/v1/miler/bookings/1/skip"},
{http.MethodGet, "/api/v1/miler/bookings"},
{http.MethodPost, "/api/v1/miler/duty/start"},
{http.MethodPut, "/api/v1/miler/duty/end"},
{http.MethodGet, "/api/v1/miler/duty/current"},
{http.MethodGet, "/api/v1/miler/consignments/1"},
{http.MethodPost, "/api/v1/miler/consignments/1/start-delivery"},
{http.MethodPost, "/api/v1/miler/consignments/1/deliver"},
{http.MethodPost, "/api/v1/miler/consignments/1/skip"},
{http.MethodPost, "/api/v1/miler/consignments/1/inward-at-hub"},
{http.MethodGet, "/api/v1/miler/bases"},
{http.MethodGet, "/api/v1/miler/earnings"},
{http.MethodGet, "/api/v1/miler/notifications"},
{http.MethodPost, "/api/v1/miler/support"},
{http.MethodGet, "/api/v1/miler/support"},
{http.MethodPost, "/api/v1/miler/uploads/sign"},
}
// consoleSurface is a representative slice of the admin and hub consoles —
// including every route the customer work touched code behind (assignment,
// express booking creation, hub queues).
var consoleSurface = []struct {
method string
path string
role int
}{
{http.MethodGet, "/api/v1/admin/dashboard", 1},
{http.MethodGet, "/api/v1/admin/bookings", 1},
{http.MethodGet, "/api/v1/admin/bookings/1", 1},
{http.MethodPost, "/api/v1/admin/bookings/1/assign-miler", 1},
{http.MethodPost, "/api/v1/admin/bookings/1/cancel", 1},
{http.MethodPost, "/api/v1/admin/expressbooking", 1},
{http.MethodPost, "/api/v1/admin/expressbooking/bulk", 1},
{http.MethodGet, "/api/v1/admin/consignments", 1},
{http.MethodGet, "/api/v1/admin/customers", 1},
{http.MethodGet, "/api/v1/hub/dashboard", 6},
{http.MethodGet, "/api/v1/hub/bookings/unassigned", 6},
{http.MethodPost, "/api/v1/hub/bookings/1/assign-miler", 6},
{http.MethodPost, "/api/v1/hub/bookings/1/auto-assign", 6},
{http.MethodPost, "/api/v1/hub/bookings/batch-assign", 6},
{http.MethodGet, "/api/v1/hub/inbound/expected", 6},
}
// Every miler route still exists and is still gated to role 5. This is the
// regression guard for "did the customer work break the rider app's routing".
func TestMilerSurfaceIsUnchanged(t *testing.T) {
app := newApp()
customerBearer := token(t, 77, 9)
for _, r := range milerSurface {
t.Run(r.method+" "+r.path, func(t *testing.T) {
if status, body := do(t, app, r.method, r.path, "", `{}`); status != fiber.StatusUnauthorized {
t.Errorf("no token: status = %d, want 401 (body: %s)", status, body)
}
// And a customer token must not reach a rider endpoint.
if status, body := do(t, app, r.method, r.path, customerBearer, `{}`); status != fiber.StatusForbidden {
t.Errorf("customer token: status = %d, want 403 (body: %s)", status, body)
}
})
}
}
func TestConsoleSurfaceIsUnchanged(t *testing.T) {
app := newApp()
customerBearer := token(t, 77, 9)
for _, r := range consoleSurface {
t.Run(r.method+" "+r.path, func(t *testing.T) {
if status, body := do(t, app, r.method, r.path, "", `{}`); status != fiber.StatusUnauthorized {
t.Errorf("no token: status = %d, want 401 (body: %s)", status, body)
}
if status, body := do(t, app, r.method, r.path, customerBearer, `{}`); status != fiber.StatusForbidden {
t.Errorf("customer token: status = %d, want 403 (body: %s)", status, body)
}
})
}
}
// The customer envelope must not have leaked onto the other surfaces. A miler
// or console client reads `code` at the top level, not `error.code`, and a
// silently reshaped error body is the kind of thing that costs a release to
// discover.
func TestOtherSurfacesKeepTheirOwnErrorEnvelope(t *testing.T) {
app := newApp()
for _, path := range []string{
"/api/v1/miler/bookings",
"/api/v1/admin/bookings",
"/api/v1/hub/dashboard",
} {
_, body := do(t, app, http.MethodGet, path, "", "")
if strings.Contains(body, `"error"`) {
t.Errorf("%s: refusal body carries the customer surface's nested error object: %s", path, body)
}
if !strings.Contains(body, `"success"`) || !strings.Contains(body, `"message"`) {
t.Errorf("%s: refusal body lost its original shape: %s", path, body)
}
}
}
// The four catalogue reads must be reachable WITHOUT a token.
//
// The booking form is explorable before sign-in — the state picker is the app's
// first screen. These four are registered before `customer.Use(...)`, which is
// the only thing keeping them public: move any of them below that line and the
// app's opening screen silently becomes a login wall, with nothing else to
// catch it. `cxPublicRoutes` above covers the auth endpoints; this covers the
// catalogue, which had no such assertion until a wrong URL in the field
// surfaced the gap.
//
// With no database configured these reach the handler and fault on a nil
// db.DB — which is the proof. A 401 or 403 would mean the request never got
// that far.
func TestCatalogueRoutesAreNotBehindAuth(t *testing.T) {
app := newApp()
for _, path := range []string{
"/api/v1/customer/serviceability/states",
"/api/v1/customer/serviceability/states/TN/districts",
"/api/v1/customer/pickup-slots?lat=11.0168&lng=76.9558",
"/api/v1/customer/config/booking-limits",
} {
t.Run(path, func(t *testing.T) {
status, body := do(t, app, http.MethodGet, path, "", "")
if status == fiber.StatusUnauthorized {
t.Fatalf("status = 401: this route is behind the auth middleware. "+
"The booking form must be explorable before sign-in — check it is "+
"registered BEFORE customer.Use(...) in routes.go (body: %s)", body)
}
if status == fiber.StatusForbidden {
t.Fatalf("status = 403: this route is behind the role gate (body: %s)", body)
}
if status == fiber.StatusNotFound {
t.Fatalf("status = 404: route not registered (body: %s)", body)
}
})
}
// A wrong-role token must not change that answer either — these routes do
// not consult the caller at all.
adminBearer := token(t, 1, 1)
for _, path := range []string{
"/api/v1/customer/serviceability/states",
"/api/v1/customer/config/booking-limits",
} {
if status, body := do(t, app, http.MethodGet, path, adminBearer, ""); status == fiber.StatusForbidden {
t.Errorf("%s refused an admin token with 403 — a catalogue read is public "+
"and should ignore the caller entirely (body: %s)", path, body)
}
}
}
// A MISTYPED path under /customer/* answers 401 or 403, never 404.
//
// Fiber mounts customerAuth via customer.Use(...) on the /customer PREFIX, so
// any path matching no route falls through to the auth middleware. Every group
// in this router behaves this way (/miler, /admin, /hub included) and it is not
// something the customer work introduced — but it is genuinely confusing in the
// field: a typo like /serviceability/state (singular) reports an auth problem
// rather than a missing route, which sends people looking for a permissions bug
// that is not there.
//
// Asserted so the behaviour is at least documented and deliberate.
func TestMistypedCustomerPathReportsAuthNotNotFound(t *testing.T) {
app := newApp()
const typo = "/api/v1/customer/serviceability/state" // note: singular
status, body := do(t, app, http.MethodGet, typo, "", "")
if status != fiber.StatusUnauthorized {
t.Errorf("no token on a mistyped path: status = %d, want 401 (body: %s)", status, body)
}
status, body = do(t, app, http.MethodGet, typo, token(t, 1, 1), "")
if status != fiber.StatusForbidden {
t.Errorf("wrong-role token on a mistyped path: status = %d, want 403 (body: %s)", status, body)
}
if !strings.Contains(body, "insufficient permissions") {
t.Errorf("unexpected refusal body: %s", body)
}
}

View File

@@ -0,0 +1,257 @@
package routes_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"doormile/config"
"doormile/routes"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/recover"
)
// Real HTTP requests against the real router.
//
// WHAT THIS DOES AND DOES NOT PROVE.
//
// Every route in this file is behind AuthMiddleware + RoleCheckMiddleware, and
// both reject before the handler runs — so a request with a wrong-role or absent
// token never reaches a line that touches Postgres. That makes it possible to
// exercise the entire HTTP surface with no database, and it is worth doing:
// it catches a mistyped path, a route registered under the wrong group, a
// missing middleware, and a param route shadowing a static one. Those are real
// bugs that compile perfectly and that unit tests over pure functions cannot see.
//
// It does NOT prove a handler works. Nothing here reaches a query, a
// transaction, or a response body built from real rows. A 200 from these
// endpoints has never been observed and this file does not claim one.
//
// The line is drawn deliberately: everything up to the handler is tested here;
// everything from the handler inwards needs a database and is still unverified.
const jwtSecret = "test-secret-for-routing-only"
func newApp() *fiber.App {
// Recover is what main.go installs too. Here it also acts as a guardrail:
// with no database configured, any request that DID reach a handler would
// nil-panic and take the whole test binary down. Nothing in this file is
// supposed to get that far — recover turns a mistake into a failed test
// rather than a crashed run.
app := fiber.New()
app.Use(recover.New())
routes.RegisterRoutes(app, &config.Config{JWTSecret: jwtSecret})
return app
}
// token mints a valid JWT for a role, so the role gate can be exercised
// independently of whether a token parses at all.
func token(t *testing.T, userID, roleID int) string {
t.Helper()
tok, err := utils.GenerateToken(userID, "test@doormile.com", roleID, 0, 1001, jwtSecret)
if err != nil {
t.Fatalf("could not mint a %d-role token: %v", roleID, err)
}
return tok
}
func do(t *testing.T, app *fiber.App, method, path, bearer, body string) (int, string) {
t.Helper()
var rdr io.Reader
if body != "" {
rdr = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, rdr)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := app.Test(req, int(10*time.Second/time.Millisecond))
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(out)
}
// The routes added for the base-handover flow, with the role each one requires.
var newRoutes = []struct {
method string
path string
wantRole int
body string
}{
{http.MethodPost, "/api/v1/miler/consignments/42/inward-at-hub", 5, `{"hub_id":7}`},
{http.MethodGet, "/api/v1/miler/bases", 5, ""},
{http.MethodGet, "/api/v1/hub/inbound/expected", 6, ""},
{http.MethodPost, "/api/v1/hub/inbound/42/reconcile", 6, `{"received":true}`},
}
// Routes that already existed and whose responses this work changed.
var changedRoutes = []struct {
method string
path string
wantRole int
body string
}{
{http.MethodPost, "/api/v1/miler/bookings/42/pickup-complete", 5, ""},
{http.MethodGet, "/api/v1/miler/bookings", 5, ""},
{http.MethodGet, "/api/v1/miler/consignments/42", 5, ""},
{http.MethodGet, "/api/v1/admin/bookings/42", 1, ""},
{http.MethodPost, "/api/v1/admin/expressbooking", 1, `{"tenantid":1,"pickuppincode":"641004","parcels":[{"weight":1}]}`},
{http.MethodGet, "/api/v1/hub/bookings/unassigned", 6, ""},
{http.MethodGet, "/api/v1/hub/inbound/today", 6, ""},
{http.MethodPost, "/api/v1/customer/bookings", 9, `{"pickuppincode":"641004"}`},
}
func allRoutes() []struct {
method string
path string
wantRole int
body string
} {
return append(append([]struct {
method string
path string
wantRole int
body string
}{}, newRoutes...), changedRoutes...)
}
// A route that is registered rejects an anonymous request with 401. One that is
// NOT registered falls through to Fiber's own 404 — which is exactly how a
// mistyped path hides, since both "fail".
func TestEveryRouteIsRegistered(t *testing.T) {
app := newApp()
for _, r := range allRoutes() {
t.Run(r.method+" "+r.path, func(t *testing.T) {
status, body := do(t, app, r.method, r.path, "", r.body)
if status == http.StatusNotFound {
t.Fatalf("route is NOT registered — got 404: %s", body)
}
if status != http.StatusUnauthorized {
t.Errorf("anonymous request should be 401, got %d: %s", status, body)
}
})
}
}
// The gate that produced the "insufficient permissions" report: a valid token of
// the wrong role must be refused, and refused BEFORE the handler runs — with no
// database configured, a handler that executed would panic on a nil db.DB, so a
// clean 403 is itself the proof that nothing downstream ran.
func TestWrongRoleIsRefusedBeforeTheHandlerRuns(t *testing.T) {
app := newApp()
// One role from each group, so every case is covered by some wrong role.
roles := map[int]string{1: "admin", 5: "miler", 6: "hub staff", 9: "customer"}
for _, r := range allRoutes() {
for role, name := range roles {
if role == r.wantRole {
continue
}
// Admin roles 1/3/4 are interchangeable; only test a genuinely wrong one.
if r.wantRole == 1 && (role == 3 || role == 4) {
continue
}
t.Run(fmt.Sprintf("%s as %s", r.path, name), func(t *testing.T) {
status, body := do(t, app, r.method, r.path, token(t, 1, role), r.body)
if status != http.StatusForbidden && status != http.StatusUnauthorized {
t.Errorf("a %s token on a role-%d route returned %d, want 403/401: %s",
name, r.wantRole, status, body)
}
})
}
}
}
// The refusal has to be machine-readable, not just a status code — the console
// and the rider app both branch on the body.
func TestRefusalBodyIsWellFormed(t *testing.T) {
app := newApp()
status, body := do(t, app, http.MethodGet, "/api/v1/miler/bases", token(t, 1, 1), "")
if status != http.StatusForbidden {
t.Fatalf("admin token on a miler route: got %d, want 403", status)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
t.Fatalf("refusal body is not JSON: %q", body)
}
if parsed["success"] != false {
t.Errorf(`refusal should carry "success": false, got %v`, parsed["success"])
}
if parsed["message"] != "insufficient permissions for this resource" {
t.Errorf("unexpected refusal message: %v", parsed["message"])
}
}
// A malformed or unsigned token must never be accepted as a valid session.
func TestGarbageTokensAreRejected(t *testing.T) {
app := newApp()
for _, tok := range []string{
"not-a-jwt",
"eyJhbGciOiJub25lIn0.eyJyb2xlaWQiOjV9.", // alg:none, roleid 5
"",
} {
status, _ := do(t, app, http.MethodGet, "/api/v1/miler/bases", tok, "")
if status != http.StatusUnauthorized {
t.Errorf("token %q returned %d, want 401", tok, status)
}
}
}
// A token signed with the wrong secret must not open a session — the check that
// stops a token minted elsewhere from being trusted here.
func TestTokenSignedWithAnotherSecretIsRejected(t *testing.T) {
app := newApp()
foreign, err := utils.GenerateToken(1, "x@y.z", 5, 0, 1001, "a-different-secret")
if err != nil {
t.Fatal(err)
}
if status, _ := do(t, app, http.MethodGet, "/api/v1/miler/bases", foreign, ""); status != http.StatusUnauthorized {
t.Errorf("foreign-signed token returned %d, want 401", status)
}
}
// `/miler/bases` is static and `/miler/consignments/:consignmentid` is dynamic.
// Fiber matches in registration order, so a param route registered first would
// swallow a static sibling — the bug the Orders route table has a comment about.
func TestStaticRoutesAreNotShadowedByParamRoutes(t *testing.T) {
app := newApp()
// Probed with a token of the WRONG role on purpose. A 403 proves the request
// matched this route and reached its role gate; a 404 would mean it matched
// nothing, which is how a param route swallowing a static sibling shows up.
// Using the right role instead would enter the handler and hit the database,
// which is not what this file tests.
cases := []struct {
path string
wrongRole int
}{
{"/api/v1/miler/bases", 1}, // static, sits beside /consignments/:id
{"/api/v1/hub/inbound/expected", 5}, // static, sits beside /inbound/:id/reconcile
{"/api/v1/miler/consignments/logs", 1}, // static, sits beside /consignments/:id
}
for _, c := range cases {
t.Run(c.path, func(t *testing.T) {
status, body := do(t, app, http.MethodGet, c.path, token(t, 1, c.wrongRole), "")
if status == http.StatusNotFound {
t.Fatalf("404 — the route is shadowed or unregistered: %s", body)
}
if status != http.StatusForbidden {
t.Errorf("got %d, want 403 (matched the route, refused the role): %s", status, body)
}
})
}
}

View File

@@ -0,0 +1,104 @@
//go:build ignore
// Read-only: does the console's page-by-page drain actually see every booking?
//
// GetAdminBookings runs Offset/Limit with NO ORDER BY. In Postgres that makes
// the row order across pages unspecified, so a paged drain can legally return
// the same row twice and never return another. This replays the exact 6 pages
// the console fetches and compares the union against the table.
//
// go run scratch/check_booking_paging.go
package main
import (
"fmt"
"log"
"sort"
"doormile/config"
"doormile/db"
"github.com/joho/godotenv"
)
const pageSize = 100 // what the server actually returns: min(100, requested)
type row struct{ Bookingid int }
func main() {
_ = godotenv.Load()
cfg := config.Load()
db.Connect(cfg)
if db.DB == nil {
log.Fatal("no DB connection")
}
var total int64
db.DB.Raw(`SELECT count(*) FROM pickupbookings`).Scan(&total)
pages := int((total + pageSize - 1) / pageSize)
fmt.Printf("bookings: %d, pages of %d: %d\n\n", total, pageSize, pages)
// The truth: every id that exists.
var all []row
db.DB.Raw(`SELECT bookingid FROM pickupbookings`).Scan(&all)
truth := map[int]bool{}
for _, r := range all {
truth[r.Bookingid] = true
}
// The drain, exactly as the console runs it — unordered Offset/Limit.
seen := map[int]int{}
for p := 0; p < pages; p++ {
var page []row
db.DB.Raw(`SELECT bookingid FROM pickupbookings LIMIT ? OFFSET ?`, pageSize, p*pageSize).Scan(&page)
for _, r := range page {
seen[r.Bookingid]++
}
fmt.Printf(" page %d: %d rows\n", p+1, len(page))
}
missed := []int{}
for id := range truth {
if seen[id] == 0 {
missed = append(missed, id)
}
}
dupes := []int{}
for id, n := range seen {
if n > 1 {
dupes = append(dupes, id)
}
}
sort.Sort(sort.Reverse(sort.IntSlice(missed)))
sort.Sort(sort.Reverse(sort.IntSlice(dupes)))
fmt.Printf("\nunordered drain saw %d distinct of %d\n", len(seen), len(truth))
fmt.Printf(" MISSED %d: %v\n", len(missed), head(missed, 20))
fmt.Printf(" DUPLICATED %d: %v\n", len(dupes), head(dupes, 20))
// The same drain with a deterministic order — the proposed fix.
seenOrdered := map[int]int{}
for p := 0; p < pages; p++ {
var page []row
db.DB.Raw(`SELECT bookingid FROM pickupbookings ORDER BY bookingid DESC LIMIT ? OFFSET ?`,
pageSize, p*pageSize).Scan(&page)
for _, r := range page {
seenOrdered[r.Bookingid]++
}
}
missedOrdered := 0
for id := range truth {
if seenOrdered[id] == 0 {
missedOrdered++
}
}
fmt.Printf("\nwith ORDER BY bookingid DESC: saw %d distinct, missed %d\n",
len(seenOrdered), missedOrdered)
}
func head(xs []int, n int) []int {
if len(xs) > n {
return xs[:n]
}
return xs
}

View File

@@ -0,0 +1,71 @@
//go:build ignore
package main
import (
"fmt"
"log"
"doormile/config"
"doormile/db"
"github.com/joho/godotenv"
)
func main() {
_ = godotenv.Load()
cfg := config.Load()
db.Connect(cfg)
if db.DB == nil {
log.Fatal("no DB")
}
fmt.Println("=== unique constraints on bookingno / trackingno ===")
type c struct{ Conname, Def string }
var cs []c
db.DB.Raw(`SELECT c.conname, pg_get_constraintdef(c.oid) def
FROM pg_constraint c JOIN pg_class t ON t.oid=c.conrelid
WHERE t.relname IN ('pickupbookings','consignments') AND c.contype='u'`).Scan(&cs)
if len(cs) == 0 {
fmt.Println(" NONE — nothing stops a duplicate at the database level")
}
for _, x := range cs {
fmt.Printf(" %s: %s\n", x.Conname, x.Def)
}
fmt.Println("\n=== duplicate booking numbers ===")
type d struct {
Bookingno string
N int64
}
var ds []d
db.DB.Raw(`SELECT bookingno, count(*) n FROM pickupbookings
GROUP BY 1 HAVING count(*)>1 ORDER BY n DESC LIMIT 10`).Scan(&ds)
fmt.Printf(" %d duplicated\n", len(ds))
for _, x := range ds {
fmt.Printf(" %s x%d\n", x.Bookingno, x.N)
}
fmt.Println("\n=== all-zero random part (would mean rand.Read failed) ===")
var zeros int64
db.DB.Raw(`SELECT count(*) FROM pickupbookings WHERE bookingno LIKE 'DM-BK-00000000-%'`).Scan(&zeros)
fmt.Printf(" %d\n", zeros)
fmt.Println("\n=== how many bookings share a timestamp suffix ===")
type s struct {
Suffix string
N int64
}
var ss []s
db.DB.Raw(`SELECT split_part(bookingno,'-',4) suffix, count(*) n
FROM pickupbookings GROUP BY 1 ORDER BY n DESC LIMIT 5`).Scan(&ss)
for _, x := range ss {
fmt.Printf(" suffix %-8s %d bookings\n", x.Suffix, x.N)
}
fmt.Println("\n=== duplicate tracking numbers ===")
var td []d
db.DB.Raw(`SELECT trackingno bookingno, count(*) n FROM consignments
GROUP BY 1 HAVING count(*)>1 LIMIT 5`).Scan(&td)
fmt.Printf(" %d duplicated\n", len(td))
}

View File

@@ -0,0 +1,115 @@
//go:build ignore
// Read-only: why a customer-app booking may not reach the console.
//
// go run scratch/check_customer_bookings.go
package main
import (
"fmt"
"log"
"doormile/config"
"doormile/db"
"github.com/joho/godotenv"
)
func main() {
_ = godotenv.Load()
cfg := config.Load()
db.Connect(cfg)
if db.DB == nil {
log.Fatal("no DB connection")
}
fmt.Println("=== 1. How many bookings exist, by source ===")
type srcRow struct {
Bookingsource string
N int64
}
var srcs []srcRow
db.DB.Raw(`SELECT COALESCE(NULLIF(bookingsource,''),'(blank)') AS bookingsource, count(*) AS n
FROM pickupbookings GROUP BY 1 ORDER BY n DESC`).Scan(&srcs)
var total int64
for _, s := range srcs {
fmt.Printf(" %-16s %d\n", s.Bookingsource, s.N)
total += s.N
}
fmt.Printf(" %-16s %d\n", "TOTAL", total)
fmt.Println("\n=== 2. The 10 newest bookings ===")
type bRow struct {
Bookingid int
Bookingno string
Bookingsource string
Status string
Tenantid *int
Pickuplatitude float64
Pickuppincode string
Createdat string
}
var newest []bRow
db.DB.Raw(`SELECT bookingid, bookingno, bookingsource, status, tenantid,
pickuplatitude, pickuppincode, createdat::text
FROM pickupbookings ORDER BY bookingid DESC LIMIT 10`).Scan(&newest)
for _, b := range newest {
tenant := "NULL"
if b.Tenantid != nil {
tenant = fmt.Sprintf("%d", *b.Tenantid)
}
fmt.Printf(" #%-6d %-14s src=%-14s status=%-24s tenant=%-5s lat=%-10.4f pin=%-7s %s\n",
b.Bookingid, b.Bookingno, b.Bookingsource, b.Status, tenant,
b.Pickuplatitude, b.Pickuppincode, b.Createdat)
}
fmt.Println("\n=== 3. Customer-app bookings specifically ===")
var appBookings []bRow
db.DB.Raw(`SELECT bookingid, bookingno, bookingsource, status, tenantid,
pickuplatitude, pickuppincode, createdat::text
FROM pickupbookings WHERE bookingsource = 'Customer_App'
ORDER BY bookingid DESC LIMIT 10`).Scan(&appBookings)
if len(appBookings) == 0 {
fmt.Println(" none at all")
}
for _, b := range appBookings {
fmt.Printf(" #%-6d %-14s status=%-24s lat=%-10.4f pin=%-7s %s\n",
b.Bookingid, b.Bookingno, b.Status, b.Pickuplatitude, b.Pickuppincode, b.Createdat)
}
fmt.Println("\n=== 4. Customer-app bookings with no pickup coordinates ===")
var noCoords int64
db.DB.Raw(`SELECT count(*) FROM pickupbookings
WHERE bookingsource = 'Customer_App'
AND (pickuplatitude = 0 OR pickuplongitude = 0
OR pickuplatitude IS NULL OR pickuplongitude IS NULL)`).Scan(&noCoords)
fmt.Printf(" %d (these cannot be assigned a rider and fail the console's zone filter)\n", noCoords)
fmt.Println("\n=== 5. What one page of the console's own query returns ===")
// The console drains GET /admin/bookings, which runs no ORDER BY. This is
// the same shape: LIMIT/OFFSET with no ordering.
var page1, page1again []idRow
db.DB.Raw(`SELECT bookingid FROM pickupbookings LIMIT 5 OFFSET 0`).Scan(&page1)
db.DB.Raw(`SELECT bookingid FROM pickupbookings LIMIT 5 OFFSET 0`).Scan(&page1again)
fmt.Printf(" unordered page 1, call A: %v\n", ids(page1))
fmt.Printf(" unordered page 1, call B: %v\n", ids(page1again))
var lastPage []idRow
offset := total - 5
if offset < 0 {
offset = 0
}
db.DB.Raw(fmt.Sprintf(`SELECT bookingid FROM pickupbookings LIMIT 5 OFFSET %d`, offset)).Scan(&lastPage)
fmt.Printf(" unordered LAST page: %v\n", ids(lastPage))
fmt.Println(" (if the newest ids appear only on the last page, a truncated drain never sees them)")
}
type idRow struct{ Bookingid int }
func ids(rows []idRow) []int {
out := make([]int, 0, len(rows))
for _, r := range rows {
out = append(out, r.Bookingid)
}
return out
}

View File

@@ -0,0 +1,136 @@
//go:build ignore
// Read-only verification for the base-handover work. Reads information_schema
// and pg_constraint only — no writes, no DDL, no migrations.
//
// go run scratch/check_handover_schema.go
package main
import (
"fmt"
"log"
"doormile/config"
"doormile/db"
"github.com/joho/godotenv"
)
func main() {
_ = godotenv.Load()
cfg := config.Load()
db.Connect(cfg)
if db.DB == nil {
log.Fatal("no DB connection")
}
fmt.Println("=== 1. New columns (expected ABSENT until AutoMigrate runs) ===")
type col struct {
Table string
Name string
}
for _, want := range []col{
{"pickupbookings", "pickupsourcetype"},
{"pickupbookings", "pickuphubid"},
{"consignments", "inwardedat"},
} {
var n int64
db.DB.Raw(`SELECT count(*) FROM information_schema.columns
WHERE table_name = ? AND column_name = ?`, want.Table, want.Name).Scan(&n)
fmt.Printf(" %-16s %-18s present=%v\n", want.Table, want.Name, n > 0)
}
fmt.Println("\n=== 2. CHECK constraints on the tables we write ===")
type chk struct {
Conname string
Def string
}
var checks []chk
db.DB.Raw(`SELECT c.conname, pg_get_constraintdef(c.oid) AS def
FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid
WHERE c.contype = 'c'
AND t.relname IN ('consignments','consignmentexceptions','consignmenthistory',
'pickupbookings','bookingassignments','milerprofiles')
ORDER BY t.relname, c.conname`).Scan(&checks)
for _, c := range checks {
fmt.Printf(" %s\n %s\n", c.Conname, c.Def)
}
fmt.Println("\n=== 3. Foreign keys on the user/id columns we write ===")
type fk struct {
Table string
Conname string
Def string
}
var fks []fk
db.DB.Raw(`SELECT t.relname AS table, c.conname, pg_get_constraintdef(c.oid) AS def
FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid
WHERE c.contype = 'f'
AND t.relname IN ('consignmentexceptions','consignmenthistory','consignments',
'tripsheets','bookingassignments','pickupbookings')
ORDER BY t.relname, c.conname`).Scan(&fks)
if len(fks) == 0 {
fmt.Println(" (none)")
}
for _, f := range fks {
fmt.Printf(" %-24s %s\n", f.Table, f.Def)
}
fmt.Println("\n=== 4. NOT NULL columns on the tables we insert into ===")
type nn struct {
Table string
Column string
Def *string
}
var nns []nn
db.DB.Raw(`SELECT table_name AS table, column_name AS column, column_default AS def
FROM information_schema.columns
WHERE table_name IN ('consignmentexceptions','consignmenthistory')
AND is_nullable = 'NO'
ORDER BY table_name, ordinal_position`).Scan(&nns)
for _, c := range nns {
d := "(no default)"
if c.Def != nil {
d = *c.Def
}
fmt.Printf(" %-24s %-20s %s\n", c.Table, c.Column, d)
}
fmt.Println("\n=== 5. Hub master data completeness (request 29) ===")
type hubRow struct {
Total int64
NoAddress int64
NoPincode int64
NoCoords int64
ActiveTotal int64
}
var h hubRow
db.DB.Raw(`SELECT count(*) AS total,
count(*) FILTER (WHERE address IS NULL OR address = '') AS no_address,
count(*) FILTER (WHERE pincode IS NULL OR pincode = '') AS no_pincode,
count(*) FILTER (WHERE latitude IS NULL OR latitude = 0 OR longitude IS NULL OR longitude = 0) AS no_coords,
count(*) FILTER (WHERE status = 'Active') AS active_total
FROM hubs WHERE deletedat IS NULL`).Scan(&h)
fmt.Printf(" hubs=%d active=%d missing_address=%d missing_pincode=%d missing_coords=%d\n",
h.Total, h.ActiveTotal, h.NoAddress, h.NoPincode, h.NoCoords)
fmt.Println("\n=== 6. Consignment status distribution (what is live now) ===")
type sc struct {
Status string
N int64
}
var scs []sc
db.DB.Raw(`SELECT status, count(*) AS n FROM consignments
WHERE deletedat IS NULL GROUP BY status ORDER BY n DESC`).Scan(&scs)
for _, s := range scs {
fmt.Printf(" %-22s %d\n", s.Status, s.N)
}
fmt.Println("\n=== 7. Open assignments on already-converted bookings (the off-duty bug) ===")
var stuck int64
db.DB.Raw(`SELECT count(*) FROM bookingassignments a
JOIN pickupbookings b ON b.bookingid = a.bookingid
WHERE a.assignmentstatus IN ('Assigned','Accepted')
AND b.status = 'Converted_To_Consignment'`).Scan(&stuck)
fmt.Printf(" assignments still open on a converted booking: %d\n", stuck)
}

62
scratch/check_latest.go Normal file
View File

@@ -0,0 +1,62 @@
//go:build ignore
package main
import (
"fmt"
"log"
"doormile/config"
"doormile/db"
"github.com/joho/godotenv"
)
func main() {
_ = godotenv.Load()
cfg := config.Load()
db.Connect(cfg)
if db.DB == nil {
log.Fatal("no DB")
}
type b struct {
Bookingid int
Bookingno string
Bookingsource string
Status string
Tenantid *int
Appcustomerid int
Pickuppincode string
Pickuplatitude float64
Createdat string
}
var rows []b
db.DB.Raw(`SELECT bookingid,bookingno,bookingsource,status,tenantid,appcustomerid,
pickuppincode,pickuplatitude,createdat::text
FROM pickupbookings ORDER BY bookingid DESC LIMIT 12`).Scan(&rows)
fmt.Println("=== 12 newest bookings (any source) ===")
for _, r := range rows {
tn := "NULL"
if r.Tenantid != nil {
tn = fmt.Sprint(*r.Tenantid)
}
fmt.Printf(" #%-5d src=%-13q status=%-24s tenant=%-5s cust=%-4d pin=%-7s lat=%.4f %s\n",
r.Bookingid, r.Bookingsource, r.Status, tn, r.Appcustomerid,
r.Pickuppincode, r.Pickuplatitude, r.Createdat)
}
fmt.Println("\n=== exact distinct bookingsource values (byte-for-byte) ===")
type s struct {
V string
N int64
}
var ss []s
db.DB.Raw(`SELECT '['||bookingsource||']' v, count(*) n FROM pickupbookings GROUP BY 1 ORDER BY n DESC`).Scan(&ss)
for _, x := range ss {
fmt.Printf(" %-20s %d\n", x.V, x.N)
}
var appTotal int64
db.DB.Raw(`SELECT count(*) FROM pickupbookings WHERE bookingsource='Customer_App'`).Scan(&appTotal)
fmt.Printf("\nCustomer_App bookings the console page should list: %d\n", appTotal)
}

37
scratch/check_miler23.go Normal file
View File

@@ -0,0 +1,37 @@
//go:build ignore
package main
import (
"fmt"
"log"
"doormile/config"
"doormile/db"
"github.com/joho/godotenv"
)
func main() {
_ = godotenv.Load()
cfg := config.Load()
db.Connect(cfg)
if db.DB == nil {
log.Fatal("no DB")
}
var avail string
db.DB.Raw(`SELECT availabilitystatus FROM milerprofiles WHERE userid = 23`).Scan(&avail)
fmt.Printf("miler 23 availability: %s\n", avail)
var open int64
db.DB.Raw(`SELECT count(*) FROM bookingassignments ba
JOIN pickupbookings b ON b.bookingid = ba.bookingid
WHERE ba.mileruserid = 23 AND ba.assignmentstatus IN ('Assigned','Accepted')`).Scan(&open)
fmt.Printf("miler 23 open assignments: %d\n", open)
var onCancelled int64
db.DB.Raw(`SELECT count(*) FROM bookingassignments ba
JOIN pickupbookings b ON b.bookingid = ba.bookingid
WHERE ba.assignmentstatus IN ('Assigned','Accepted') AND b.status = 'Cancelled'`).Scan(&onCancelled)
fmt.Printf("\nACROSS ALL RIDERS — open assignments on a CANCELLED booking: %d\n", onCancelled)
}

38
scratch/check_phone.go Normal file
View File

@@ -0,0 +1,38 @@
//go:build ignore
package main
import (
"fmt"
"log"
"doormile/config"
"doormile/db"
"github.com/joho/godotenv"
)
func main() {
_ = godotenv.Load()
cfg := config.Load()
db.Connect(cfg)
if db.DB == nil {
log.Fatal("no DB")
}
type c struct {
Appcustomerid int
Firstname string
Phone string
Configid int
Status string
}
var rows []c
db.DB.Raw(`SELECT appcustomerid,firstname,phone,configid,status FROM appcustomers
WHERE phone IN ('9876543210') OR appcustomerid = 5`).Scan(&rows)
if len(rows) == 0 {
fmt.Println("no appcustomer with phone 9876543210, and no id 5")
}
for _, r := range rows {
fmt.Printf(" id=%d name=%q phone=%s configid=%d status=%s\n",
r.Appcustomerid, r.Firstname, r.Phone, r.Configid, r.Status)
}
}

View File

@@ -0,0 +1,80 @@
//go:build ignore
package main
import (
"fmt"
"log"
"doormile/config"
"doormile/db"
"github.com/joho/godotenv"
)
func main() {
_ = godotenv.Load()
cfg := config.Load()
db.Connect(cfg)
if db.DB == nil {
log.Fatal("no DB")
}
type b struct {
Bookingid int
Bookingno string
Status string
Createdat string
Updatedat string
Assignedmileruserid *int
Pickuplatitude float64
Pickuplongitude float64
Pickuppincode string
Deliverypincode string
Deliveryaddress string
Appcustomerid int
}
var rows []b
db.DB.Raw(`SELECT bookingid, bookingno, status, createdat::text, updatedat::text,
assignedmileruserid, pickuplatitude, pickuplongitude, pickuppincode,
deliverypincode, deliveryaddress, appcustomerid
FROM pickupbookings WHERE bookingid IN (546,547,179) ORDER BY bookingid DESC`).Scan(&rows)
for _, r := range rows {
miler := "none"
if r.Assignedmileruserid != nil {
miler = fmt.Sprintf("%d", *r.Assignedmileruserid)
}
fmt.Printf("#%d %s\n status=%s created=%s\n updated=%s miler=%s customer=%d\n pickup=(%.4f,%.4f) %s -> %s drop=%q\n\n",
r.Bookingid, r.Bookingno, r.Status, r.Createdat, r.Updatedat, miler,
r.Appcustomerid, r.Pickuplatitude, r.Pickuplongitude, r.Pickuppincode,
r.Deliverypincode, r.Deliveryaddress)
}
fmt.Println("=== assignments on those bookings ===")
type a struct {
Bookingid int
Assignmentstatus string
Assignedat string
Remarks string
}
var as []a
db.DB.Raw(`SELECT bookingid, assignmentstatus, assignedat::text, remarks
FROM bookingassignments WHERE bookingid IN (546,547,179)`).Scan(&as)
if len(as) == 0 {
fmt.Println(" none — no rider was ever assigned")
}
for _, x := range as {
fmt.Printf(" booking %d: %s at %s %q\n", x.Bookingid, x.Assignmentstatus, x.Assignedat, x.Remarks)
}
fmt.Println("\n=== how many bookings sit in each status ===")
type s struct {
Status string
N int64
}
var ss []s
db.DB.Raw(`SELECT status, count(*) n FROM pickupbookings GROUP BY 1 ORDER BY n DESC`).Scan(&ss)
for _, x := range ss {
fmt.Printf(" %-26s %d\n", x.Status, x.N)
}
}

View File

@@ -0,0 +1,174 @@
//go:build ignore
// Read-only probe of the live `logistics` database for the customer-app work.
//
// STRICTLY READ-ONLY. It runs SELECTs against information_schema and a few
// COUNTs, and nothing else — no DDL, no INSERT, no UPDATE. It exists to answer
// one question before anybody boots the service against this database:
//
// is migrations.Migrate() actually additive here, or would it collide with
// something that already exists?
//
// main.go calls Migrate() on startup, so "just run the server and see" is a
// schema change to a database the Miler app and both consoles are live against.
// This is the safe half of that check.
//
// go run scratch/cx_readonly_probe.go
package main
import (
"database/sql"
"fmt"
"log"
"os"
"strings"
_ "github.com/lib/pq"
)
// The tables migrations.Migrate() would create for the customer app.
var newTables = []string{
"serviceablestates",
"serviceabledistricts",
"pickupslottemplates",
"customerbookinglimits",
"bookingdestinations",
"bookingparcelphotos",
"bookingstageevents",
"customerrefreshtokens",
"customerdevices",
}
// Columns it would add to existing tables. These are the ones that touch rows
// the live system already reads.
var newColumns = map[string][]string{
"pickupbookings": {
"slotid", "customerstage", "customerstatus",
"estimateminrupees", "estimatemaxrupees", "routekm",
"pickuptitle", "pickupsub", "cancelreason",
},
"bookingparcels": {"bookingdestinationid"},
}
var newSequences = []string{"cx_booking_reference_seq", "cx_tracking_seq"}
func main() {
// Credentials come from the environment only. The other probes in this
// folder hardcode the production password; that is one more copy of a live
// credential in the repository than there needs to be, and this file does
// not add another.
//
// set -a && . ./.env && set +a && go run scratch/cx_readonly_probe.go
host := env("DB_HOST", "")
port := env("DB_PORT", "5433")
user := env("DB_USER", "")
pass := env("DB_PASSWORD", "")
name := env("DB_NAME", "logistics")
if host == "" || user == "" || pass == "" {
log.Fatal("set DB_HOST, DB_USER and DB_PASSWORD first — " +
"e.g. `set -a && . ./.env && set +a`")
}
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
host, user, pass, name, port)
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatalf("open: %v", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("ping: %v", err)
}
fmt.Printf("connected read-only to %s@%s:%s/%s\n\n", user, host, port, name)
var total int
must(db.QueryRow(`SELECT count(*) FROM information_schema.tables
WHERE table_schema = 'public'`).Scan(&total))
fmt.Printf("existing public tables: %d\n\n", total)
fmt.Println("── tables the migration would CREATE ──")
existing := 0
for _, t := range newTables {
var n int
must(db.QueryRow(`SELECT count(*) FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1`, t).Scan(&n))
if n > 0 {
existing++
var rows int
if err := db.QueryRow(fmt.Sprintf(`SELECT count(*) FROM %q`, t)).Scan(&rows); err != nil {
rows = -1
}
fmt.Printf(" %-24s ALREADY EXISTS (%d rows)\n", t, rows)
} else {
fmt.Printf(" %-24s absent — would be created\n", t)
}
}
fmt.Println("\n── columns the migration would ADD to live tables ──")
for table, cols := range newColumns {
var tableRows int
if err := db.QueryRow(fmt.Sprintf(`SELECT count(*) FROM %q`, table)).Scan(&tableRows); err != nil {
fmt.Printf(" %s: could not count rows: %v\n", table, err)
continue
}
fmt.Printf(" %s (%d rows):\n", table, tableRows)
for _, col := range cols {
var n int
must(db.QueryRow(`SELECT count(*) FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2`,
table, col).Scan(&n))
state := "absent — would be added"
if n > 0 {
state = "ALREADY EXISTS"
}
fmt.Printf(" %-24s %s\n", col, state)
}
}
fmt.Println("\n── sequences ──")
for _, s := range newSequences {
var n int
must(db.QueryRow(`SELECT count(*) FROM information_schema.sequences
WHERE sequence_schema = 'public' AND sequence_name = $1`, s).Scan(&n))
state := "absent — would be created"
if n > 0 {
state = "ALREADY EXISTS"
}
fmt.Printf(" %-30s %s\n", s, state)
}
// Anything that would make the migration NOT additive: a name collision on
// a table that is not ours, or a column of an incompatible type.
fmt.Println("\n── collision check ──")
if existing == 0 {
fmt.Println(" none — every customer-app table is new to this database")
} else {
fmt.Printf(" %d of %d target tables already exist; inspect them before migrating\n",
existing, len(newTables))
}
// How much live data the additive columns would land on.
var bookings, consignments int
_ = db.QueryRow(`SELECT count(*) FROM pickupbookings`).Scan(&bookings)
_ = db.QueryRow(`SELECT count(*) FROM consignments`).Scan(&consignments)
fmt.Printf("\nlive rows that would gain nullable columns: pickupbookings=%d consignments=%d\n",
bookings, consignments)
fmt.Println("\nNOTHING WAS WRITTEN. This probe issues SELECTs only.")
}
func env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func must(err error) {
if err != nil {
log.Fatalf("query: %v", err)
}
}

131
scratch/cx_tenant_probe.go Normal file
View File

@@ -0,0 +1,131 @@
//go:build ignore
// Read-only probe: why a customer-app booking may not reach the console.
//
// STRICTLY READ-ONLY. SELECTs and COUNTs only — no DDL, no INSERT, no UPDATE,
// no DELETE. It answers three questions before anything is changed:
//
// 1. Do customer-app bookings exist, and what tenantid do they carry?
// 2. Which tenants exist, and which are Doormile's own operational ones?
// 3. Which console logins are tenant-scoped, and would therefore be unable
// to see a booking whose tenantid is NULL?
//
// go run scratch/cx_tenant_probe.go
package main
import (
"database/sql"
"fmt"
"os"
"github.com/joho/godotenv"
_ "github.com/lib/pq"
)
func main() {
_ = godotenv.Load()
dsn := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
env("DB_HOST", "127.0.0.1"), env("DB_PORT", "5433"),
env("DB_USER", "admin"), env("DB_PASSWORD", ""), env("DB_NAME", "logistics"),
)
db, err := sql.Open("postgres", dsn)
if err != nil {
fmt.Println("open:", err)
os.Exit(1)
}
defer db.Close()
if err := db.Ping(); err != nil {
fmt.Println("ping:", err)
os.Exit(1)
}
section("1. Bookings by source and tenant attribution")
rows(db, `
SELECT COALESCE(bookingsource,'(null)') AS source,
CASE WHEN tenantid IS NULL THEN 'NULL' ELSE 'set' END AS tenant,
COUNT(*) AS n,
MAX(bookingid) AS newest_id
FROM pickupbookings
GROUP BY 1,2
ORDER BY 1,2`)
section("2. The 5 newest customer-app bookings")
rows(db, `
SELECT bookingid, bookingno, COALESCE(tenantid::text,'NULL') AS tenantid,
status, COALESCE(customerstatus,'') AS customerstatus, createdat
FROM pickupbookings
WHERE bookingsource = 'Customer_App'
ORDER BY bookingid DESC
LIMIT 5`)
section("3. Tenants")
rows(db, `SELECT tenantid, tenantname, status FROM tenants ORDER BY tenantid`)
section("4. Console logins and their tenant scope (NULL = Doormile staff, sees everything)")
rows(db, `
SELECT email, role, COALESCE(tenantid::text,'NULL (unscoped)') AS tenant_scope
FROM doormile_auth
ORDER BY tenantid NULLS FIRST, email`)
section("5. Total bookings (does the console's page budget still truncate?)")
rows(db, `SELECT COUNT(*) AS total_bookings FROM pickupbookings`)
}
func section(title string) { fmt.Printf("\n===== %s =====\n", title) }
func rows(db *sql.DB, query string) {
rs, err := db.Query(query)
if err != nil {
fmt.Println(" query failed:", err)
return
}
defer rs.Close()
cols, _ := rs.Columns()
fmt.Println(" " + join(cols, " | "))
for rs.Next() {
vals := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rs.Scan(ptrs...); err != nil {
fmt.Println(" scan:", err)
return
}
out := make([]string, len(cols))
for i, v := range vals {
switch t := v.(type) {
case nil:
out[i] = "NULL"
case []byte:
out[i] = string(t)
default:
out[i] = fmt.Sprint(t)
}
}
fmt.Println(" " + join(out, " | "))
}
}
func join(parts []string, sep string) string {
s := ""
for i, p := range parts {
if i > 0 {
s += sep
}
s += p
}
return s
}
func env(k, fallback string) string {
if v := os.Getenv(k); v != "" {
return v
}
return fallback
}

170
seed_customer_app.sql Normal file
View File

@@ -0,0 +1,170 @@
-- ============================================================
-- Doormile — customer app (doormile_cx) seed data
-- Idempotent: safe to re-run (ON CONFLICT DO UPDATE throughout)
--
-- Seeded to mirror the client's in-app mock exactly, because those cases back
-- the app's 18 widget tests and its design QA:
-- * Tamil Nadu / Kerala / Karnataka open
-- * Puducherry serviceable with NO open district (the "opening soon" state)
-- * Madurai / Kozhikode / Mangaluru unavailable, each with a reason
-- * At least one fully-booked pickup slot (t5, capacity 0)
-- Change any of those and a green test suite stops meaning anything.
--
-- Run AFTER the Go service has started once, so AutoMigrate has created the
-- tables this file fills.
-- ============================================================
-- ── Serviceable states ───────────────────────────────────────────────────────
-- districtCount is computed at read time from the districts below, so a state
-- listed here with nothing open still returns and carries its transit tag. The
-- app hides a state showing 0 rather than the server omitting it.
INSERT INTO serviceablestates (statecode, statename, transittag, displayorder, status, createdat, updatedat)
VALUES
('TN', 'Tamil Nadu', 'Ultra-fast transit', 1, 'Active', NOW(), NOW()),
('KL', 'Kerala', 'Next-day transit', 2, 'Active', NOW(), NOW()),
('KA', 'Karnataka', 'Next-day transit', 3, 'Active', NOW(), NOW()),
('TG', 'Telangana', 'Next-day transit', 4, 'Active', NOW(), NOW()),
('PY', 'Puducherry', 'Opening soon', 5, 'Active', NOW(), NOW())
ON CONFLICT (statecode) DO UPDATE
SET statename = EXCLUDED.statename,
transittag = EXCLUDED.transittag,
displayorder = EXCLUDED.displayorder,
status = EXCLUDED.status,
updatedat = NOW();
-- ── Serviceable districts ────────────────────────────────────────────────────
-- Unavailable districts are seeded too, and returned by the API. The picker
-- filters them out but the app shows their names in a quiet "coming soon" line,
-- so deleting them would remove real copy from the screen. `note` is why —
-- never left blank on an unavailable row, or the app has to invent a reason.
--
-- centrelatitude/longitude matter more than they look: only state and district
-- are required at booking time, so for most destinations these coordinates are
-- the ONLY geography the parcel has until the miler corrects it at the door.
-- They price the estimate and they keep the stop out of the 0,0 bucket that
-- route sequencing skips.
INSERT INTO serviceabledistricts
(districtcode, statecode, districtname, available, note, promise,
centrelatitude, centrelongitude, pincodeprefix, displayorder, createdat, updatedat)
VALUES
-- Tamil Nadu
('TN-CBE', 'TN', 'Coimbatore', TRUE, '', 'Next-day delivery', 11.016845, 76.955832, '641', 1, NOW(), NOW()),
('TN-MAA', 'TN', 'Chennai', TRUE, '', 'Next-day delivery', 13.082680, 80.270718, '600', 2, NOW(), NOW()),
('TN-SLM', 'TN', 'Salem', TRUE, '', '2-day delivery', 11.664325, 78.146011, '636', 3, NOW(), NOW()),
('TN-TRY', 'TN', 'Tiruchirappalli', TRUE, '', '2-day delivery', 10.790483, 78.704674, '620', 4, NOW(), NOW()),
('TN-ERD', 'TN', 'Erode', TRUE, '', '2-day delivery', 11.341000, 77.717300, '638', 5, NOW(), NOW()),
('TN-KNK', 'TN', 'Kanyakumari', TRUE, '', '2-day delivery', 8.178200, 77.435400, '629', 6, NOW(), NOW()),
('TN-MDU', 'TN', 'Madurai', FALSE, 'Opening soon', '', 9.925201, 78.119775, '625', 7, NOW(), NOW()),
-- Kerala
('KL-EKM', 'KL', 'Ernakulam', TRUE, '', 'Next-day delivery', 9.981636, 76.299881, '682', 1, NOW(), NOW()),
('KL-TVM', 'KL', 'Thiruvananthapuram', TRUE, '', '2-day delivery', 8.524139, 76.936638, '695', 2, NOW(), NOW()),
('KL-TSR', 'KL', 'Thrissur', TRUE, '', '2-day delivery', 10.527640, 76.214402, '680', 3, NOW(), NOW()),
('KL-KKD', 'KL', 'Kozhikode', FALSE, 'Opening soon', '', 11.258753, 75.780411, '673', 4, NOW(), NOW()),
-- Karnataka
('KA-BLR', 'KA', 'Bengaluru Urban', TRUE, '', 'Next-day delivery', 12.971599, 77.594566, '560', 1, NOW(), NOW()),
('KA-MYS', 'KA', 'Mysuru', TRUE, '', '2-day delivery', 12.295810, 76.639381, '570', 2, NOW(), NOW()),
('KA-MNG', 'KA', 'Dakshina Kannada', FALSE, 'Paused this week', '', 12.914142, 74.856000, '575', 3, NOW(), NOW()),
-- Telangana
('TG-HYD', 'TG', 'Hyderabad', TRUE, '', 'Next-day delivery', 17.385044, 78.486671, '500', 1, NOW(), NOW()),
-- Puducherry: serviceable state, no open district. This is the "Opening
-- soon" state the app has a designed screen for, and the only place that
-- screen can be exercised.
('PY-PDY', 'PY', 'Puducherry', FALSE, 'Opening soon', '', 11.913860, 79.812600, '605', 1, NOW(), NOW())
ON CONFLICT (districtcode) DO UPDATE
SET statecode = EXCLUDED.statecode,
districtname = EXCLUDED.districtname,
available = EXCLUDED.available,
note = EXCLUDED.note,
promise = EXCLUDED.promise,
centrelatitude = EXCLUDED.centrelatitude,
centrelongitude = EXCLUDED.centrelongitude,
pincodeprefix = EXCLUDED.pincodeprefix,
displayorder = EXCLUDED.displayorder,
updatedat = NOW();
-- Attach each district to the nearest active hub, so the destination card can
-- name a serving base. Done by proximity rather than hard-coded ids because hub
-- ids differ between environments and a wrong id is worse than a missing name.
UPDATE serviceabledistricts d
SET hubid = nearest.hubid
FROM LATERAL (
SELECT h.hubid
FROM hubs h
WHERE h.status = 'Active'
AND h.deletedat IS NULL
AND h.latitude <> 0
ORDER BY (h.latitude - d.centrelatitude) ^ 2
+ (h.longitude - d.centrelongitude) ^ 2
LIMIT 1
) AS nearest
WHERE d.hubid IS NULL;
-- ── Pickup slot templates ────────────────────────────────────────────────────
-- Roughly six windows a day, which is what the design lays out. The customer
-- never sees these rows: the API expands them onto today and tomorrow and mints
-- a dated slot id, so a slot the customer picks always resolves back to a real
-- preferredpickupfrom/to that the assignment engine can staff.
--
-- t5 is seeded at capacity 0 on purpose — it is the ONLY way to reach the
-- "Fully booked" state in design QA without actually filling a window.
INSERT INTO pickupslottemplates
(code, starthour, startminute, endhour, endminute, capacity, applocationid,
tag, caption, displayorder, status, createdat, updatedat)
VALUES
('t1', 8, 0, 10, 0, 25, NULL, 'Fastest pickup', 'Arriving in approx. 45 mins', 1, 'Active', NOW(), NOW()),
('t2', 10, 0, 12, 0, 25, NULL, '', '', 2, 'Active', NOW(), NOW()),
('t3', 12, 0, 14, 0, 25, NULL, '', '', 3, 'Active', NOW(), NOW()),
('t4', 14, 0, 16, 0, 25, NULL, '', '', 4, 'Active', NOW(), NOW()),
('t5', 16, 0, 18, 0, 0, NULL, '', '', 5, 'Active', NOW(), NOW()),
('t6', 18, 0, 20, 0, 20, NULL, '', '', 6, 'Active', NOW(), NOW())
ON CONFLICT DO NOTHING;
-- ── Booking limits ───────────────────────────────────────────────────────────
-- The global default row. Ops varies these per city by inserting a row with an
-- applocationid; the client keeps 20/5 if the call fails, and the server never
-- answers 0 for either — a zero cap would reject every booking on the platform.
-- maxdestinations is seeded at 1 ON PURPOSE, not at the 5 the design allows.
--
-- The fan-out works server-side, but the deployed rider app keys its stop list
-- on `orderid`, which is booking-level — so all three stops of a
-- three-destination pickup collapse into one in its local store, and two
-- parcels would be left with no stop and no way to close them. Accepting such a
-- booking would create work nobody can complete.
--
-- Because the client reads this value from GET /customer/config/booking-limits
-- and adapts, capping it here keeps every pickup single-destination with no
-- feature flag and no app release. Raise it to 5 with a single UPDATE once a
-- rider build that keys on `consignmentid` is live:
--
-- UPDATE customerbookinglimits SET maxdestinations = 5 WHERE applocationid IS NULL;
--
-- Same discipline as MILER_HUB_HANDOVER_ENABLED: never turn on a server
-- behaviour the deployed rider app cannot finish.
INSERT INTO customerbookinglimits (applocationid, maxpackages, maxdestinations, createdat, updatedat)
SELECT NULL, 20, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM customerbookinglimits WHERE applocationid IS NULL);
-- ── Test account ─────────────────────────────────────────────────────────────
-- Automated tests and design QA cannot receive a real text, and the previous
-- end-to-end attempt on this platform stalled for exactly that reason: customer
-- login needed an OTP on a real handset and could not be scripted.
--
-- Pair this row with CX_STAGING_OTP=1234 in the staging environment. That env
-- var is refused outright when ENV=production (internal/sms), because a fixed
-- code is a skeleton key for every account on the platform.
INSERT INTO appcustomers (firstname, lastname, phone, email, status, configid, createdat, updatedat)
SELECT 'Doormile', 'QA', '+919999900001', 'qa@doormile.com', 'Active', 1001, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM appcustomers WHERE phone = '+919999900001');
INSERT INTO appcustomers (firstname, lastname, phone, email, status, configid, createdat, updatedat)
SELECT 'Design', 'QA', '+919999900002', 'design.qa@doormile.com', 'Active', 1001, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM appcustomers WHERE phone = '+919999900002');

103
utils/epoch.go Normal file
View File

@@ -0,0 +1,103 @@
package utils
import (
"fmt"
"time"
)
// Timestamps on the customer surface.
//
// The requirement is epoch milliseconds, UTC, integer — a real instant, not a
// wall clock. That is not what this database hands back. The DSN sets
// TimeZone=Asia/Kolkata and the timestamp columns hold IST wall-clock digits
// (see DBNow), so a stored value read into a time.Time carries the right
// digits with the wrong (or no) zone. Calling UnixMilli on it directly is off
// by 5h30m — the same class of defect as sending a naive local string with a Z
// on it, which has already produced "yesterday's work shown as today" on the
// miler app. So every timestamp leaving /customer/* goes through here.
//
// Reinterpreting the wall clock in IST is correct for both shapes this
// database produces: a value tagged UTC that actually holds IST digits, and a
// value correctly tagged +05:30. Both name the same instant afterwards.
// istLocation is Asia/Kolkata, with an exact fixed-offset fallback for
// containers shipped without tzdata. IST observes no DST, so +05:30 is not an
// approximation.
var istLocation = func() *time.Location {
if loc, err := time.LoadLocation("Asia/Kolkata"); err == nil {
return loc
}
return time.FixedZone("IST", 5*3600+30*60)
}()
// IST reinterprets a database timestamp's wall clock as Indian Standard Time,
// yielding the instant it actually names.
func IST(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), istLocation)
}
// EpochMillis converts a database timestamp to UTC epoch milliseconds.
func EpochMillis(t time.Time) int64 {
return IST(t).UnixMilli()
}
// EpochMillisPtr is EpochMillis over a nullable column. A nil timestamp stays
// null in JSON rather than becoming the epoch, which the client would render
// as 1 Jan 1970.
func EpochMillisPtr(t *time.Time) *int64 {
if t == nil || t.IsZero() {
return nil
}
ms := EpochMillis(*t)
return &ms
}
// ISTNow is the current moment in IST, for formatting and for comparing
// against a value already put through IST().
func ISTNow() time.Time {
return time.Now().In(istLocation)
}
// ISTLocation exposes the zone for callers building their own times.
func ISTLocation() *time.Location { return istLocation }
// FormatISTDate renders a date the way the customer app shows it: "Thu, 12 Sep".
// Display strings are formatted server-side, in IST, so the client never has to
// know the operating timezone.
func FormatISTDate(t time.Time) string {
return IST(t).Format("Mon, 2 Jan")
}
// FormatISTDay renders a date relative to today where that reads better —
// "Today", "Tomorrow", otherwise "Mon, 8 Sep". Used for pickup slot days.
func FormatISTDay(t time.Time) string {
d := IST(t)
today := ISTNow()
y1, m1, d1 := d.Date()
y2, m2, d2 := today.Date()
switch {
case y1 == y2 && m1 == m2 && d1 == d2:
return "Today"
case y1 == y2 && m1 == m2 && d1 == d2+1:
return "Tomorrow"
default:
tomorrow := today.AddDate(0, 0, 1)
y3, m3, d3 := tomorrow.Date()
if y1 == y3 && m1 == m3 && d1 == d3 {
return "Tomorrow"
}
return d.Format("Mon, 2 Jan")
}
}
// FormatISTWindow renders a pickup window as the design writes it:
// "2:00 – 4:00 PM" — en dash, spaced, and the meridiem stated once when both
// ends share it.
func FormatISTWindow(from, to time.Time) string {
f, t := IST(from), IST(to)
fMer, tMer := f.Format("PM"), t.Format("PM")
if fMer == tMer {
return fmt.Sprintf("%s – %s", f.Format("3:04"), t.Format("3:04 PM"))
}
return fmt.Sprintf("%s – %s", f.Format("3:04 PM"), t.Format("3:04 PM"))
}

116
utils/epoch_test.go Normal file
View File

@@ -0,0 +1,116 @@
package utils
import (
"testing"
"time"
)
// The customer contract asks for epoch milliseconds, UTC, integer. This
// database stores IST wall-clock digits (see DBNow), so the conversion is the
// one place a 5h30m error can enter every timestamp the app renders — which is
// exactly the defect class §3.3 of the contract warns about after it produced
// "yesterday's work shown as today" on the miler app.
func TestEpochMillisTreatsStoredWallClockAsIST(t *testing.T) {
// A row written as 2026-09-05 14:30:00 by this database means 2:30pm in
// Chennai, whatever zone the time.Time happens to carry.
stored := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC)
got := EpochMillis(stored)
want := time.Date(2026, 9, 5, 9, 0, 0, 0, time.UTC).UnixMilli() // 14:30 IST == 09:00 UTC
if got != want {
t.Errorf("EpochMillis(2026-09-05 14:30 stored) = %d, want %d (a %d ms error)",
got, want, got-want)
}
}
func TestEpochMillisAgreesForBothTaggings(t *testing.T) {
// The driver can hand back the same instant either tagged UTC with IST
// digits, or correctly tagged +05:30. Both must name one moment, or a
// column type change silently shifts every timestamp on the tracking screen.
ist := ISTLocation()
taggedUTC := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC)
taggedIST := time.Date(2026, 9, 5, 14, 30, 0, 0, ist)
if EpochMillis(taggedUTC) != EpochMillis(taggedIST) {
t.Errorf("EpochMillis disagrees on tagging: utc-tagged=%d ist-tagged=%d",
EpochMillis(taggedUTC), EpochMillis(taggedIST))
}
}
func TestEpochMillisPtrKeepsNullNull(t *testing.T) {
// A nil timestamp must stay null in JSON. Coercing it to 0 renders as
// 1 Jan 1970 on the timeline, which reads as a real event.
if got := EpochMillisPtr(nil); got != nil {
t.Errorf("EpochMillisPtr(nil) = %v, want nil", got)
}
var zero time.Time
if got := EpochMillisPtr(&zero); got != nil {
t.Errorf("EpochMillisPtr(zero time) = %v, want nil", got)
}
stored := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC)
got := EpochMillisPtr(&stored)
if got == nil || *got != EpochMillis(stored) {
t.Errorf("EpochMillisPtr(%v) = %v, want %d", stored, got, EpochMillis(stored))
}
}
func TestFormatISTWindowMatchesTheDesign(t *testing.T) {
// "2:00 – 4:00 PM" — en dash, spaced, meridiem stated once when both ends
// share it. The client renders this string verbatim.
cases := []struct {
name string
fromH, fromM int
toH, toM int
want string
}{
{"afternoon window", 14, 0, 16, 0, "2:00 – 4:00 PM"},
{"morning window", 8, 0, 10, 0, "8:00 – 10:00 AM"},
{"straddles noon", 10, 0, 14, 0, "10:00 AM – 2:00 PM"},
{"half hour bounds", 18, 30, 20, 30, "6:30 – 8:30 PM"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
from := time.Date(2026, 9, 5, tc.fromH, tc.fromM, 0, 0, time.UTC)
to := time.Date(2026, 9, 5, tc.toH, tc.toM, 0, 0, time.UTC)
if got := FormatISTWindow(from, to); got != tc.want {
t.Errorf("FormatISTWindow = %q, want %q", got, tc.want)
}
})
}
}
func TestFormatISTDayUsesRelativeWordsOnlyWhenTrue(t *testing.T) {
now := ISTNow()
if got := FormatISTDay(now); got != "Today" {
t.Errorf("FormatISTDay(now) = %q, want \"Today\"", got)
}
if got := FormatISTDay(now.AddDate(0, 0, 1)); got != "Tomorrow" {
t.Errorf("FormatISTDay(tomorrow) = %q, want \"Tomorrow\"", got)
}
// Three days out has no relative word — it falls back to the dated form,
// which must not read as "Today".
got := FormatISTDay(now.AddDate(0, 0, 3))
if got == "Today" || got == "Tomorrow" {
t.Errorf("FormatISTDay(+3 days) = %q, want a dated label", got)
}
}
// FormatISTDay crossing a month boundary is the case a naive day+1 comparison
// gets wrong: 30 September plus one day is 1 October, not 31 September.
func TestFormatISTDayAcrossMonthEnd(t *testing.T) {
now := ISTNow()
tomorrow := now.AddDate(0, 0, 1)
if now.Month() == tomorrow.Month() {
t.Skip("not a month boundary today; the AddDate path is exercised by the general case")
}
if got := FormatISTDay(tomorrow); got != "Tomorrow" {
t.Errorf("FormatISTDay across a month end = %q, want \"Tomorrow\"", got)
}
}

View File

@@ -80,7 +80,18 @@ type Claims struct {
}
func GenerateToken(userID int, email string, roleID int, tenantID int, configID int, secret string) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour)
return GenerateTokenWithTTL(userID, email, roleID, tenantID, configID, secret, 24*time.Hour)
}
// GenerateTokenWithTTL is GenerateToken with an explicit lifetime.
//
// The customer app pairs a short access token with a long-lived refresh token,
// so a stolen access token expires in an hour rather than a day, while the
// customer still stays signed in for months. The miler and console surfaces
// keep the 24-hour default: they have no refresh endpoint, and shortening their
// token would sign a rider out mid-shift.
func GenerateTokenWithTTL(userID int, email string, roleID int, tenantID int, configID int, secret string, ttl time.Duration) (string, error) {
expirationTime := time.Now().Add(ttl)
claims := &Claims{
UserID: userID,
Email: email,

97
utils/response_cx.go Normal file
View File

@@ -0,0 +1,97 @@
package utils
import "github.com/gofiber/fiber/v2"
// Customer-app (doormile_cx) response envelope.
//
// Deliberately a separate helper set from OK/List/Fail rather than a reuse of
// them. The customer contract fixes three things the miler/console contract
// does not: `message` is always present on success (empty string, never
// omitted), the machine-readable code is nested under `error.code` rather than
// sitting at the top level, and `total`/`nextCursor` ride the list envelope.
// Serving one endpoint in one shape and its neighbour in another is exactly
// what cost the miler client a release on /miler/verify-pin — so the customer
// surface gets its own helpers and every /customer/* response goes through
// them, auth included.
// CxErr* are the error codes in the customer contract. The client branches on
// these; `message` is customer-safe English shown verbatim in the UI.
const (
CxErrInvalid = "invalid"
CxErrInvalidName = "invalid_name"
CxErrInvalidOtp = "invalid_otp"
CxErrUnauthorized = "unauthorized"
CxErrForbidden = "forbidden"
CxErrNotFound = "not_found"
CxErrConflict = "conflict"
CxErrUnserviceable = "unserviceable"
CxErrRateLimited = "rate_limited"
CxErrServer = "server_error"
)
// CxOK is the success envelope: {success, data, message}.
func CxOK(c *fiber.Ctx, data interface{}) error {
return c.JSON(fiber.Map{"success": true, "data": data, "message": ""})
}
// CxCreated is CxOK with a 201.
func CxCreated(c *fiber.Ctx, data interface{}) error {
return c.Status(fiber.StatusCreated).
JSON(fiber.Map{"success": true, "data": data, "message": ""})
}
// CxList is the list envelope. data is always an array — never null, because
// the client types it as a list and a null throws in the parser. nextCursor is
// null when the page is the last one.
func CxList(c *fiber.Ctx, data interface{}, total int, nextCursor *string) error {
body := fiber.Map{
"success": true,
"data": data,
"total": total,
"nextCursor": nil,
"message": "",
}
if nextCursor != nil && *nextCursor != "" {
body["nextCursor"] = *nextCursor
}
return c.JSON(body)
}
// CxFail is the error envelope: {success:false, message, error:{code}}. The
// app funnels every failure into one retryable error state and renders
// `message` verbatim, so callers must pass customer-safe English here — never
// an enum key, a driver error or a wrapped stack.
func CxFail(c *fiber.Ctx, status int, code, msg string) error {
return c.Status(status).JSON(fiber.Map{
"success": false,
"message": msg,
"error": fiber.Map{"code": code},
})
}
// Shorthands for the statuses the contract pins to a specific code.
func CxBadRequest(c *fiber.Ctx, msg string) error {
return CxFail(c, fiber.StatusBadRequest, CxErrInvalid, msg)
}
func CxUnauthorized(c *fiber.Ctx, msg string) error {
return CxFail(c, fiber.StatusUnauthorized, CxErrUnauthorized, msg)
}
func CxForbidden(c *fiber.Ctx, msg string) error {
return CxFail(c, fiber.StatusForbidden, CxErrForbidden, msg)
}
func CxNotFound(c *fiber.Ctx, msg string) error {
return CxFail(c, fiber.StatusNotFound, CxErrNotFound, msg)
}
func CxConflict(c *fiber.Ctx, msg string) error {
return CxFail(c, fiber.StatusConflict, CxErrConflict, msg)
}
// CxInternal never leaks the underlying error to the customer. Log the real
// one; the app shows this.
func CxInternal(c *fiber.Ctx) error {
return CxFail(c, fiber.StatusInternalServerError, CxErrServer, "Something went wrong")
}