backend requirements onthe xustomer app
This commit is contained in:
147
CLAUDE.md
147
CLAUDE.md
@@ -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
|
each has been directly inspected this session (via `routes.go`); the actual
|
||||||
client codebases (Flutter, React) have not been opened in this session.
|
client codebases (Flutter, React) have not been opened in this session.
|
||||||
|
|
||||||
1. **Customer app (B2C)** — Flutter. Auth via Firebase OTP (phone). Backend
|
1. **Customer app (B2C)** — Flutter, being replaced: `doormile_customer_app`
|
||||||
surface: 19 customer routes **[verified this session]**
|
(PIN auth, single-destination bookings) is retired in favour of
|
||||||
(`customer`/`customerAuth` groups) — booking creation, tracking,
|
`doormile_cx`. Backend surface rebuilt 2026-09-05 to the Customer App v1
|
||||||
`AppCustomer`/`AppCustomerLocation`. **[carried forward]**: reported built
|
contract: **28 customer routes** (`customer`/`customerAuth` groups) — OTP
|
||||||
and verified in prior sessions; the last live end-to-end test was
|
auth with refresh, serviceability/slots/limits, place proxy, fare estimate,
|
||||||
blocked here — see §9.
|
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
|
2. **Miler app** — Flutter, for delivery riders. Backend surface: 38 routes
|
||||||
**[verified this session]** (`miler`/`milerAuth` groups) — duty
|
**[verified this session]** (`miler`/`milerAuth` groups) — duty
|
||||||
start/stop, GPS pings, assignment accept/reject/cancel, delivery
|
start/stop, GPS pings, assignment accept/reject/cancel, delivery
|
||||||
@@ -462,6 +464,139 @@ 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)
|
## 9. Current blockers & open work (whole-project level)
|
||||||
|
|
||||||
**[carried forward]**
|
**[carried forward]**
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ type Config struct {
|
|||||||
// stay unordered rather than assignment failing.
|
// stay unordered rather than assignment failing.
|
||||||
RouteOptimizerURL string
|
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
|
// 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,
|
// 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
|
// 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"),
|
AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"),
|
||||||
|
|
||||||
RouteOptimizerURL: getEnv("ROUTE_OPTIMIZER_URL", "https://routes.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", ""),
|
TrustedProxies: getEnv("TRUSTED_PROXIES", ""),
|
||||||
SMTPHost: getEnv("SMTP_HOST", ""),
|
SMTPHost: getEnv("SMTP_HOST", ""),
|
||||||
SMTPPort: getEnv("SMTP_PORT", "465"),
|
SMTPPort: getEnv("SMTP_PORT", "465"),
|
||||||
|
|||||||
92
config/config_test.go
Normal file
92
config/config_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,3 +168,74 @@ const (
|
|||||||
ExceptionResolved = "Resolved"
|
ExceptionResolved = "Resolved"
|
||||||
ExceptionClosed = "Closed"
|
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
116
constants/constants_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"doormile/db"
|
"doormile/db"
|
||||||
"doormile/dto"
|
"doormile/dto"
|
||||||
"doormile/internal/assignment"
|
"doormile/internal/assignment"
|
||||||
|
"doormile/internal/cxstage"
|
||||||
"doormile/internal/notify"
|
"doormile/internal/notify"
|
||||||
"doormile/models"
|
"doormile/models"
|
||||||
"doormile/utils"
|
"doormile/utils"
|
||||||
@@ -2770,6 +2771,18 @@ func AdminCancelBooking(c *fiber.Ctx) error {
|
|||||||
return utils.Internal(c, "failed to cancel booking")
|
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 {
|
if booking.Assignedmileruserid != nil {
|
||||||
db.DB.Model(&models.MilerProfile{}).
|
db.DB.Model(&models.MilerProfile{}).
|
||||||
Where("userid = ?", *booking.Assignedmileruserid).
|
Where("userid = ?", *booking.Assignedmileruserid).
|
||||||
@@ -2849,6 +2862,15 @@ func AdminBulkCancelBookings(c *fiber.Ctx) error {
|
|||||||
continue
|
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 {
|
if booking.Assignedmileruserid != nil {
|
||||||
db.DB.Model(&models.MilerProfile{}).
|
db.DB.Model(&models.MilerProfile{}).
|
||||||
Where("userid = ?", *booking.Assignedmileruserid).
|
Where("userid = ?", *booking.Assignedmileruserid).
|
||||||
@@ -3895,3 +3917,13 @@ func InternalReassign(c *fiber.Ctx) error {
|
|||||||
"booking_id": booking.Bookingid,
|
"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
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"doormile/constants"
|
"doormile/constants"
|
||||||
"doormile/db"
|
"doormile/db"
|
||||||
|
"doormile/internal/cxstage"
|
||||||
"doormile/internal/notify"
|
"doormile/internal/notify"
|
||||||
"doormile/internal/routing"
|
"doormile/internal/routing"
|
||||||
"doormile/models"
|
"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)
|
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
|
return &booking, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,26 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"doormile/config"
|
|
||||||
"doormile/constants"
|
|
||||||
"doormile/db"
|
"doormile/db"
|
||||||
"doormile/dto"
|
"doormile/dto"
|
||||||
"doormile/internal/assignment"
|
|
||||||
"doormile/models"
|
"doormile/models"
|
||||||
"doormile/utils"
|
"doormile/utils"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func generateBookingNo() string {
|
// Customer profile and saved addresses.
|
||||||
b := make([]byte, 4)
|
//
|
||||||
rand.Read(b)
|
// The rest of the customer surface — auth, catalogue, estimate, bookings,
|
||||||
return fmt.Sprintf("DM-BK-%X-%d", b, time.Now().Unix()%100000)
|
// 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 {
|
func calculateDistance(lat1, lon1, lat2, lon2 float64) float64 {
|
||||||
const R = 6371.0
|
const R = 6371.0
|
||||||
@@ -40,190 +37,15 @@ func calculateVolumetricWeight(length, width, height float64) float64 {
|
|||||||
return (length * width * height) / 5000.0
|
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 {
|
func GetCustomerProfile(c *fiber.Ctx) error {
|
||||||
customerID := c.Locals("userid").(int)
|
customerID := c.Locals("userid").(int)
|
||||||
|
|
||||||
var customer models.AppCustomer
|
var customer models.AppCustomer
|
||||||
if err := db.DB.First(&customer, customerID).Error; err != nil {
|
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 {
|
func UpdateCustomerProfile(c *fiber.Ctx) error {
|
||||||
@@ -231,76 +53,91 @@ func UpdateCustomerProfile(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
var customer models.AppCustomer
|
var customer models.AppCustomer
|
||||||
if err := db.DB.First(&customer, customerID).Error; err != nil {
|
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 {
|
// Pointer fields: an omitted key leaves the stored value alone, an explicit
|
||||||
Firstname string `json:"firstname"`
|
// value overwrites it. The previous version cleared email and lastname on
|
||||||
Lastname string `json:"lastname"`
|
// every call that did not resend them, which quietly wiped a customer's
|
||||||
Email string `json:"email"`
|
// email the first time they edited their name.
|
||||||
Defaultlatitude float64 `json:"defaultlatitude"`
|
var req struct {
|
||||||
Defaultlongitude float64 `json:"defaultlongitude"`
|
Name *string `json:"name"`
|
||||||
Defaultpincode string `json:"defaultpincode"`
|
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 req.Name != nil {
|
||||||
if err := c.BodyParser(req); err != nil {
|
first, last := splitName(*req.Name)
|
||||||
return utils.BadRequest(c, "invalid request body")
|
if first == "" {
|
||||||
|
return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name")
|
||||||
|
}
|
||||||
|
customer.Firstname, customer.Lastname = first, last
|
||||||
}
|
}
|
||||||
|
if req.Email != nil {
|
||||||
if req.Firstname != "" {
|
customer.Email = *req.Email
|
||||||
customer.Firstname = req.Firstname
|
|
||||||
}
|
}
|
||||||
customer.Lastname = req.Lastname
|
if req.Defaultlatitude != nil {
|
||||||
customer.Email = req.Email
|
customer.Defaultlatitude = *req.Defaultlatitude
|
||||||
if req.Defaultlatitude != 0 {
|
|
||||||
customer.Defaultlatitude = req.Defaultlatitude
|
|
||||||
}
|
}
|
||||||
if req.Defaultlongitude != 0 {
|
if req.Defaultlongitude != nil {
|
||||||
customer.Defaultlongitude = req.Defaultlongitude
|
customer.Defaultlongitude = *req.Defaultlongitude
|
||||||
}
|
}
|
||||||
if req.Defaultpincode != "" {
|
if req.Defaultpincode != nil {
|
||||||
customer.Defaultpincode = req.Defaultpincode
|
customer.Defaultpincode = *req.Defaultpincode
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.DB.Save(&customer).Error; err != nil {
|
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 {
|
func GetCustomerLocations(c *fiber.Ctx) error {
|
||||||
customerID := c.Locals("userid").(int)
|
customerID := c.Locals("userid").(int)
|
||||||
|
|
||||||
var locations []models.AppCustomerLocation
|
var locations []models.AppCustomerLocation
|
||||||
if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active").Find(&locations).Error; err != nil {
|
if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active").
|
||||||
return utils.Internal(c, "failed to fetch locations")
|
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 {
|
func CreateCustomerLocation(c *fiber.Ctx) error {
|
||||||
customerID := c.Locals("userid").(int)
|
customerID := c.Locals("userid").(int)
|
||||||
|
|
||||||
var count int64
|
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 {
|
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)
|
req := new(dto.LocationCreateRequest)
|
||||||
if err := c.BodyParser(req); err != nil {
|
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 {
|
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 {
|
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{
|
location := models.AppCustomerLocation{
|
||||||
@@ -320,27 +157,29 @@ func CreateCustomerLocation(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := db.DB.Create(&location).Error; err != nil {
|
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 {
|
func UpdateCustomerLocation(c *fiber.Ctx) error {
|
||||||
customerID := c.Locals("userid").(int)
|
customerID := c.Locals("userid").(int)
|
||||||
locationID, err := strconv.Atoi(c.Params("id"))
|
locationID, err := strconv.Atoi(c.Params("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.BadRequest(c, "invalid location ID")
|
return utils.CxBadRequest(c, "That address could not be found")
|
||||||
}
|
}
|
||||||
|
|
||||||
var location models.AppCustomerLocation
|
var location models.AppCustomerLocation
|
||||||
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).First(&location).Error; err != nil {
|
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).
|
||||||
return utils.NotFound(c, "location not found")
|
First(&location).Error; err != nil {
|
||||||
|
return utils.CxNotFound(c, "That address could not be found")
|
||||||
}
|
}
|
||||||
|
|
||||||
req := new(dto.LocationCreateRequest)
|
req := new(dto.LocationCreateRequest)
|
||||||
if err := c.BodyParser(req); err != nil {
|
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 != "" {
|
if req.Label != "" {
|
||||||
@@ -366,392 +205,58 @@ func UpdateCustomerLocation(c *fiber.Ctx) error {
|
|||||||
location.Isdefault = req.Isdefault
|
location.Isdefault = req.Isdefault
|
||||||
|
|
||||||
if 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 {
|
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 {
|
func DeleteCustomerLocation(c *fiber.Ctx) error {
|
||||||
customerID := c.Locals("userid").(int)
|
customerID := c.Locals("userid").(int)
|
||||||
locationID, err := strconv.Atoi(c.Params("id"))
|
locationID, err := strconv.Atoi(c.Params("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.BadRequest(c, "invalid location ID")
|
return utils.CxBadRequest(c, "That address could not be found")
|
||||||
}
|
}
|
||||||
|
|
||||||
var location models.AppCustomerLocation
|
var location models.AppCustomerLocation
|
||||||
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).First(&location).Error; err != nil {
|
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).
|
||||||
return utils.NotFound(c, "location not found")
|
First(&location).Error; err != nil {
|
||||||
|
return utils.CxNotFound(c, "That address could not be found")
|
||||||
}
|
}
|
||||||
|
|
||||||
location.Status = "InActive"
|
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 {
|
// renderSavedAddress is the one shape a saved address is returned in, matching
|
||||||
customerID := c.Locals("userid").(int)
|
// 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
|
||||||
req := new(dto.PickupBookingRequest)
|
// object to the client.
|
||||||
if err := c.BodyParser(req); err != nil {
|
func renderSavedAddress(l *models.AppCustomerLocation) fiber.Map {
|
||||||
return utils.BadRequest(c, "invalid request body")
|
title := l.Label
|
||||||
|
if title == "" {
|
||||||
|
title = l.Address
|
||||||
}
|
}
|
||||||
|
return fiber.Map{
|
||||||
if req.Pickupaddress == "" || req.Pickuppincode == "" {
|
"id": strconv.Itoa(l.Appcustomerlocationid),
|
||||||
return utils.BadRequest(c, "pickup address and pincode are required")
|
"label": l.Label,
|
||||||
}
|
"title": title,
|
||||||
|
"sub": joinNonEmpty(", ", l.Address, l.Landmark, l.City, l.Pincode),
|
||||||
if len(req.Parcels) == 0 {
|
"recipientName": l.Receivername,
|
||||||
return utils.BadRequest(c, "at least one parcel is required")
|
"recipientPhone": l.Receiverphone,
|
||||||
}
|
"lat": l.Latitude,
|
||||||
|
"lng": l.Longitude,
|
||||||
// Geocode delivery pincode to lat/lon when the app doesn't supply coordinates.
|
"isDefault": l.Isdefault,
|
||||||
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",
|
|
||||||
// A B2C booking is always collected at the sender's own door, so the source
|
|
||||||
// type is recorded rather than left blank — the rider app titles the stop
|
|
||||||
// with the sender's name and address instead of grouping it under the
|
|
||||||
// rider's own base.
|
|
||||||
Pickupsourcetype: constants.PickupSourceCustomer,
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fillConsignmentFacts attaches the consignment's tracking number and live
|
|
||||||
// status to each booking that has one.
|
|
||||||
//
|
|
||||||
// A booking freezes at Converted_To_Consignment the moment it is collected,
|
|
||||||
// while the parcel keeps moving on the consignment — so without this a customer
|
|
||||||
// sees a status that stopped updating the instant their parcel was picked up.
|
|
||||||
//
|
|
||||||
// The tracking number matters more: GET /customer/track/:trackingno is keyed on
|
|
||||||
// it, and nothing else the customer app can read exposes it. Tracking was
|
|
||||||
// unreachable from the app not because the endpoint was missing but because the
|
|
||||||
// number never travelled to it.
|
|
||||||
//
|
|
||||||
// Batched — one query for the whole page, not one per row.
|
|
||||||
func fillConsignmentFacts(bookings []models.PickupBooking) {
|
|
||||||
ids := make([]int, 0, len(bookings))
|
|
||||||
for _, b := range bookings {
|
|
||||||
if b.Consignmentid != nil {
|
|
||||||
ids = append(ids, *b.Consignmentid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var consignments []models.Consignment
|
|
||||||
if err := db.DB.Select("consignmentid, trackingno, status").
|
|
||||||
Where("consignmentid IN ?", ids).Find(&consignments).Error; err != nil {
|
|
||||||
utils.Warn("fillConsignmentFacts: could not load consignments", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
byID := make(map[int]models.Consignment, len(consignments))
|
|
||||||
for _, cn := range consignments {
|
|
||||||
byID[cn.Consignmentid] = cn
|
|
||||||
}
|
|
||||||
for i := range bookings {
|
|
||||||
if bookings[i].Consignmentid == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if cn, ok := byID[*bookings[i].Consignmentid]; ok {
|
|
||||||
bookings[i].Trackingno = cn.Trackingno
|
|
||||||
bookings[i].Consignmentstatus = cn.Status
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
fillConsignmentFacts(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")
|
|
||||||
}
|
|
||||||
|
|
||||||
one := []models.PickupBooking{booking}
|
|
||||||
fillConsignmentFacts(one)
|
|
||||||
booking = one[0]
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|||||||
614
controllers/cxAuthController.go
Normal file
614
controllers/cxAuthController.go
Normal 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)
|
||||||
|
}
|
||||||
895
controllers/cxBookingController.go
Normal file
895
controllers/cxBookingController.go
Normal file
@@ -0,0 +1,895 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
760
controllers/cxBookingView.go
Normal file
760
controllers/cxBookingView.go
Normal 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
|
||||||
|
}
|
||||||
563
controllers/cxCatalogueController.go
Normal file
563
controllers/cxCatalogueController.go
Normal 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
|
||||||
|
}
|
||||||
100
controllers/cxConsignmentHooks.go
Normal file
100
controllers/cxConsignmentHooks.go
Normal 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)
|
||||||
|
}
|
||||||
856
controllers/cxCustomerApp_test.go
Normal file
856
controllers/cxCustomerApp_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
85
controllers/cxDeviceController.go
Normal file
85
controllers/cxDeviceController.go
Normal 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})
|
||||||
|
}
|
||||||
266
controllers/cxFareController.go
Normal file
266
controllers/cxFareController.go
Normal 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
713
controllers/cxHttp_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
181
controllers/cxIdentifierScramble.go
Normal file
181
controllers/cxIdentifierScramble.go
Normal 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
|
||||||
|
}
|
||||||
281
controllers/cxIdentifierScramble_test.go
Normal file
281
controllers/cxIdentifierScramble_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
controllers/cxIdentifiers.go
Normal file
95
controllers/cxIdentifiers.go
Normal 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))
|
||||||
|
}
|
||||||
160
controllers/cxOpsController.go
Normal file
160
controllers/cxOpsController.go
Normal 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)
|
||||||
|
}
|
||||||
251
controllers/cxPickupFanout.go
Normal file
251
controllers/cxPickupFanout.go
Normal 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
|
||||||
|
}
|
||||||
350
controllers/cxPlacesController.go
Normal file
350
controllers/cxPlacesController.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -443,8 +443,13 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
|
|||||||
// the base gate — and ridercharges the order amount, both written the same way
|
// the base gate — and ridercharges the order amount, both written the same way
|
||||||
// MilerDeliverConsignment writes them for a final-mile leg. Without this an
|
// MilerDeliverConsignment writes them for a final-mile leg. Without this an
|
||||||
// intercity rider's every job reported zero distance and zero value.
|
// intercity rider's every job reported zero distance and zero value.
|
||||||
var booking models.PickupBooking
|
// Resolved through bookingdestinations, not through
|
||||||
if tx.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil {
|
// 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
|
dropLat, dropLon := lat, lon
|
||||||
if dropLat == 0 && dropLon == 0 {
|
if dropLat == 0 && dropLon == 0 {
|
||||||
dropLat, dropLon = hub.Latitude, hub.Longitude
|
dropLat, dropLon = hub.Latitude, hub.Longitude
|
||||||
@@ -479,10 +484,25 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
|
|||||||
return utils.Internal(c, "failed to update miler availability")
|
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 {
|
if err := tx.Commit().Error; err != nil {
|
||||||
return utils.Internal(c, "failed to record the handover")
|
return utils.Internal(c, "failed to record the handover")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyInTransit()
|
||||||
|
|
||||||
// Best-effort, on an already-bound subject — a dropped event must never fail
|
// Best-effort, on an already-bound subject — a dropped event must never fail
|
||||||
// a handover the rider has physically completed.
|
// a handover the rider has physically completed.
|
||||||
if db.Js != nil {
|
if db.Js != nil {
|
||||||
|
|||||||
@@ -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
|
// step / road-optimized sequence lives on the active assignment row, written
|
||||||
// by the express route optimizer. Step 0 = not sequenced (single-stop or
|
// by the express route optimizer. Step 0 = not sequenced (single-stop or
|
||||||
// optimizer down), never a position — passed through verbatim.
|
// optimizer down), never a position — passed through verbatim.
|
||||||
@@ -287,41 +309,6 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
|
|||||||
var customer models.AppCustomer
|
var customer models.AppCustomer
|
||||||
db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer)
|
db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer)
|
||||||
|
|
||||||
// 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 {
|
|
||||||
paymentMode = cn.Paymentmode
|
|
||||||
codAmount = cn.Codamount - cn.Codcollected
|
|
||||||
if codAmount < 0 {
|
|
||||||
codAmount = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if codAmount == 0 {
|
|
||||||
for _, p := range b.Payments {
|
|
||||||
if p.Paymentmode == constants.PaymentModeCash && p.Paymentstatus == constants.PaymentStatusPending {
|
|
||||||
codAmount = p.Amount
|
|
||||||
paymentMode = p.Paymentmode
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if paymentMode == "" {
|
|
||||||
paymentMode = p.Paymentmode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Where this parcel is collected FROM, and what kind of place that is, so
|
// 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
|
// Home can title the stop correctly. Without pickup_source_type every
|
||||||
// logistics pickup was grouped under the rider's own base name and a
|
// logistics pickup was grouped under the rider's own base name and a
|
||||||
@@ -334,79 +321,145 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
|
|||||||
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b,
|
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b,
|
||||||
customer.Firstname+" "+customer.Lastname)
|
customer.Firstname+" "+customer.Lastname)
|
||||||
|
|
||||||
// next_action / next_hub: the leg this parcel is on, rebuilt from server
|
stops := milerStopsForBooking(&b, destinationsByBooking[b.Bookingid])
|
||||||
// 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 b.Status == constants.BookingCancelled {
|
|
||||||
nextAction = constants.NextActionNone
|
|
||||||
} else if b.Consignmentid != nil {
|
|
||||||
if cn, ok := codByConsignment[*b.Consignmentid]; ok {
|
|
||||||
nextAction = nextActionForConsignment(cn.Status)
|
|
||||||
nextHub = nextHubForConsignment(&cn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
row := fiber.Map{
|
for _, stop := range stops {
|
||||||
"bookingid": b.Bookingid,
|
// Cash-to-collect: prefer the consignment's COD once it exists, otherwise
|
||||||
"bookingreference": b.Bookingno,
|
// fall back to a pending Cash payment on the booking. Prepaid/UPI stays 0.
|
||||||
"status": b.Status,
|
codAmount := 0.0
|
||||||
"stoptype": milerStopType(b.Status),
|
paymentMode := ""
|
||||||
"consignmentid": b.Consignmentid,
|
var consignmentStatus string
|
||||||
"consignmentstatus": consignmentStatus,
|
|
||||||
"next_action": nextAction,
|
// next_action / next_hub: the leg this parcel is on, rebuilt from server
|
||||||
"next_hub": nextHub,
|
// state on every poll. pickup-complete used to be the only place that ever
|
||||||
"pickup_source_type": sourceType,
|
// said it, so a restart mid-leg left the app with nothing authoritative to
|
||||||
"sourceid": sourceID,
|
// read — consignment status alone cannot separate a hub-routed parcel from
|
||||||
"pickuplocationid": sourceID,
|
// a freshly-collected hyperlocal one, since both can sit on Created.
|
||||||
"pickup_source_name": sourceName,
|
nextAction := constants.NextActionPickup
|
||||||
"pickupaddress": sourceAddress,
|
var nextHub fiber.Map
|
||||||
"pickuplatitude": b.Pickuplatitude,
|
|
||||||
"pickuplongitude": b.Pickuplongitude,
|
if stop.consignmentID != nil {
|
||||||
"deliveryaddress": b.Deliveryaddress,
|
if cn, ok := codByConsignment[*stop.consignmentID]; ok {
|
||||||
"deliverylatitude": b.Deliverylatitude,
|
consignmentStatus = cn.Status
|
||||||
"deliverylongitude": b.Deliverylongitude,
|
paymentMode = cn.Paymentmode
|
||||||
"customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
codAmount = cn.Codamount - cn.Codcollected
|
||||||
"customerphone": customer.Phone,
|
if codAmount < 0 {
|
||||||
// Arrival fact: the rider app derives its "Arrived" rung from
|
codAmount = 0
|
||||||
// pickup-scheduled + a non-null reachedat, so this survives an app
|
}
|
||||||
// restart without a separate booking status. Null until the rider hits
|
nextAction = nextActionForConsignment(cn.Status)
|
||||||
// the reached endpoint. arrivallatitude/longitude are the GPS captured
|
nextHub = nextHubForConsignment(&cn)
|
||||||
// at that moment (null if the app sent none).
|
} else {
|
||||||
"reachedat": b.Arrivedat,
|
// The consignment row did not load. Fall back to what this
|
||||||
"arrivallatitude": b.Arrivallatitude,
|
// destination asked for rather than to zero — a rider shown
|
||||||
"arrivallongitude": b.Arrivallongitude,
|
// ₹0 collects nothing, and the customer's money is the one
|
||||||
"parcels": b.Parcels,
|
// thing that must not silently vanish from a stop.
|
||||||
"serviceoptions": b.ServiceOptions,
|
codAmount = stop.codAmount
|
||||||
"codamount": codAmount,
|
}
|
||||||
"paymentmode": paymentMode,
|
} else if stop.codAmount > 0 {
|
||||||
"createdat": b.Createdat,
|
// Not yet collected: the collection is what the customer
|
||||||
// Route sequencing — 0/empty when the stop was never sequenced.
|
// requested for this door.
|
||||||
// sequencedat is the authoritative-order signal: non-null means the
|
codAmount = stop.codAmount
|
||||||
// console/optimizer fixed this stop's position and the app must follow
|
}
|
||||||
// step exactly; null means no route was assigned and the app is free to
|
if b.Status == constants.BookingCancelled {
|
||||||
// fall back to its own nearest-first ordering.
|
nextAction = constants.NextActionNone
|
||||||
"step": 0,
|
}
|
||||||
"cumulativekms": 0.0,
|
|
||||||
"etaminutes": 0,
|
// The pickup fee is charged once for the visit, so it is only offered
|
||||||
"cumulativeeta": 0,
|
// against the first stop — asking a rider to collect it again at the
|
||||||
"sequencedat": nil,
|
// 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
|
||||||
|
paymentMode = p.Paymentmode
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if paymentMode == "" {
|
||||||
|
paymentMode = p.Paymentmode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row := fiber.Map{
|
||||||
|
"bookingid": b.Bookingid,
|
||||||
|
"bookingreference": b.Bookingno,
|
||||||
|
"status": b.Status,
|
||||||
|
"stoptype": milerStopType(b.Status),
|
||||||
|
// 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,
|
||||||
|
"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": 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
|
||||||
|
// pickup-scheduled + a non-null reachedat, so this survives an app
|
||||||
|
// restart without a separate booking status. Null until the rider hits
|
||||||
|
// the reached endpoint. arrivallatitude/longitude are the GPS captured
|
||||||
|
// at that moment (null if the app sent none).
|
||||||
|
"reachedat": b.Arrivedat,
|
||||||
|
"arrivallatitude": b.Arrivallatitude,
|
||||||
|
"arrivallongitude": b.Arrivallongitude,
|
||||||
|
"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.
|
||||||
|
// sequencedat is the authoritative-order signal: non-null means the
|
||||||
|
// console/optimizer fixed this stop's position and the app must follow
|
||||||
|
// step exactly; null means no route was assigned and the app is free to
|
||||||
|
// fall back to its own nearest-first ordering.
|
||||||
|
"step": 0,
|
||||||
|
"cumulativekms": 0.0,
|
||||||
|
"etaminutes": 0,
|
||||||
|
"cumulativeeta": 0,
|
||||||
|
"sequencedat": nil,
|
||||||
|
}
|
||||||
|
if a, ok := seqByBooking[b.Bookingid]; ok {
|
||||||
|
row["step"] = a.Step
|
||||||
|
row["cumulativekms"] = a.Cumulativekms
|
||||||
|
row["etaminutes"] = a.Etaminutes
|
||||||
|
row["cumulativeeta"] = a.Cumulativeeta
|
||||||
|
row["sequencedat"] = a.Sequencedat
|
||||||
|
}
|
||||||
|
response = append(response, row)
|
||||||
}
|
}
|
||||||
if a, ok := seqByBooking[b.Bookingid]; ok {
|
|
||||||
row["step"] = a.Step
|
|
||||||
row["cumulativekms"] = a.Cumulativekms
|
|
||||||
row["etaminutes"] = a.Etaminutes
|
|
||||||
row["cumulativeeta"] = a.Cumulativeeta
|
|
||||||
row["sequencedat"] = a.Sequencedat
|
|
||||||
}
|
|
||||||
response = append(response, row)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sequenced stops ascend by step; unsequenced (step 0) fall to the end while
|
// 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 {
|
sort.SliceStable(response, func(i, j int) bool {
|
||||||
si, sj := response[i]["step"].(int), response[j]["step"].(int)
|
si, sj := response[i]["step"].(int), response[j]["step"].(int)
|
||||||
switch {
|
switch {
|
||||||
@@ -424,6 +477,121 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
|
|||||||
return utils.List(c, response, int64(len(response)))
|
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
|
// milerConsignmentForRider loads a consignment and confirms it belongs to this
|
||||||
// rider — either it is linked to a booking currently assigned to them, or they
|
// 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
|
// are the one who collected it (Createdby). Returns a stable error code on
|
||||||
@@ -437,6 +605,19 @@ func milerConsignmentForRider(milerUserID, consignmentID int) (*models.Consignme
|
|||||||
db.DB.Model(&models.PickupBooking{}).
|
db.DB.Model(&models.PickupBooking{}).
|
||||||
Where("consignmentid = ? AND assignedmileruserid = ?", consignmentID, milerUserID).
|
Where("consignmentid = ? AND assignedmileruserid = ?", consignmentID, milerUserID).
|
||||||
Count(&count)
|
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 {
|
if count == 0 && consignment.Createdby != milerUserID {
|
||||||
return nil, constants.ErrConsignmentNotAssigned, fmt.Errorf("not this rider's consignment")
|
return nil, constants.ErrConsignmentNotAssigned, fmt.Errorf("not this rider's consignment")
|
||||||
}
|
}
|
||||||
@@ -544,14 +725,30 @@ func MilerStartDelivery(c *fiber.Ctx) error {
|
|||||||
return utils.Internal(c, "failed to update miler availability")
|
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 {
|
if err := tx.Commit().Error; err != nil {
|
||||||
return utils.Internal(c, "failed to start delivery")
|
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
|
// 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).
|
// 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
|
var customer models.AppCustomer
|
||||||
if db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error == nil && customer.Devicetoken != "" {
|
if db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error == nil && customer.Devicetoken != "" {
|
||||||
body := "Your parcel is out for delivery."
|
body := "Your parcel is out for delivery."
|
||||||
@@ -598,16 +795,32 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
|
|||||||
return utils.BadRequest(c, "deliveredtoname is required")
|
return utils.BadRequest(c, "deliveredtoname is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
var consignment models.Consignment
|
// Ownership and the booking behind the parcel, via the one helper that
|
||||||
if err := db.DB.First(&consignment, consignmentID).Error; err != nil {
|
// understands a multi-destination pickup. This used to be a direct
|
||||||
return utils.NotFound(c, "consignment not found")
|
// `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
|
||||||
var booking models.PickupBooking
|
// not found" and could not close the delivery at all.
|
||||||
if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID).
|
// Both failures answer 404 with the exact messages this endpoint has always
|
||||||
First(&booking).Error; err != nil {
|
// 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")
|
||||||
|
}
|
||||||
return utils.NotFound(c, "assigned consignment not found")
|
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 {
|
if consignment.Status != constants.ConsignmentOutForDelivery {
|
||||||
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
|
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
|
||||||
@@ -702,10 +915,27 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
|
|||||||
return utils.Internal(c, "failed to close assignment")
|
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 {
|
if err := tx.Commit().Error; err != nil {
|
||||||
return utils.Internal(c, "failed to confirm delivery")
|
return utils.Internal(c, "failed to confirm delivery")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyDelivered()
|
||||||
|
|
||||||
if db.Js != nil {
|
if db.Js != nil {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"bookingid": booking.Bookingid,
|
"bookingid": booking.Bookingid,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@@ -16,6 +15,7 @@ import (
|
|||||||
"doormile/db"
|
"doormile/db"
|
||||||
"doormile/dto"
|
"doormile/dto"
|
||||||
"doormile/internal/assignment"
|
"doormile/internal/assignment"
|
||||||
|
"doormile/internal/cxstage"
|
||||||
"doormile/internal/legs"
|
"doormile/internal/legs"
|
||||||
"doormile/internal/notify"
|
"doormile/internal/notify"
|
||||||
"doormile/internal/routing"
|
"doormile/internal/routing"
|
||||||
@@ -26,12 +26,6 @@ import (
|
|||||||
"github.com/redis/go-redis/v9"
|
"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 {
|
func LoginMiler(cfg *config.Config) fiber.Handler {
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
req := new(dto.MilerLoginRequest)
|
req := new(dto.MilerLoginRequest)
|
||||||
@@ -529,10 +523,40 @@ func AcceptMilerAssignment(c *fiber.Ctx) error {
|
|||||||
return utils.Internal(c, "failed to update miler availability")
|
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 {
|
if err := tx.Commit().Error; err != nil {
|
||||||
return utils.Internal(c, "failed to commit assignment acceptance")
|
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
|
// 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.
|
// re-sequence the rider off the request path. No-op below two active stops.
|
||||||
routing.SequenceMilerStopsAsync(milerUserID)
|
routing.SequenceMilerStopsAsync(milerUserID)
|
||||||
@@ -687,6 +711,17 @@ func MilerCancelAssignment(c *fiber.Ctx) error {
|
|||||||
return utils.Internal(c, "failed to update miler availability")
|
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 {
|
if err := tx.Commit().Error; err != nil {
|
||||||
return utils.Internal(c, "failed to commit cancellation")
|
return utils.Internal(c, "failed to commit cancellation")
|
||||||
}
|
}
|
||||||
@@ -751,9 +786,29 @@ func BookingReachedCustomer(c *fiber.Ctx) error {
|
|||||||
return utils.Internal(c, "failed to update miler availability")
|
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 {
|
if err := tx.Commit().Error; err != nil {
|
||||||
return utils.Internal(c, "failed to confirm arrival")
|
return utils.Internal(c, "failed to confirm arrival")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go cxstage.Notify(booking.Bookingid, nil, constants.CxStageArrived)
|
||||||
|
|
||||||
return utils.OK(c, fiber.Map{
|
return utils.OK(c, fiber.Map{
|
||||||
"bookingid": booking.Bookingid,
|
"bookingid": booking.Bookingid,
|
||||||
"status": booking.Status,
|
"status": booking.Status,
|
||||||
@@ -859,12 +914,19 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
|
|||||||
return utils.NotFound(c, "assigned booking not found")
|
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 {
|
type ParcelUpdate struct {
|
||||||
ParcelID int `json:"parcel_id"`
|
ParcelID int `json:"parcel_id"`
|
||||||
Weight float64 `json:"weight"`
|
Weight float64 `json:"weight"`
|
||||||
Length float64 `json:"length"`
|
Length float64 `json:"length"`
|
||||||
Width float64 `json:"width"`
|
Width float64 `json:"width"`
|
||||||
Height float64 `json:"height"`
|
Height float64 `json:"height"`
|
||||||
|
Photos []string `json:"photos"`
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Parcels []ParcelUpdate `json:"parcels"`
|
Parcels []ParcelUpdate `json:"parcels"`
|
||||||
@@ -887,8 +949,13 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
|
|||||||
parcelMap[parcels[i].Bookingparcelid] = &parcels[i]
|
parcelMap[parcels[i].Bookingparcelid] = &parcels[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := utils.DBNow()
|
||||||
var totalChargeable float64
|
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 {
|
for _, upd := range req.Parcels {
|
||||||
p, ok := parcelMap[upd.ParcelID]
|
p, ok := parcelMap[upd.ParcelID]
|
||||||
@@ -900,10 +967,51 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
|
|||||||
p.Width = upd.Width
|
p.Width = upd.Width
|
||||||
p.Height = upd.Height
|
p.Height = upd.Height
|
||||||
p.Updatedat = now
|
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)
|
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{
|
return utils.OK(c, fiber.Map{
|
||||||
@@ -1030,30 +1138,17 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var parcels []models.BookingParcel
|
// One visit, N orders. A customer-app booking fans out into one consignment
|
||||||
tx.Where("bookingid = ?", bookingID).Find(&parcels)
|
// per destination — each with its own tracking number and its own journey —
|
||||||
|
// while a console-created booking, which has no destination rows, produces
|
||||||
var totalDead, totalChargeable, maxL, maxW, maxH float64
|
// the single consignment it always did. cxPickupLegs is what decides which
|
||||||
for _, p := range parcels {
|
// of those this is; nothing below needs to know.
|
||||||
vol := calculateVolumetricWeight(p.Length, p.Width, p.Height)
|
legs, err := cxPickupLegs(tx, &booking)
|
||||||
totalDead += p.Weight
|
if err != nil {
|
||||||
totalChargeable += math.Max(p.Weight, vol)
|
tx.Rollback()
|
||||||
if p.Length > maxL {
|
utils.Error("BookingPickupComplete: could not resolve pickup legs", "booking_id", bookingID, "error", err)
|
||||||
maxL = p.Length
|
return utils.Internal(c, "failed to read the parcels on this booking")
|
||||||
}
|
|
||||||
if p.Width > maxW {
|
|
||||||
maxW = p.Width
|
|
||||||
}
|
|
||||||
if p.Height > maxH {
|
|
||||||
maxH = p.Height
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if len(parcels) == 0 {
|
|
||||||
totalDead = 0.5
|
|
||||||
totalChargeable = 0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
trackingNo := generateTrackingNo()
|
|
||||||
|
|
||||||
// The base this parcel belongs to. Backend decides — the app is told where to
|
// 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
|
// go and never picks a base itself. resolveHandoverHub prefers the base the
|
||||||
@@ -1069,36 +1164,6 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
|||||||
utils.Warn("BookingPickupComplete: no base could be resolved for this pickup", "miler_user_id", milerUserID, "booking_id", bookingID)
|
utils.Warn("BookingPickupComplete: no base could be resolved for this pickup", "miler_user_id", milerUserID, "booking_id", bookingID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// 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, booking.Deliverypincode,
|
|
||||||
booking.Pickuplatitude, booking.Pickuplongitude,
|
|
||||||
booking.Deliverylatitude, booking.Deliverylongitude) {
|
|
||||||
if collectedStateEnabled() {
|
|
||||||
consignmentStatus = constants.ConsignmentCollectedByMiler
|
|
||||||
} else {
|
|
||||||
consignmentStatus = constants.ConsignmentOutForDelivery
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The consignment's tenant is the booking's own tenant (set explicitly at
|
// The consignment's tenant is the booking's own tenant (set explicitly at
|
||||||
// CreateExpressBooking time), not the completing miler's tenantid claim — a
|
// CreateExpressBooking time), not the completing miler's tenantid claim — a
|
||||||
// miler can carry parcels for tenants other than their own, and using
|
// miler can carry parcels for tenants other than their own, and using
|
||||||
@@ -1110,96 +1175,257 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
|||||||
consignmentTenantID = *booking.Tenantid
|
consignmentTenantID = *booking.Tenantid
|
||||||
}
|
}
|
||||||
|
|
||||||
// Carried over so the consignment stays traceable to the client site it was
|
var tenant models.Tenant
|
||||||
// collected from — for a food client that's the kitchen, and "how many
|
tenantNeedsOTP := false
|
||||||
// parcels went out of which kitchen" is unanswerable without it.
|
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil {
|
||||||
consignment := models.Consignment{
|
tenantNeedsOTP = tenant.Requiredeliveryotp
|
||||||
Trackingno: trackingNo,
|
|
||||||
Tenantid: consignmentTenantID,
|
|
||||||
Pickuplocationid: booking.Pickuplocationid,
|
|
||||||
Tenantlocationid: booking.Tenantlocationid,
|
|
||||||
Pickuplatitude: booking.Pickuplatitude,
|
|
||||||
Pickuplongitude: booking.Pickuplongitude,
|
|
||||||
Deliverylatitude: booking.Deliverylatitude,
|
|
||||||
Deliverylongitude: booking.Deliverylongitude,
|
|
||||||
Pickuppincode: booking.Pickuppincode,
|
|
||||||
Deliverypincode: booking.Deliverypincode,
|
|
||||||
Length: maxL,
|
|
||||||
Width: maxW,
|
|
||||||
Height: maxH,
|
|
||||||
Deadweight: totalDead,
|
|
||||||
Volumetricweight: totalChargeable - totalDead,
|
|
||||||
Chargeableweight: totalChargeable,
|
|
||||||
Paymentmode: "Prepaid",
|
|
||||||
Status: consignmentStatus,
|
|
||||||
Estimateddeliveryat: nil,
|
|
||||||
Createdby: milerUserID,
|
|
||||||
Originhubid: defaultHubID,
|
|
||||||
Currenthubid: defaultHubID,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
var payment models.BookingPayment
|
||||||
if tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil {
|
hasPayment := tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil
|
||||||
if payment.Paymentstatus == constants.PaymentStatusPaid {
|
|
||||||
consignment.Codcollected = payment.Amount
|
created := make([]models.Consignment, 0, len(legs))
|
||||||
} else {
|
trackingNos := make([]string, 0, len(legs))
|
||||||
consignment.Codamount = payment.Amount
|
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.
|
||||||
|
consignment := models.Consignment{
|
||||||
|
Trackingno: trackingNo,
|
||||||
|
Tenantid: consignmentTenantID,
|
||||||
|
Pickuplocationid: booking.Pickuplocationid,
|
||||||
|
Tenantlocationid: booking.Tenantlocationid,
|
||||||
|
Pickuplatitude: booking.Pickuplatitude,
|
||||||
|
Pickuplongitude: booking.Pickuplongitude,
|
||||||
|
Deliverylatitude: leg.DeliveryLatitude,
|
||||||
|
Deliverylongitude: leg.DeliveryLongitude,
|
||||||
|
Pickuppincode: booking.Pickuppincode,
|
||||||
|
Deliverypincode: leg.DeliveryPincode,
|
||||||
|
Length: maxL,
|
||||||
|
Width: maxW,
|
||||||
|
Height: maxH,
|
||||||
|
Deadweight: totalDead,
|
||||||
|
Volumetricweight: totalChargeable - totalDead,
|
||||||
|
Chargeableweight: totalChargeable,
|
||||||
|
Paymentmode: "Prepaid",
|
||||||
|
Status: consignmentStatus,
|
||||||
|
Estimateddeliveryat: cxEstimatedDelivery(leg, now),
|
||||||
|
Createdby: milerUserID,
|
||||||
|
Originhubid: defaultHubID,
|
||||||
|
Currenthubid: defaultHubID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
consignment.Paymentmode = "COD"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// A parcel that goes straight out for delivery here (collected-state flow off)
|
// Under the compatibility flow the parcel is treated as received at the base
|
||||||
// needs its receiver OTP before commit — same as before. When the flow is on,
|
// the moment it is collected, so the received-at fact is stamped here too —
|
||||||
// a hyperlocal parcel stops at Collected_By_Miler and its OTP is issued later
|
// otherwise every parcel inwarded this way would have a null handover time
|
||||||
// at start-delivery instead, so this block simply doesn't fire. Only clients
|
// and base reconciliation would have nothing to compare against.
|
||||||
// that ask for one get an OTP (Tenant.Requiredeliveryotp).
|
if consignmentStatus == constants.ConsignmentInwardedAtHub {
|
||||||
if consignmentStatus == constants.ConsignmentOutForDelivery {
|
consignment.Inwardedat = &now
|
||||||
var tenant models.Tenant
|
}
|
||||||
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil && tenant.Requiredeliveryotp {
|
|
||||||
|
// 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.Paymentmode = "COD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A parcel that goes straight out for delivery here (collected-state flow off)
|
||||||
|
// needs its receiver OTP before commit — same as before. When the flow is on,
|
||||||
|
// 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 && tenantNeedsOTP {
|
||||||
consignment.Deliveryotp = utils.GenerateNumericOTP(6)
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cxLinkLegToOrder(tx, leg, consignment.Consignmentid, trackingNo,
|
||||||
|
consignment.Estimateddeliveryat, now); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
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{
|
||||||
|
Consignmentid: consignment.Consignmentid,
|
||||||
|
Hubid: defaultHubID,
|
||||||
|
Userid: &milerUserID,
|
||||||
|
Eventstatus: consignmentStatus,
|
||||||
|
Remarks: "Package collected by miler and converted to consignment",
|
||||||
|
}
|
||||||
|
if err := tx.Create(&history).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return utils.Internal(c, "failed to record consignment history")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Create(&consignment).Error; err != nil {
|
// 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()
|
tx.Rollback()
|
||||||
return utils.Internal(c, "failed to convert booking to consignment")
|
utils.Error("BookingPickupComplete: could not record picked_up", "booking_id", bookingID, "error", err)
|
||||||
|
return utils.Internal(c, "failed to record the pickup")
|
||||||
}
|
}
|
||||||
|
|
||||||
booking.Consignmentid = &consignment.Consignmentid
|
// 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
|
booking.Status = constants.BookingConvertedConsignment
|
||||||
if err := tx.Save(&booking).Error; err != nil {
|
if err := tx.Save(&booking).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return utils.Internal(c, "failed to link booking to consignment")
|
return utils.Internal(c, "failed to link booking to consignment")
|
||||||
}
|
}
|
||||||
|
|
||||||
history := models.ConsignmentHistory{
|
// Unchanged from before the fan-out: a parcel still on its way to a base
|
||||||
Consignmentid: consignment.Consignmentid,
|
// keeps the rider marked busy; anything else frees them up.
|
||||||
Hubid: defaultHubID,
|
|
||||||
Userid: &milerUserID,
|
|
||||||
Eventstatus: consignmentStatus,
|
|
||||||
Remarks: "Package collected by miler and converted to consignment",
|
|
||||||
}
|
|
||||||
if err := tx.Create(&history).Error; err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
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.
|
|
||||||
postPickupAvailability := constants.MilerAvailable
|
postPickupAvailability := constants.MilerAvailable
|
||||||
if consignmentStatus == constants.ConsignmentCollectedByMiler ||
|
if riderMarkedBusy {
|
||||||
consignmentStatus == constants.ConsignmentCreated {
|
|
||||||
// Created here means hub-routed and still in the rider's hands: they are
|
|
||||||
// carrying it to a base, so they are not free yet.
|
|
||||||
postPickupAvailability = constants.MilerPickedUp
|
postPickupAvailability = constants.MilerPickedUp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,7 +1434,7 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
|||||||
// on the compatibility flow and the rider could not go off duty — MilerEndDuty
|
// on the compatibility flow and the rider could not go off duty — MilerEndDuty
|
||||||
// refuses while any assignment is still Assigned/Accepted. On the handover
|
// refuses while any assignment is still Assigned/Accepted. On the handover
|
||||||
// flow the assignment stays open on purpose and closes at inward-at-hub.
|
// flow the assignment stays open on purpose and closes at inward-at-hub.
|
||||||
if consignmentStatus == constants.ConsignmentInwardedAtHub {
|
if !assignmentStillOpen {
|
||||||
if err := tx.Model(&models.BookingAssignment{}).
|
if err := tx.Model(&models.BookingAssignment{}).
|
||||||
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
|
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
|
||||||
bookingID, milerUserID,
|
bookingID, milerUserID,
|
||||||
@@ -1231,25 +1457,16 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
|||||||
return utils.Internal(c, "failed to complete pickup")
|
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
|
// 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 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
|
// the collected-state flow is on, no OTP exists yet and the notification is
|
||||||
// just "collected"; the OTP rides the start-delivery notification instead.
|
// just "collected"; the OTP rides the start-delivery notification instead.
|
||||||
var customer models.AppCustomer
|
notifyCustomerOnPickup(&booking, created, trackingNos)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// next_action says what the rider does next; next_hub says where. An
|
// 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
|
// inward_at_hub with no base named leaves a rider holding a parcel with
|
||||||
@@ -1259,22 +1476,74 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
|||||||
//
|
//
|
||||||
// consignment_id is always present: the delivery leg is keyed on it, and
|
// 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.
|
// 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{
|
resp := fiber.Map{
|
||||||
"tracking_no": trackingNo,
|
"tracking_no": trackingNos[0],
|
||||||
"consignment_id": consignment.Consignmentid,
|
"consignment_id": first.Consignmentid,
|
||||||
"consignmentstatus": consignment.Status,
|
"consignmentstatus": first.Status,
|
||||||
"status": consignment.Status,
|
"status": first.Status,
|
||||||
"booking_no": booking.Bookingno,
|
"booking_no": booking.Bookingno,
|
||||||
"booking_status": booking.Status,
|
"booking_status": booking.Status,
|
||||||
"next_action": nextActionForConsignment(consignment.Status),
|
"next_action": nextActionForConsignment(first.Status),
|
||||||
|
"consignments": renderPickupOrders(created, trackingNos),
|
||||||
}
|
}
|
||||||
if consignment.Status == constants.ConsignmentCreated ||
|
if first.Status == constants.ConsignmentCreated ||
|
||||||
consignment.Status == constants.ConsignmentInwardedAtHub {
|
first.Status == constants.ConsignmentInwardedAtHub {
|
||||||
resp["next_hub"] = renderBase(handoverHub)
|
resp["next_hub"] = renderBase(handoverHub)
|
||||||
}
|
}
|
||||||
return utils.OK(c, resp)
|
return utils.OK(c, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func BookingVehicleRequiredEscalate(c *fiber.Ctx) error {
|
func BookingVehicleRequiredEscalate(c *fiber.Ctx) error {
|
||||||
milerUserID := c.Locals("userid").(int)
|
milerUserID := c.Locals("userid").(int)
|
||||||
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
392
docs/customer-api-testing.md
Normal file
392
docs/customer-api-testing.md
Normal 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`.
|
||||||
928
docs/customer-app-api.md
Normal file
928
docs/customer-app-api.md
Normal 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.
|
||||||
1495
docs/openapi-customer.yaml
Normal file
1495
docs/openapi-customer.yaml
Normal file
File diff suppressed because it is too large
Load Diff
41
dto/auth.go
41
dto/auth.go
@@ -1,41 +1,10 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
type CustomerRegisterRequest struct {
|
// The customer app has no password and no PIN: it authenticates on a 4-digit
|
||||||
Firstname string `json:"firstname" xml:"firstname" form:"firstname"`
|
// code sent to a phone or an email address, and its request shapes are declared
|
||||||
Lastname string `json:"lastname" xml:"lastname" form:"lastname"`
|
// inline in controllers/cxAuthController.go alongside the handlers that read
|
||||||
Phone string `json:"phone" xml:"phone" form:"phone"`
|
// them. The PIN register/login/verify/reset request types that used to live
|
||||||
Email string `json:"email" xml:"email" form:"email"`
|
// here went with that flow.
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MilerLoginRequest struct {
|
type MilerLoginRequest struct {
|
||||||
Phone string `json:"phone" xml:"phone" form:"phone"`
|
Phone string `json:"phone" xml:"phone" form:"phone"`
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"doormile/constants"
|
"doormile/constants"
|
||||||
"doormile/db"
|
"doormile/db"
|
||||||
|
"doormile/internal/cxstage"
|
||||||
"doormile/internal/routing"
|
"doormile/internal/routing"
|
||||||
"doormile/models"
|
"doormile/models"
|
||||||
"doormile/utils"
|
"doormile/utils"
|
||||||
@@ -227,8 +228,24 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate,
|
|||||||
return fmt.Errorf("update MilerProfile availability: %w", err)
|
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()
|
tx.Commit()
|
||||||
|
|
||||||
|
go cxstage.Notify(booking.Bookingid, nil, constants.CxStageAssigned)
|
||||||
|
|
||||||
utils.Info("CRMAssignment: assigned",
|
utils.Info("CRMAssignment: assigned",
|
||||||
"booking_id", booking.Bookingid,
|
"booking_id", booking.Bookingid,
|
||||||
"miler_id", milerUserID,
|
"miler_id", milerUserID,
|
||||||
|
|||||||
425
internal/cxstage/stage.go
Normal file
425
internal/cxstage/stage.go
Normal 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
|
||||||
|
}
|
||||||
126
internal/cxstage/stage_test.go
Normal file
126
internal/cxstage/stage_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
internal/sms/sms.go
Normal file
105
internal/sms/sms.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// 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 {
|
||||||
|
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
140
internal/sms/sms_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -223,3 +223,77 @@ func awsEncode(s string, encodeSlash bool) string {
|
|||||||
}
|
}
|
||||||
return b.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
|
||||||
|
}
|
||||||
|
|||||||
182
internal/storage/spaces_test.go
Normal file
182
internal/storage/spaces_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
main.go
7
main.go
@@ -167,6 +167,13 @@ func main() {
|
|||||||
AllowMethods: "GET,POST,PUT,DELETE,PATCH,OPTIONS",
|
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
|
// Structured Zap logger middleware
|
||||||
app.Use(middlewares.ZapLogger())
|
app.Use(middlewares.ZapLogger())
|
||||||
|
|
||||||
|
|||||||
@@ -39,3 +39,21 @@ func CityGateMiddleware(c *fiber.Ctx) error {
|
|||||||
"code": "CITY_NOT_SUPPORTED",
|
"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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package middlewares
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -37,8 +39,7 @@ func Idempotency() fiber.Handler {
|
|||||||
if key == "" || db.Rdb == nil {
|
if key == "" || db.Rdb == nil {
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
uid, _ := c.Locals("userid").(int)
|
base := "idem:" + idempotencyScope(c) + ":" + key
|
||||||
base := fmt.Sprintf("idem:%d:%s", uid, key)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -80,3 +81,32 @@ func Idempotency() fiber.Handler {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
}
|
||||||
|
|||||||
122
middlewares/idempotency_test.go
Normal file
122
middlewares/idempotency_test.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,21 +19,33 @@ func ZapLogger() fiber.Handler {
|
|||||||
method := c.Method()
|
method := c.Method()
|
||||||
path := c.Path()
|
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 {
|
if err != nil {
|
||||||
utils.Error("API request error",
|
utils.Error("API request error", append(fields, "error", err.Error())...)
|
||||||
"method", method,
|
|
||||||
"path", path,
|
|
||||||
"status", status,
|
|
||||||
"latency", latency,
|
|
||||||
"error", err.Error(),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
utils.Info("API request success",
|
utils.Info("API request success", fields...)
|
||||||
"method", method,
|
|
||||||
"path", path,
|
|
||||||
"status", status,
|
|
||||||
"latency", latency,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
|
|||||||
33
middlewares/requestid.go
Normal file
33
middlewares/requestid.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,19 @@ func Migrate(db *gorm.DB) error {
|
|||||||
&models.MilerDutyLog{},
|
&models.MilerDutyLog{},
|
||||||
&models.MilerBreakLog{},
|
&models.MilerBreakLog{},
|
||||||
&models.MilerSupportTicket{},
|
&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 {
|
if err != nil {
|
||||||
@@ -94,5 +107,47 @@ func Migrate(db *gorm.DB) error {
|
|||||||
utils.Info("✅ consignments_status_check constraint includes Collected_By_Miler")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,10 +83,58 @@ type PickupBooking struct {
|
|||||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;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
|
// Relations
|
||||||
Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"`
|
Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"`
|
||||||
ServiceOptions []BookingServiceOption `json:"serviceoptions" gorm:"foreignKey:Bookingid"`
|
ServiceOptions []BookingServiceOption `json:"serviceoptions" gorm:"foreignKey:Bookingid"`
|
||||||
Payments []BookingPayment `json:"payments" gorm:"foreignKey:Bookingid"`
|
Payments []BookingPayment `json:"payments" gorm:"foreignKey:Bookingid"`
|
||||||
|
Destinations []BookingDestination `json:"destinations" gorm:"foreignKey:Bookingid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (PickupBooking) TableName() string {
|
func (PickupBooking) TableName() string {
|
||||||
@@ -94,8 +142,16 @@ func (PickupBooking) TableName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BookingParcel struct {
|
type BookingParcel struct {
|
||||||
Bookingparcelid int `json:"bookingparcelid" gorm:"primaryKey;column:bookingparcelid"`
|
Bookingparcelid int `json:"bookingparcelid" gorm:"primaryKey;column:bookingparcelid"`
|
||||||
Bookingid int `json:"bookingid" gorm:"column:bookingid"`
|
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"`
|
Itemcategory string `json:"itemcategory" gorm:"column:itemcategory"`
|
||||||
Itemdescription string `json:"itemdescription" gorm:"column:itemdescription"`
|
Itemdescription string `json:"itemdescription" gorm:"column:itemdescription"`
|
||||||
Declaredvalue float64 `json:"declaredvalue" gorm:"column:declaredvalue"`
|
Declaredvalue float64 `json:"declaredvalue" gorm:"column:declaredvalue"`
|
||||||
|
|||||||
241
models/customer_app.go
Normal file
241
models/customer_app.go
Normal 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
175
models/customer_app_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,18 +78,46 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// --------------------
|
// --------------------
|
||||||
// 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 := api.Group("/customer")
|
||||||
customer.Post("/register", controllers.RegisterCustomer(cfg))
|
|
||||||
customer.Post("/login", authThrottle, controllers.LoginCustomer)
|
// Auth — a 4-digit code to a phone or an email address, no password
|
||||||
customer.Post("/verify-pin", authThrottle, controllers.VerifyCustomerPin(cfg))
|
// anywhere. Throttled on the shared budget with every other credential
|
||||||
customer.Post("/reset-pin", authThrottle, controllers.ResetCustomerPin)
|
// endpoint so an attacker cannot reset it by rotating between them.
|
||||||
customer.Post("/send-email-otp", authThrottle, controllers.SendCustomerEmailOtp(cfg))
|
customer.Post("/auth/otp/request", authThrottle, controllers.CxRequestOtp(cfg))
|
||||||
customer.Post("/verify-email-otp", authThrottle, controllers.VerifyCustomerEmailOtp())
|
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
|
// Authenticated Customer App routes
|
||||||
customerAuth := customer.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(9))
|
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.Get("/profile", controllers.GetCustomerProfile)
|
||||||
customerAuth.Put("/profile", controllers.UpdateCustomerProfile)
|
customerAuth.Put("/profile", controllers.UpdateCustomerProfile)
|
||||||
|
|
||||||
@@ -98,14 +126,38 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
customerAuth.Put("/locations/:id", controllers.UpdateCustomerLocation)
|
customerAuth.Put("/locations/:id", controllers.UpdateCustomerLocation)
|
||||||
customerAuth.Delete("/locations/:id", controllers.DeleteCustomerLocation)
|
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)
|
// Places are proxied, never keyed: the legacy rider app shipped a Maps key
|
||||||
customerAuth.Get("/bookings", controllers.GetCustomerBookings)
|
// in the binary and it had to be revoked. The customer app is handed
|
||||||
customerAuth.Get("/bookings/:bookingid", controllers.GetCustomerBookingDetails)
|
// results, not credentials.
|
||||||
customerAuth.Post("/bookings/:bookingid/cancel", controllers.CancelCustomerBooking)
|
customerAuth.Get("/places/reverse-geocode", controllers.ReverseGeocodeCx(cfg))
|
||||||
customerAuth.Get("/bookings/:bookingid/price", controllers.GetCustomerBookingQuote)
|
customerAuth.Get("/places/search", controllers.SearchCxPlaces(cfg))
|
||||||
customerAuth.Get("/track/:trackingno", controllers.TrackConsignment)
|
|
||||||
|
// 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
|
// MILER APIS
|
||||||
|
|||||||
405
routes/routes_customer_test.go
Normal file
405
routes/routes_customer_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
174
scratch/cx_readonly_probe.go
Normal file
174
scratch/cx_readonly_probe.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
170
seed_customer_app.sql
Normal file
170
seed_customer_app.sql
Normal 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
103
utils/epoch.go
Normal 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
116
utils/epoch_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,7 +80,18 @@ type Claims struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GenerateToken(userID int, email string, roleID int, tenantID int, configID int, secret string) (string, error) {
|
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{
|
claims := &Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Email: email,
|
Email: email,
|
||||||
|
|||||||
97
utils/response_cx.go
Normal file
97
utils/response_cx.go
Normal 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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user