diff --git a/CLAUDE.md b/CLAUDE.md index 13f7edf..e4bb875 100644 --- a/CLAUDE.md +++ b/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 client codebases (Flutter, React) have not been opened in this session. -1. **Customer app (B2C)** — Flutter. Auth via Firebase OTP (phone). Backend - surface: 19 customer routes **[verified this session]** - (`customer`/`customerAuth` groups) — booking creation, tracking, - `AppCustomer`/`AppCustomerLocation`. **[carried forward]**: reported built - and verified in prior sessions; the last live end-to-end test was - blocked here — see §9. +1. **Customer app (B2C)** — Flutter, being replaced: `doormile_customer_app` + (PIN auth, single-destination bookings) is retired in favour of + `doormile_cx`. Backend surface rebuilt 2026-09-05 to the Customer App v1 + contract: **28 customer routes** (`customer`/`customerAuth` groups) — OTP + auth with refresh, serviceability/slots/limits, place proxy, fare estimate, + multi-destination pickups, per-order tracking, push devices. See §8.5. Auth + is a 4-digit OTP to phone or email, NOT Firebase and NOT the miler PIN flow; + the SMS gateway is still unplugged, which is the same wall §9 describes. 2. **Miler app** — Flutter, for delivery riders. Backend surface: 38 routes **[verified this session]** (`miler`/`milerAuth` groups) — duty start/stop, GPS pings, assignment accept/reject/cancel, delivery @@ -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) **[carried forward]** diff --git a/config/config.go b/config/config.go index 2318c27..d5b6120 100644 --- a/config/config.go +++ b/config/config.go @@ -27,6 +27,17 @@ type Config struct { // stay unordered rather than assignment failing. RouteOptimizerURL string + // GeocoderURL is the Nominatim-compatible geocoding service the customer + // app'''s place search and reverse geocode are proxied through. Proxied on + // purpose: the legacy rider app shipped a Google Maps key inside the + // binary and it had to be revoked, so the customer app is never handed a + // key at all — it asks this service and this service asks the geocoder. + GeocoderURL string + // GeocoderEmail is the contact address Nominatim'''s usage policy asks + // callers to identify themselves with. Sent as the User-Agent contact; + // requests without one are throttled or blocked. + GeocoderEmail string + // TrustedProxies is a comma-separated list of reverse-proxy IPs/CIDRs that // are allowed to set X-Forwarded-For. Rate limiting keys on the client IP, // so behind a proxy this MUST be set — otherwise every request appears to @@ -60,6 +71,8 @@ func Load() *Config { AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"), RouteOptimizerURL: getEnv("ROUTE_OPTIMIZER_URL", "https://routes.workolik.com"), + GeocoderURL: getEnv("GEOCODER_URL", "https://nominatim.openstreetmap.org"), + GeocoderEmail: getEnv("GEOCODER_EMAIL", ""), TrustedProxies: getEnv("TRUSTED_PROXIES", ""), SMTPHost: getEnv("SMTP_HOST", ""), SMTPPort: getEnv("SMTP_PORT", "465"), diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..9c8e35a --- /dev/null +++ b/config/config_test.go @@ -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") + } +} diff --git a/constants/constants.go b/constants/constants.go index 23573bc..b85d8f6 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -168,3 +168,74 @@ const ( ExceptionResolved = "Resolved" ExceptionClosed = "Closed" ) + +// Customer-app stages. Nine operational stages, spelt exactly as the customer +// client parses them: lowercase snake_case, on the wire verbatim. The client +// rolls these up into seven milestones itself and falls back to "booked" on an +// unknown key, silently — so adding a value here without an app release makes a +// parcel look un-started. Never rename one; add and coordinate. +// +// Stages 0-5 belong to the booking. Stages 6-8 belong to each order and may +// differ between destinations of the same booking. +const ( + CxStageBooked = "booked" // 0 — pickup requested + CxStageAssigned = "assigned" // 1 — a miler accepted it + CxStageOnTheWay = "on_the_way" // 2 — rider en route, distance/ETA live + CxStageArrived = "arrived" // 3 — rider at the door; LAST cancellable stage + CxStagePickedUp = "picked_up" // 4 — weighed, photographed, price settled + CxStageOrderCreated = "order_created" // 5 — one tracking number minted per destination + CxStageInTransit = "in_transit" // 6 — per order from here on + CxStageOutForDelivery = "out_for_delivery" // 7 — delivery agent carrying it + CxStageDelivered = "delivered" // 8 — handed over +) + +// Customer-facing booking status. Derived from the stage but sent explicitly, +// because a client that has to infer it will eventually infer it differently. +const ( + CxStatusActive = "active" + CxStatusCompleted = "completed" + CxStatusCancelled = "cancelled" +) + +// Who caused a stage transition. Recorded on every bookingstageevents row: the +// customer timeline is derived from that table, so it has to be real, and a +// cancellation the customer did not make is unexplainable without this. +const ( + CxActorMiler = "miler" + CxActorOps = "ops" + CxActorCustomer = "customer" + CxActorSystem = "system" +) + +// CxStageOrder is the rank of each stage, used to decide whether a transition +// moves forward and whether cancellation is still open. Cancellation closes +// after arrived, so anything at or past picked_up is refused. +var CxStageOrder = map[string]int{ + CxStageBooked: 0, + CxStageAssigned: 1, + CxStageOnTheWay: 2, + CxStageArrived: 3, + CxStagePickedUp: 4, + CxStageOrderCreated: 5, + CxStageInTransit: 6, + CxStageOutForDelivery: 7, + CxStageDelivered: 8, +} + +// CxStageRank returns the rank of a stage, or -1 when the stage is unknown or +// empty. A booking written before this surface existed has no stage at all, +// and -1 keeps it strictly behind every real stage rather than tying with +// "booked". +func CxStageRank(stage string) int { + if r, ok := CxStageOrder[stage]; ok { + return r + } + return -1 +} + +// CxCancellable reports whether a booking at this stage may still be cancelled. +// The UI mirrors this to hide the button, but the server re-checks on the +// cancel call — the button state is a hint, never the authority. +func CxCancellable(stage string) bool { + return CxStageRank(stage) <= CxStageOrder[CxStageArrived] +} diff --git a/constants/constants_test.go b/constants/constants_test.go new file mode 100644 index 0000000..fba53c4 --- /dev/null +++ b/constants/constants_test.go @@ -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) + } +} diff --git a/controllers/adminController.go b/controllers/adminController.go index 2f683ac..70022d9 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -15,6 +15,7 @@ import ( "doormile/db" "doormile/dto" "doormile/internal/assignment" + "doormile/internal/cxstage" "doormile/internal/notify" "doormile/models" "doormile/utils" @@ -2770,6 +2771,18 @@ func AdminCancelBooking(c *fiber.Ctx) error { return utils.Internal(c, "failed to cancel booking") } + // Tell the customer's projection too. Without this the pickup keeps + // rendering as active and cancellable in the customer app, because + // customerstatus was written as "active" at booking time and nothing here + // ever moved it. Best-effort and outside the save above: an ops cancel that + // has already committed must not be reported as failed because the + // customer-side write did not land. No-op for console-created bookings. + if err := cxstage.Cancel(db.DB, booking.Bookingid, "Cancelled by Doormile operations", + constants.CxActorOps, opsActorID(c), "POST /admin/bookings/{id}/cancel"); err != nil { + utils.Error("AdminCancelBooking: could not update the customer projection", + "booking_id", booking.Bookingid, "error", err) + } + if booking.Assignedmileruserid != nil { db.DB.Model(&models.MilerProfile{}). Where("userid = ?", *booking.Assignedmileruserid). @@ -2849,6 +2862,15 @@ func AdminBulkCancelBookings(c *fiber.Ctx) error { continue } + // Same reason as AdminCancelBooking: without this the pickup keeps + // rendering as active and cancellable in the customer app. No-op for + // console-created bookings. + if err := cxstage.Cancel(db.DB, booking.Bookingid, "Cancelled by Doormile operations", + constants.CxActorOps, opsActorID(c), "POST /admin/bookings/bulk-cancel"); err != nil { + utils.Error("AdminBulkCancelBookings: could not update the customer projection", + "booking_id", booking.Bookingid, "error", err) + } + if booking.Assignedmileruserid != nil { db.DB.Model(&models.MilerProfile{}). Where("userid = ?", *booking.Assignedmileruserid). @@ -3895,3 +3917,13 @@ func InternalReassign(c *fiber.Ctx) error { "booking_id": booking.Bookingid, }) } + +// opsActorID returns the console user behind an ops action, for the customer's +// audit trail. Nil when the request carries no user id, which is a legitimate +// state for an internal caller rather than something to fail on. +func opsActorID(c *fiber.Ctx) *int { + if uid, ok := c.Locals("userid").(int); ok && uid != 0 { + return &uid + } + return nil +} diff --git a/controllers/booking_assignment_service.go b/controllers/booking_assignment_service.go index 044ea38..71c7e2a 100644 --- a/controllers/booking_assignment_service.go +++ b/controllers/booking_assignment_service.go @@ -7,6 +7,7 @@ import ( "doormile/constants" "doormile/db" + "doormile/internal/cxstage" "doormile/internal/notify" "doormile/internal/routing" "doormile/models" @@ -71,6 +72,21 @@ func assignMilerTx(tx *gorm.DB, bookingID, milerUserID int, assignedByUserID *in return nil, fmt.Errorf("failed to update miler availability: %w", err) } + // The customer's "Miler assigned" milestone, recorded where the assignment + // is actually created rather than where a rider taps Accept. A rider who + // never opens the app would otherwise leave the customer watching "finding + // a Miler" while ops has the booking down as assigned — two surfaces + // disagreeing about the same fact. + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: booking.Bookingid, + Stage: constants.CxStageAssigned, + ActorType: constants.CxActorOps, + ActorID: assignedByUserID, + Source: "assignMilerTx", + }); err != nil { + return nil, fmt.Errorf("failed to record the assigned stage: %w", err) + } + return &booking, nil } diff --git a/controllers/customerController.go b/controllers/customerController.go index 7dbadf6..213e63b 100644 --- a/controllers/customerController.go +++ b/controllers/customerController.go @@ -1,29 +1,26 @@ package controllers import ( - "crypto/rand" - "encoding/json" - "fmt" "math" "strconv" - "time" - "doormile/config" - "doormile/constants" "doormile/db" "doormile/dto" - "doormile/internal/assignment" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" ) -func generateBookingNo() string { - b := make([]byte, 4) - rand.Read(b) - return fmt.Sprintf("DM-BK-%X-%d", b, time.Now().Unix()%100000) -} +// Customer profile and saved addresses. +// +// The rest of the customer surface — auth, catalogue, estimate, bookings, +// tracking, places, devices — lives in the cx*Controller.go files and answers +// in the customer envelope (utils.CxOK / utils.CxFail). The PIN login, +// single-destination booking create/list/detail/cancel and the /customer/track +// read that used to live here were replaced by that surface, not moved: a +// customer books a pickup with 1..N destinations now, and there is no shape in +// which the old single-address request is still a valid booking. func calculateDistance(lat1, lon1, lat2, lon2 float64) float64 { const R = 6371.0 @@ -40,190 +37,15 @@ func calculateVolumetricWeight(length, width, height float64) float64 { return (length * width * height) / 5000.0 } -func RegisterCustomer(cfg *config.Config) fiber.Handler { - return func(c *fiber.Ctx) error { - req := new(dto.CustomerRegisterRequest) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") - } - - if req.Phone == "" || req.Firstname == "" || req.Pin == "" { - return utils.BadRequest(c, "phone, firstname, and pin are required") - } - - pinHash, err := utils.HashPassword(req.Pin) - if err != nil { - return utils.Internal(c, "failed to process registration") - } - - configID := req.Configid - if configID == 0 { - configID = 1001 - } - - var existing models.AppCustomer - if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&existing).Error; err == nil { - return utils.Conflict(c, "a customer with this phone number already exists") - } - - customer := models.AppCustomer{ - Firstname: req.Firstname, - Lastname: req.Lastname, - Phone: req.Phone, - Email: req.Email, - Loginpinhash: pinHash, - Status: "Active", - Configid: configID, - } - - if err := db.DB.Create(&customer).Error; err != nil { - return utils.Internal(c, "failed to register customer") - } - - token, err := utils.GenerateToken(customer.Appcustomerid, customer.Phone, 9, 0, customer.Configid, cfg.JWTSecret) - if err != nil { - return utils.Internal(c, "registration successful but failed to generate token") - } - - return c.Status(fiber.StatusCreated).JSON(fiber.Map{ - "success": true, - "token": token, - "user": customer, - }) - } -} - -func LoginCustomer(c *fiber.Ctx) error { - req := new(dto.CustomerLoginRequest) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") - } - - if req.Phone == "" { - return utils.BadRequest(c, "phone is required") - } - - configID := req.Configid - if configID == 0 { - configID = 1001 - } - - var customer models.AppCustomer - if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil { - return utils.NotFound(c, "no account found for this phone number") - } - - if customer.Status == "Blocked" { - return utils.Forbidden(c, "this account has been blocked") - } - - return c.JSON(fiber.Map{ - "success": true, - "message": "PIN verification required", - "phone": req.Phone, - }) -} - -func VerifyCustomerPin(cfg *config.Config) fiber.Handler { - return func(c *fiber.Ctx) error { - req := new(dto.CustomerPinVerifyRequest) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") - } - - if req.Phone == "" || req.Pin == "" { - return utils.BadRequest(c, "phone and pin are required") - } - - configID := req.Configid - if configID == 0 { - configID = 1001 - } - - var customer models.AppCustomer - if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil { - return utils.NotFound(c, "customer not found") - } - - if !utils.CheckPasswordHash(req.Pin, customer.Loginpinhash) { - return utils.Unauthorized(c, "incorrect PIN") - } - - now := time.Now() - customer.Lastloginat = &now - if req.DeviceToken != "" { - customer.Devicetoken = req.DeviceToken - } - db.DB.Save(&customer) - - token, err := utils.GenerateToken(customer.Appcustomerid, customer.Phone, 9, 0, customer.Configid, cfg.JWTSecret) - if err != nil { - return utils.Internal(c, "failed to generate token") - } - - return c.JSON(fiber.Map{ - "success": true, - "token": token, - "user": customer, - }) - } -} - -func ResetCustomerPin(c *fiber.Ctx) error { - req := new(dto.CustomerResetPinRequest) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") - } - - if req.Phone == "" || req.NewPin == "" { - return utils.BadRequest(c, "phone and new_pin are required") - } - - configID := req.Configid - if configID == 0 { - configID = 1001 - } - - var customer models.AppCustomer - if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil { - return utils.NotFound(c, "customer not found") - } - - // Proof of identity is required before overwriting a login credential. - // Without it this endpoint reset any customer's PIN from their phone number - // alone — and phone numbers are the login identifier, not a secret — so - // reset-pin followed by verify-pin was a complete account takeover. - // The caller must first pass /customer/send-email-otp and - // /customer/verify-email-otp for this account's registered address. - if customer.Email == "" { - return utils.Forbidden(c, "this account has no registered email to verify against — contact support to reset the PIN") - } - if !ConsumeEmailVerification(customer.Email) { - return utils.Forbidden(c, "verify your registered email first via /customer/send-email-otp and /customer/verify-email-otp") - } - - pinHash, err := utils.HashPassword(req.NewPin) - if err != nil { - return utils.Internal(c, "failed to process PIN reset") - } - - customer.Loginpinhash = pinHash - if err := db.DB.Save(&customer).Error; err != nil { - return utils.Internal(c, "failed to reset PIN") - } - - return utils.Message(c, "PIN reset successfully") -} - func GetCustomerProfile(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) var customer models.AppCustomer if err := db.DB.First(&customer, customerID).Error; err != nil { - return utils.NotFound(c, "profile not found") + return utils.CxNotFound(c, "We could not find your profile") } - return utils.OK(c, customer) + return utils.CxOK(c, renderCustomer(&customer)) } func UpdateCustomerProfile(c *fiber.Ctx) error { @@ -231,76 +53,91 @@ func UpdateCustomerProfile(c *fiber.Ctx) error { var customer models.AppCustomer if err := db.DB.First(&customer, customerID).Error; err != nil { - return utils.NotFound(c, "profile not found") + return utils.CxNotFound(c, "We could not find your profile") } - type ProfileUpdate struct { - Firstname string `json:"firstname"` - Lastname string `json:"lastname"` - Email string `json:"email"` - Defaultlatitude float64 `json:"defaultlatitude"` - Defaultlongitude float64 `json:"defaultlongitude"` - Defaultpincode string `json:"defaultpincode"` + // Pointer fields: an omitted key leaves the stored value alone, an explicit + // value overwrites it. The previous version cleared email and lastname on + // every call that did not resend them, which quietly wiped a customer's + // email the first time they edited their name. + var req struct { + Name *string `json:"name"` + Email *string `json:"email"` + Defaultlatitude *float64 `json:"defaultLatitude"` + Defaultlongitude *float64 `json:"defaultLongitude"` + Defaultpincode *string `json:"defaultPincode"` + } + if err := c.BodyParser(&req); err != nil { + return utils.CxBadRequest(c, "We could not read that request") } - req := new(ProfileUpdate) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") + if req.Name != nil { + first, last := splitName(*req.Name) + if first == "" { + return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name") + } + customer.Firstname, customer.Lastname = first, last } - - if req.Firstname != "" { - customer.Firstname = req.Firstname + if req.Email != nil { + customer.Email = *req.Email } - customer.Lastname = req.Lastname - customer.Email = req.Email - if req.Defaultlatitude != 0 { - customer.Defaultlatitude = req.Defaultlatitude + if req.Defaultlatitude != nil { + customer.Defaultlatitude = *req.Defaultlatitude } - if req.Defaultlongitude != 0 { - customer.Defaultlongitude = req.Defaultlongitude + if req.Defaultlongitude != nil { + customer.Defaultlongitude = *req.Defaultlongitude } - if req.Defaultpincode != "" { - customer.Defaultpincode = req.Defaultpincode + if req.Defaultpincode != nil { + customer.Defaultpincode = *req.Defaultpincode } if err := db.DB.Save(&customer).Error; err != nil { - return utils.Internal(c, "failed to update profile") + utils.Error("UpdateCustomerProfile: save failed", "customer_id", customerID, "error", err) + return utils.CxInternal(c) } - return utils.OK(c, customer) + return utils.CxOK(c, renderCustomer(&customer)) } func GetCustomerLocations(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) var locations []models.AppCustomerLocation - if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active").Find(&locations).Error; err != nil { - return utils.Internal(c, "failed to fetch locations") + if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active"). + Order("isdefault DESC, appcustomerlocationid DESC").Find(&locations).Error; err != nil { + utils.Error("GetCustomerLocations: query failed", "customer_id", customerID, "error", err) + return utils.CxInternal(c) } - return utils.List(c, locations, int64(len(locations))) + out := make([]fiber.Map, 0, len(locations)) + for i := range locations { + out = append(out, renderSavedAddress(&locations[i])) + } + return utils.CxList(c, out, len(out), nil) } func CreateCustomerLocation(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) var count int64 - db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ? AND status = ?", customerID, "Active").Count(&count) + db.DB.Model(&models.AppCustomerLocation{}). + Where("appcustomerid = ? AND status = ?", customerID, "Active").Count(&count) if count >= 10 { - return utils.BadRequest(c, "maximum of 10 saved locations allowed") + return utils.CxBadRequest(c, "You can save up to 10 addresses") } req := new(dto.LocationCreateRequest) if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") + return utils.CxBadRequest(c, "We could not read that request") } if req.Address == "" || req.Pincode == "" || req.Latitude == 0 || req.Longitude == 0 { - return utils.BadRequest(c, "address, pincode, latitude, and longitude are required") + return utils.CxBadRequest(c, "An address needs a street, a pincode and a map location") } if req.Isdefault { - db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ?", customerID).Update("isdefault", false) + db.DB.Model(&models.AppCustomerLocation{}). + Where("appcustomerid = ?", customerID).Update("isdefault", false) } location := models.AppCustomerLocation{ @@ -320,27 +157,29 @@ func CreateCustomerLocation(c *fiber.Ctx) error { } if err := db.DB.Create(&location).Error; err != nil { - return utils.Internal(c, "failed to save location") + utils.Error("CreateCustomerLocation: insert failed", "customer_id", customerID, "error", err) + return utils.CxInternal(c) } - return utils.Created(c, location) + return utils.CxCreated(c, renderSavedAddress(&location)) } func UpdateCustomerLocation(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) locationID, err := strconv.Atoi(c.Params("id")) if err != nil { - return utils.BadRequest(c, "invalid location ID") + return utils.CxBadRequest(c, "That address could not be found") } var location models.AppCustomerLocation - if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).First(&location).Error; err != nil { - return utils.NotFound(c, "location not found") + if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID). + First(&location).Error; err != nil { + return utils.CxNotFound(c, "That address could not be found") } req := new(dto.LocationCreateRequest) if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") + return utils.CxBadRequest(c, "We could not read that request") } if req.Label != "" { @@ -366,392 +205,58 @@ func UpdateCustomerLocation(c *fiber.Ctx) error { location.Isdefault = req.Isdefault if req.Isdefault { - db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ?", customerID).Update("isdefault", false) + db.DB.Model(&models.AppCustomerLocation{}). + Where("appcustomerid = ?", customerID).Update("isdefault", false) } if err := db.DB.Save(&location).Error; err != nil { - return utils.Internal(c, "failed to update location") + utils.Error("UpdateCustomerLocation: save failed", "location_id", locationID, "error", err) + return utils.CxInternal(c) } - return utils.OK(c, location) + return utils.CxOK(c, renderSavedAddress(&location)) } func DeleteCustomerLocation(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) locationID, err := strconv.Atoi(c.Params("id")) if err != nil { - return utils.BadRequest(c, "invalid location ID") + return utils.CxBadRequest(c, "That address could not be found") } var location models.AppCustomerLocation - if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).First(&location).Error; err != nil { - return utils.NotFound(c, "location not found") + if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID). + First(&location).Error; err != nil { + return utils.CxNotFound(c, "That address could not be found") } location.Status = "InActive" - db.DB.Save(&location) + if err := db.DB.Save(&location).Error; err != nil { + utils.Error("DeleteCustomerLocation: save failed", "location_id", locationID, "error", err) + return utils.CxInternal(c) + } - return utils.Message(c, "location deleted successfully") + return utils.CxOK(c, fiber.Map{"id": strconv.Itoa(location.Appcustomerlocationid), "deleted": true}) } -func CreateCustomerBooking(c *fiber.Ctx) error { - customerID := c.Locals("userid").(int) - - req := new(dto.PickupBookingRequest) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") +// renderSavedAddress is the one shape a saved address is returned in, matching +// the two-line title/sub the pickup search and the booking pickup block use — +// so an address picked from Saved and one picked from search are the same +// object to the client. +func renderSavedAddress(l *models.AppCustomerLocation) fiber.Map { + title := l.Label + if title == "" { + title = l.Address } - - if req.Pickupaddress == "" || req.Pickuppincode == "" { - return utils.BadRequest(c, "pickup address and pincode are required") - } - - if len(req.Parcels) == 0 { - return utils.BadRequest(c, "at least one parcel is required") - } - - // Geocode delivery pincode to lat/lon when the app doesn't supply coordinates. - if req.Deliverylatitude == 0 && req.Deliverylongitude == 0 && req.Deliverypincode != "" { - if lat, lon, ok := pincodeToLatLon(req.Deliverypincode); ok { - req.Deliverylatitude = lat - req.Deliverylongitude = lon - } - } - - tx := db.DB.Begin() - - booking := models.PickupBooking{ - Bookingno: generateBookingNo(), - Appcustomerid: customerID, - Pickuplocationid: req.Pickuplocationid, - Pickupaddress: req.Pickupaddress, - Pickuppincode: req.Pickuppincode, - Pickuplatitude: req.Pickuplatitude, - Pickuplongitude: req.Pickuplongitude, - Deliveryaddress: req.Deliveryaddress, - Deliverypincode: req.Deliverypincode, - Deliverylatitude: req.Deliverylatitude, - Deliverylongitude: req.Deliverylongitude, - Bookingsource: "Customer_App", - // 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 - } + return fiber.Map{ + "id": strconv.Itoa(l.Appcustomerlocationid), + "label": l.Label, + "title": title, + "sub": joinNonEmpty(", ", l.Address, l.Landmark, l.City, l.Pincode), + "recipientName": l.Receivername, + "recipientPhone": l.Receiverphone, + "lat": l.Latitude, + "lng": l.Longitude, + "isDefault": l.Isdefault, } } - -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") -} diff --git a/controllers/cxAuthController.go b/controllers/cxAuthController.go new file mode 100644 index 0000000..9fa56cd --- /dev/null +++ b/controllers/cxAuthController.go @@ -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) +} diff --git a/controllers/cxBookingController.go b/controllers/cxBookingController.go new file mode 100644 index 0000000..dae8706 --- /dev/null +++ b/controllers/cxBookingController.go @@ -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) + } +} diff --git a/controllers/cxBookingView.go b/controllers/cxBookingView.go new file mode 100644 index 0000000..ba68500 --- /dev/null +++ b/controllers/cxBookingView.go @@ -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 +} diff --git a/controllers/cxCatalogueController.go b/controllers/cxCatalogueController.go new file mode 100644 index 0000000..170d131 --- /dev/null +++ b/controllers/cxCatalogueController.go @@ -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 +} diff --git a/controllers/cxConsignmentHooks.go b/controllers/cxConsignmentHooks.go new file mode 100644 index 0000000..98ac573 --- /dev/null +++ b/controllers/cxConsignmentHooks.go @@ -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) +} diff --git a/controllers/cxCustomerApp_test.go b/controllers/cxCustomerApp_test.go new file mode 100644 index 0000000..3193627 --- /dev/null +++ b/controllers/cxCustomerApp_test.go @@ -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) + } +} diff --git a/controllers/cxDeviceController.go b/controllers/cxDeviceController.go new file mode 100644 index 0000000..d0bec08 --- /dev/null +++ b/controllers/cxDeviceController.go @@ -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}) +} diff --git a/controllers/cxFareController.go b/controllers/cxFareController.go new file mode 100644 index 0000000..035ffde --- /dev/null +++ b/controllers/cxFareController.go @@ -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) +} diff --git a/controllers/cxHttp_test.go b/controllers/cxHttp_test.go new file mode 100644 index 0000000..075ab43 --- /dev/null +++ b/controllers/cxHttp_test.go @@ -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) + } +} diff --git a/controllers/cxIdentifierScramble.go b/controllers/cxIdentifierScramble.go new file mode 100644 index 0000000..13bb6ad --- /dev/null +++ b/controllers/cxIdentifierScramble.go @@ -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) & 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 +} diff --git a/controllers/cxIdentifierScramble_test.go b/controllers/cxIdentifierScramble_test.go new file mode 100644 index 0000000..9adc375 --- /dev/null +++ b/controllers/cxIdentifierScramble_test.go @@ -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) + } + } +} diff --git a/controllers/cxIdentifiers.go b/controllers/cxIdentifiers.go new file mode 100644 index 0000000..0507311 --- /dev/null +++ b/controllers/cxIdentifiers.go @@ -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)) +} diff --git a/controllers/cxOpsController.go b/controllers/cxOpsController.go new file mode 100644 index 0000000..5ce04d0 --- /dev/null +++ b/controllers/cxOpsController.go @@ -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) +} diff --git a/controllers/cxPickupFanout.go b/controllers/cxPickupFanout.go new file mode 100644 index 0000000..465e2a4 --- /dev/null +++ b/controllers/cxPickupFanout.go @@ -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 +} diff --git a/controllers/cxPlacesController.go b/controllers/cxPlacesController.go new file mode 100644 index 0000000..7ff9599 --- /dev/null +++ b/controllers/cxPlacesController.go @@ -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) + } +} diff --git a/controllers/logisticsHandoverController.go b/controllers/logisticsHandoverController.go index d16bf31..3344749 100644 --- a/controllers/logisticsHandoverController.go +++ b/controllers/logisticsHandoverController.go @@ -443,8 +443,13 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error { // the base gate — and ridercharges the order amount, both written the same way // MilerDeliverConsignment writes them for a final-mile leg. Without this an // intercity rider's every job reported zero distance and zero value. - var booking models.PickupBooking - if tx.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil { + // Resolved through bookingdestinations, not through + // pickupbookings.consignmentid. That column names only the FIRST order of a + // multi-destination pickup, so joining on it found nothing for orders 2..N + // — and an intercity rider handing in the second parcel of a three-stop + // pickup had their assignment left open and their distance recorded as zero. + if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil { + booking := *bookingPtr dropLat, dropLon := lat, lon if dropLat == 0 && dropLon == 0 { dropLat, dropLon = hub.Latitude, hub.Longitude @@ -479,10 +484,25 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error { return utils.Internal(c, "failed to update miler availability") } + // The customer's "In transit" milestone. Recorded against THIS order, not + // the booking, because the other parcels from the same visit may still be + // in the rider's hands. + notifyInTransit, err := recordCxConsignmentStage(tx, consignment.Consignmentid, + constants.ConsignmentInwardedAtHub, constants.CxActorMiler, &milerUserID, + "POST /miler/consignments/{id}/inward-at-hub") + if err != nil { + tx.Rollback() + utils.Error("MilerInwardConsignmentAtHub: could not record in_transit", + "consignment_id", consignment.Consignmentid, "error", err) + return utils.Internal(c, "failed to record the handover") + } + if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to record the handover") } + notifyInTransit() + // Best-effort, on an already-bound subject — a dropped event must never fail // a handover the rider has physically completed. if db.Js != nil { diff --git a/controllers/milerAppController.go b/controllers/milerAppController.go index d3670a8..94a4cb0 100644 --- a/controllers/milerAppController.go +++ b/controllers/milerAppController.go @@ -254,6 +254,28 @@ func MilerGetMyBookings(c *fiber.Ctx) error { } } + // A customer-app pickup fans out into one consignment per destination, and + // each of those is a SEPARATE delivery the rider has to make. Without this + // the queue showed one row per booking keyed on pickupbookings.consignmentid + // — which names only the first order — so on a three-destination pickup two + // parcels would exist in the rider's bag with no stop, no deliver button and + // no way to close them. + // + // Bookings with no destination rows (every console/express booking, and + // everything written before the fan-out) are untouched: they still produce + // exactly one row, built from the booking's own columns. + destinationsByBooking := map[int][]models.BookingDestination{} + if len(bookingIDs) > 0 { + var destinations []models.BookingDestination + db.DB.Where("bookingid IN ?", bookingIDs).Order("seq ASC").Find(&destinations) + for _, d := range destinations { + destinationsByBooking[d.Bookingid] = append(destinationsByBooking[d.Bookingid], d) + if d.Consignmentid != nil { + consignmentIDs = append(consignmentIDs, *d.Consignmentid) + } + } + } + // step / road-optimized sequence lives on the active assignment row, written // by the express route optimizer. Step 0 = not sequenced (single-stop or // optimizer down), never a position — passed through verbatim. @@ -287,41 +309,6 @@ func MilerGetMyBookings(c *fiber.Ctx) error { var customer models.AppCustomer 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 // Home can title the stop correctly. Without pickup_source_type every // 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, customer.Firstname+" "+customer.Lastname) - // next_action / next_hub: the leg this parcel is on, rebuilt from server - // state on every poll. pickup-complete used to be the only place that ever - // said it, so a restart mid-leg left the app with nothing authoritative to - // read — consignment status alone cannot separate a hub-routed parcel from - // a freshly-collected hyperlocal one, since both can sit on Created. - nextAction := constants.NextActionPickup - var nextHub fiber.Map - if 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) - } - } + stops := milerStopsForBooking(&b, destinationsByBooking[b.Bookingid]) - row := fiber.Map{ - "bookingid": b.Bookingid, - "bookingreference": b.Bookingno, - "status": b.Status, - "stoptype": milerStopType(b.Status), - "consignmentid": b.Consignmentid, - "consignmentstatus": consignmentStatus, - "next_action": nextAction, - "next_hub": nextHub, - "pickup_source_type": sourceType, - "sourceid": sourceID, - "pickuplocationid": sourceID, - "pickup_source_name": sourceName, - "pickupaddress": sourceAddress, - "pickuplatitude": b.Pickuplatitude, - "pickuplongitude": b.Pickuplongitude, - "deliveryaddress": b.Deliveryaddress, - "deliverylatitude": b.Deliverylatitude, - "deliverylongitude": b.Deliverylongitude, - "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": b.Parcels, - "serviceoptions": b.ServiceOptions, - "codamount": 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, + for _, stop := range stops { + // Cash-to-collect: prefer the consignment's COD once it exists, otherwise + // fall back to a pending Cash payment on the booking. Prepaid/UPI stays 0. + codAmount := 0.0 + paymentMode := "" + var consignmentStatus string + + // next_action / next_hub: the leg this parcel is on, rebuilt from server + // state on every poll. pickup-complete used to be the only place that ever + // said it, so a restart mid-leg left the app with nothing authoritative to + // read — consignment status alone cannot separate a hub-routed parcel from + // a freshly-collected hyperlocal one, since both can sit on Created. + nextAction := constants.NextActionPickup + var nextHub fiber.Map + + if stop.consignmentID != nil { + if cn, ok := codByConsignment[*stop.consignmentID]; ok { + consignmentStatus = cn.Status + paymentMode = cn.Paymentmode + codAmount = cn.Codamount - cn.Codcollected + if codAmount < 0 { + codAmount = 0 + } + nextAction = nextActionForConsignment(cn.Status) + nextHub = nextHubForConsignment(&cn) + } else { + // The consignment row did not load. Fall back to what this + // destination asked for rather than to zero — a rider shown + // ₹0 collects nothing, and the customer's money is the one + // thing that must not silently vanish from a stop. + codAmount = stop.codAmount + } + } else if stop.codAmount > 0 { + // Not yet collected: the collection is what the customer + // requested for this door. + codAmount = stop.codAmount + } + if b.Status == constants.BookingCancelled { + nextAction = constants.NextActionNone + } + + // The pickup fee is charged once for the visit, so it is only offered + // against the first stop — asking a rider to collect it again at the + // second parcel would double-charge the customer. + if codAmount == 0 && stop.seq == 0 { + for _, p := range b.Payments { + if p.Paymentmode == constants.PaymentModeCash && p.Paymentstatus == constants.PaymentStatusPending { + codAmount = p.Amount + 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 - // keeping the newest-first order the app already relied on. + // keeping the newest-first order the app already relied on. Stops from one + // booking share its step, so they stay adjacent and in destination order. sort.SliceStable(response, func(i, j int) bool { si, sj := response[i]["step"].(int), response[j]["step"].(int) switch { @@ -424,6 +477,121 @@ func MilerGetMyBookings(c *fiber.Ctx) error { return utils.List(c, response, int64(len(response))) } +// milerStop is one thing the rider actually has to do with one parcel — a +// pickup before collection, a delivery leg after it. +type milerStop struct { + seq int + consignmentID *int + trackingNo string + deliveryAddress string + deliveryLat float64 + deliveryLng float64 + recipientName string + recipientPhone string + // codAmount is the collection the CUSTOMER asked for at THIS door, read off + // the destination row. The consignment's own figure wins at delivery time + // because it also knows what has already been collected — but this is what + // makes the per-stop split provable without a database, and it is the value + // used if the consignment row could not be loaded. Money must never fall + // back to another destination's number. + codAmount float64 + parcels []models.BookingParcel +} + +// milerStopsForBooking decides how many stops a booking is worth to the rider. +// +// Before the parcels are collected it is always ONE stop: the rider makes a +// single visit to the customer's door, whatever it is carrying away. After +// collection a customer-app pickup becomes one stop per destination, because +// each parcel now has its own journey, its own tracking number and its own +// deliver/skip action. +// +// A booking with no destination rows — every console/express booking, and +// everything written before the fan-out — is always one stop built from the +// booking's own columns, exactly as before. +func milerStopsForBooking(b *models.PickupBooking, destinations []models.BookingDestination) []milerStop { + single := []milerStop{{ + seq: 0, + consignmentID: b.Consignmentid, + deliveryAddress: b.Deliveryaddress, + deliveryLat: b.Deliverylatitude, + deliveryLng: b.Deliverylongitude, + parcels: b.Parcels, + }} + // A booking with exactly one destination row still carries its COD there. + if len(destinations) == 1 { + single[0].codAmount = destinations[0].Codamount + } + + if len(destinations) == 0 { + return single + } + + // Not collected yet: one visit, one stop. The destinations are still only an + // intention, and showing three rows for a pickup that has not happened would + // have the rider drive to the same door three times. + collected := false + for _, d := range destinations { + if d.Consignmentid != nil { + collected = true + break + } + } + if !collected { + // Destination 0's address is already mirrored onto the booking, so the + // single pre-pickup stop is correct as built above. + return single + } + + parcelsByDestination := map[int][]models.BookingParcel{} + for _, p := range b.Parcels { + if p.Bookingdestinationid != nil { + parcelsByDestination[*p.Bookingdestinationid] = append(parcelsByDestination[*p.Bookingdestinationid], p) + } + } + + // Stop order is `seq`, guaranteed here rather than inherited from whatever + // ORDER BY the caller happened to use. The queue query does sort by seq + // today, so this changes nothing — but the ordering IS the route the rider + // drives, and leaving it as an unstated precondition means the next caller, + // or an edited query, silently reorders someone's afternoon. Sorted on a + // copy so the caller's slice is never mutated underneath it. + ordered := make([]models.BookingDestination, len(destinations)) + copy(ordered, destinations) + sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Seq < ordered[j].Seq }) + destinations = ordered + + stops := make([]milerStop, 0, len(destinations)) + for _, d := range destinations { + lat, lng := 0.0, 0.0 + if d.Pinlatitude != nil && d.Pinlongitude != nil { + lat, lng = *d.Pinlatitude, *d.Pinlongitude + } + stops = append(stops, milerStop{ + seq: d.Seq, + consignmentID: d.Consignmentid, + trackingNo: d.Trackingno, + deliveryAddress: joinNonEmpty(", ", d.Building, d.Street, d.Landmark, d.Districtname, d.Statename), + deliveryLat: lat, + deliveryLng: lng, + recipientName: d.Recipientname, + recipientPhone: d.Recipientphone, + codAmount: d.Codamount, + parcels: parcelsByDestination[d.Bookingdestinationid], + }) + } + + // Destination 0's coordinates are mirrored onto the booking and may have + // been corrected there by the rider at the door, so prefer those when the + // destination itself never got a pin. + if stops[0].deliveryLat == 0 && stops[0].deliveryLng == 0 { + stops[0].deliveryLat = b.Deliverylatitude + stops[0].deliveryLng = b.Deliverylongitude + } + + return stops +} + // milerConsignmentForRider loads a consignment and confirms it belongs to this // rider — either it is linked to a booking currently assigned to them, or they // are the one who collected it (Createdby). Returns a stable error code on @@ -437,6 +605,19 @@ func milerConsignmentForRider(milerUserID, consignmentID int) (*models.Consignme db.DB.Model(&models.PickupBooking{}). Where("consignmentid = ? AND assignedmileruserid = ?", consignmentID, milerUserID). Count(&count) + + // pickupbookings.consignmentid names only the FIRST order of a + // multi-destination pickup, so the count above misses orders 2..N entirely. + // The destination table is what actually knows which booking a consignment + // belongs to. + if count == 0 { + db.DB.Model(&models.BookingDestination{}). + Joins("JOIN pickupbookings ON pickupbookings.bookingid = bookingdestinations.bookingid"). + Where("bookingdestinations.consignmentid = ? AND pickupbookings.assignedmileruserid = ?", + consignmentID, milerUserID). + Count(&count) + } + if count == 0 && consignment.Createdby != milerUserID { return nil, constants.ErrConsignmentNotAssigned, fmt.Errorf("not this rider's consignment") } @@ -544,14 +725,30 @@ func MilerStartDelivery(c *fiber.Ctx) error { return utils.Internal(c, "failed to update miler availability") } + notifyOutForDelivery, err := recordCxConsignmentStage(tx, consignment.Consignmentid, + constants.ConsignmentOutForDelivery, constants.CxActorMiler, &milerUserID, + "POST /miler/consignments/{id}/start-delivery") + if err != nil { + tx.Rollback() + utils.Error("MilerStartDelivery: could not record out_for_delivery", + "consignment_id", consignment.Consignmentid, "error", err) + return utils.Internal(c, "failed to start delivery") + } + if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to start delivery") } + notifyOutForDelivery() + // Tell the customer it's on the way, and hand the receiver their OTP (only the // receiver — the rider is told it at the door). - var booking models.PickupBooking - if db.DB.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil { + // + // Resolved through bookingdestinations: pickupbookings.consignmentid names + // only the first order of a multi-destination pickup, so joining on it meant + // no customer was ever told about orders 2..N going out for delivery. + if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil { + booking := *bookingPtr var customer models.AppCustomer if db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error == nil && customer.Devicetoken != "" { body := "Your parcel is out for delivery." @@ -598,16 +795,32 @@ func MilerDeliverConsignment(c *fiber.Ctx) error { return utils.BadRequest(c, "deliveredtoname is required") } - var consignment models.Consignment - if err := db.DB.First(&consignment, consignmentID).Error; err != nil { - return utils.NotFound(c, "consignment not found") - } - - var booking models.PickupBooking - if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID). - First(&booking).Error; err != nil { + // Ownership and the booking behind the parcel, via the one helper that + // understands a multi-destination pickup. This used to be a direct + // `WHERE consignmentid = ? AND assignedmileruserid = ?` on pickupbookings — + // which names only the FIRST order of a pickup, so a rider delivering the + // second parcel of a three-destination visit was told "assigned consignment + // not found" and could not close the delivery at all. + // Both failures answer 404 with the exact messages this endpoint has always + // returned. milerConsignmentForRider distinguishes "no such consignment" + // from "not yours" and start-delivery reports that as a 403, but the + // deployed rider app was built against a 404 here and changing a live + // endpoint's status code is not this work's business. Only the LOOKUP is + // fixed; the contract is byte-identical. + consignmentPtr, code, err := milerConsignmentForRider(milerUserID, consignmentID) + if err != nil { + if code == constants.ErrConsignmentNotFound { + return utils.NotFound(c, "consignment not found") + } return utils.NotFound(c, "assigned consignment not found") } + consignment := *consignmentPtr + + _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid) + if !ok || bookingPtr == nil { + return utils.NotFound(c, "assigned consignment not found") + } + booking := *bookingPtr if consignment.Status != constants.ConsignmentOutForDelivery { return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState, @@ -702,10 +915,27 @@ func MilerDeliverConsignment(c *fiber.Ctx) error { return utils.Internal(c, "failed to close assignment") } + // The customer's Delivered milestone, against THIS order. The booking only + // reads as completed once every destination has landed — cxstage rolls the + // booking up from the least-advanced order, so a two-parcel pickup with one + // still in transit stays "In transit" rather than telling the customer + // everything arrived. + notifyDelivered, err := recordCxConsignmentStage(tx, consignment.Consignmentid, + constants.ConsignmentDelivered, constants.CxActorMiler, &milerUserID, + "POST /miler/consignments/{id}/deliver") + if err != nil { + tx.Rollback() + utils.Error("MilerDeliverConsignment: could not record delivered", + "consignment_id", consignment.Consignmentid, "error", err) + return utils.Internal(c, "failed to confirm delivery") + } + if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to confirm delivery") } + notifyDelivered() + if db.Js != nil { payload := map[string]interface{}{ "bookingid": booking.Bookingid, diff --git a/controllers/milerController.go b/controllers/milerController.go index c4a2c2c..476d004 100644 --- a/controllers/milerController.go +++ b/controllers/milerController.go @@ -2,7 +2,6 @@ package controllers import ( "context" - "crypto/rand" "encoding/json" "fmt" "math" @@ -16,6 +15,7 @@ import ( "doormile/db" "doormile/dto" "doormile/internal/assignment" + "doormile/internal/cxstage" "doormile/internal/legs" "doormile/internal/notify" "doormile/internal/routing" @@ -26,12 +26,6 @@ import ( "github.com/redis/go-redis/v9" ) -func generateTrackingNo() string { - b := make([]byte, 4) - rand.Read(b) - return fmt.Sprintf("DM-TRK-%X-%d", b, time.Now().Unix()%100000) -} - func LoginMiler(cfg *config.Config) fiber.Handler { return func(c *fiber.Ctx) error { req := new(dto.MilerLoginRequest) @@ -529,10 +523,40 @@ func AcceptMilerAssignment(c *fiber.Ctx) error { return utils.Internal(c, "failed to update miler availability") } + // Accepting is the customer's "on the way": the rider has seen the job and + // is heading over. `assigned` was already recorded when the assignment was + // created (assignMilerTx / commitAssignment) and is re-asserted here only + // as a safety net for a booking assigned before this surface existed — + // Record dedupes, so it appends nothing when it is already on the timeline. + // + // Deriving on_the_way from the GPS stream instead would mean re-deriving it + // on every ping: thousands of writes to learn something the accept already + // said. + stageAt := utils.DBNow() + for _, stage := range []string{constants.CxStageAssigned, constants.CxStageOnTheWay} { + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: assignment.Bookingid, + Stage: stage, + ActorType: constants.CxActorMiler, + ActorID: &milerUserID, + Source: "POST /miler/assignments/{id}/accept", + At: stageAt, + }); err != nil { + tx.Rollback() + utils.Error("AcceptMilerAssignment: could not record customer stage", + "booking_id", assignment.Bookingid, "stage", stage, "error", err) + return utils.Internal(c, "failed to accept assignment") + } + } + if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to commit assignment acceptance") } + // No push here: `assigned` was already announced when the assignment was + // created, and on_the_way deliberately rolls up on the timeline. The + // existing "Miler Accepted" notification below is the one the customer gets. + // Accepting a stop moves it into the active set the optimizer orders over, so // re-sequence the rider off the request path. No-op below two active stops. routing.SequenceMilerStopsAsync(milerUserID) @@ -687,6 +711,17 @@ func MilerCancelAssignment(c *fiber.Ctx) error { return utils.Internal(c, "failed to update miler availability") } + // The pickup is NOT cancelled — it goes back into the pool. The customer's + // stage has to walk back with it, or they keep seeing "Miler assigned" and + // a rider card for someone who is no longer coming. + if err := cxstage.Release(tx, booking.Bookingid, req.Reason, + constants.CxActorMiler, &milerUserID, + "POST /miler/bookings/{id}/cancel"); err != nil { + tx.Rollback() + utils.Error("MilerCancelAssignment: could not release the customer stage", "booking_id", booking.Bookingid, "error", err) + return utils.Internal(c, "failed to commit cancellation") + } + if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to commit cancellation") } @@ -751,9 +786,29 @@ func BookingReachedCustomer(c *fiber.Ctx) error { return utils.Internal(c, "failed to update miler availability") } + // Arrival is the LAST cancellable stage on the customer side, so it has to + // be recorded transactionally with the arrival fact itself. A gap between + // the two is a window in which the customer can still cancel a pickup the + // rider is already standing at. + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: booking.Bookingid, + Stage: constants.CxStageArrived, + ActorType: constants.CxActorMiler, + ActorID: &milerUserID, + Source: "POST /miler/bookings/{id}/reached", + At: utils.DBNow(), + }); err != nil { + tx.Rollback() + utils.Error("BookingReachedCustomer: could not record arrived stage", "booking_id", booking.Bookingid, "error", err) + return utils.Internal(c, "failed to record arrival") + } + if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to confirm arrival") } + + go cxstage.Notify(booking.Bookingid, nil, constants.CxStageArrived) + return utils.OK(c, fiber.Map{ "bookingid": booking.Bookingid, "status": booking.Status, @@ -859,12 +914,19 @@ func BookingParcelConfirm(c *fiber.Ctx) error { return utils.NotFound(c, "assigned booking not found") } + // Photos are the evidence half of the receipt. Weight without a photograph + // is a number the customer has no way to check, and this is the only point + // in the flow where anyone is standing next to the parcel. Sent as storage + // keys from the presigned upload (POST /miler/uploads/sign), not as raw + // URLs: the customer is served a short-lived signed link derived from the + // key, never a permanent one. type ParcelUpdate struct { - ParcelID int `json:"parcel_id"` - Weight float64 `json:"weight"` - Length float64 `json:"length"` - Width float64 `json:"width"` - Height float64 `json:"height"` + ParcelID int `json:"parcel_id"` + Weight float64 `json:"weight"` + Length float64 `json:"length"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Photos []string `json:"photos"` } var req struct { Parcels []ParcelUpdate `json:"parcels"` @@ -887,8 +949,13 @@ func BookingParcelConfirm(c *fiber.Ctx) error { parcelMap[parcels[i].Bookingparcelid] = &parcels[i] } - now := time.Now() + now := utils.DBNow() var totalChargeable float64 + // Chargeable weight per destination, so each order settles on the weight of + // its own parcels rather than on the whole visit's total. + perDestination := map[int]float64{} + + tx := db.DB.Begin() for _, upd := range req.Parcels { p, ok := parcelMap[upd.ParcelID] @@ -900,10 +967,51 @@ func BookingParcelConfirm(c *fiber.Ctx) error { p.Width = upd.Width p.Height = upd.Height p.Updatedat = now - db.DB.Save(p) + if err := tx.Save(p).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to save parcel measurements") + } volumetric := calculateVolumetricWeight(upd.Length, upd.Width, upd.Height) - totalChargeable += math.Max(upd.Weight, volumetric) + chargeable := math.Max(upd.Weight, volumetric) + totalChargeable += chargeable + if p.Bookingdestinationid != nil { + perDestination[*p.Bookingdestinationid] += chargeable + } + + for _, key := range upd.Photos { + key = strings.TrimSpace(key) + if key == "" { + continue + } + photo := models.BookingParcelPhoto{ + Bookingid: bookingID, + Bookingdestinationid: p.Bookingdestinationid, + Objectkey: key, + Capturedbyuserid: &milerUserID, + Capturedat: now, + } + if err := tx.Create(&photo).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to save parcel photo") + } + } + } + + // The verification block the customer's receipt reads. Written here, at the + // door, where the measurement was actually taken — pickup-complete restates + // it from the same parcel rows when the price settles, so the two cannot + // disagree. + for destinationID, weight := range perDestination { + if err := cxRecordVerification(tx, destinationID, weight, milerUserID, now); err != nil { + tx.Rollback() + utils.Error("BookingParcelConfirm: could not record verification", "booking_id", bookingID, "error", err) + return utils.Internal(c, "failed to record the parcel weight") + } + } + + if err := tx.Commit().Error; err != nil { + return utils.Internal(c, "failed to confirm parcels") } return utils.OK(c, fiber.Map{ @@ -1030,30 +1138,17 @@ func BookingPickupComplete(c *fiber.Ctx) error { } } - var parcels []models.BookingParcel - tx.Where("bookingid = ?", bookingID).Find(&parcels) - - var totalDead, totalChargeable, maxL, maxW, maxH float64 - for _, p := range parcels { - vol := calculateVolumetricWeight(p.Length, p.Width, p.Height) - totalDead += p.Weight - totalChargeable += math.Max(p.Weight, vol) - if p.Length > maxL { - maxL = p.Length - } - if p.Width > maxW { - maxW = p.Width - } - if p.Height > maxH { - maxH = p.Height - } + // One visit, N orders. A customer-app booking fans out into one consignment + // per destination — each with its own tracking number and its own journey — + // while a console-created booking, which has no destination rows, produces + // the single consignment it always did. cxPickupLegs is what decides which + // of those this is; nothing below needs to know. + legs, err := cxPickupLegs(tx, &booking) + if err != nil { + tx.Rollback() + utils.Error("BookingPickupComplete: could not resolve pickup legs", "booking_id", bookingID, "error", err) + return utils.Internal(c, "failed to read the parcels on this booking") } - 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 // 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) } - // 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 // CreateExpressBooking time), not the completing miler's tenantid claim — a // 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 } - // 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: 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 + var tenant models.Tenant + tenantNeedsOTP := false + if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil { + tenantNeedsOTP = tenant.Requiredeliveryotp } + // Money collected at the door. Split across the legs below rather than + // stamped whole onto each one: a single payment covering a three-stop + // pickup must not appear three times in the books. var payment models.BookingPayment - if tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil { - if payment.Paymentstatus == constants.PaymentStatusPaid { - consignment.Codcollected = payment.Amount - } else { - consignment.Codamount = payment.Amount + hasPayment := tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil + + created := make([]models.Consignment, 0, len(legs)) + trackingNos := make([]string, 0, len(legs)) + riderMarkedBusy := false + assignmentStillOpen := false + + for i, leg := range legs { + totalDead, totalChargeable, maxL, maxW, maxH := cxLegWeights(leg) + trackingNo := generateTrackingNo() + + // A hub-routed parcel: with the hub-handover flow ON it stops at Created — + // collected, in the rider's hands, on its way to a base — and only reaches + // Inwarded_at_Hub when the handover is actually recorded. With it OFF + // (default, and what the current app expects) it is marked Inwarded_at_Hub + // here, which is not where the parcel physically is but is what the current + // app and the console's inbound views read. + consignmentStatus := constants.ConsignmentInwardedAtHub + if hubHandoverEnabled() { + consignmentStatus = constants.ConsignmentCreated + } + + // Hyperlocal shortcut: pickup and delivery in the same postal area mean no + // hub-to-hub tripsheet leg is needed, so the same miler carries it to the + // final mile instead of parking it at the hub. Decided per leg, because on + // a multi-destination pickup one parcel can be going round the corner while + // another is going to another state. + // + // With the collected-state flow ON it lands in Collected_By_Miler — collected + // but not yet out for delivery — and the rider taps start-delivery to move it + // to Out_for_Delivery, which lets the console tell "collected" from "actively + // delivering". With it OFF (default, and what the current app expects) it goes + // straight to Out_for_Delivery exactly as before. + if isHyperlocalBooking(booking.Pickuppincode, leg.DeliveryPincode, + booking.Pickuplatitude, booking.Pickuplongitude, + leg.DeliveryLatitude, leg.DeliveryLongitude) { + if collectedStateEnabled() { + consignmentStatus = constants.ConsignmentCollectedByMiler + } else { + consignmentStatus = constants.ConsignmentOutForDelivery + } + } + + // Carried over so the consignment stays traceable to the client site it was + // collected from — for a food client that's the kitchen, and "how many + // parcels went out of which kitchen" is unanswerable without it. + 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" } - } - // 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 { - var tenant models.Tenant - if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil && tenant.Requiredeliveryotp { + // Under the compatibility flow the parcel is treated as received at the base + // the moment it is collected, so the received-at fact is stamped here too — + // otherwise every parcel inwarded this way would have a null handover time + // and base reconciliation would have nothing to compare against. + if consignmentStatus == constants.ConsignmentInwardedAtHub { + consignment.Inwardedat = &now + } + + // The pickup fee the miler collected covers the whole visit, so it is + // recorded once — against the first order — rather than repeated on + // every leg. + if hasPayment && i == 0 { + if payment.Paymentstatus == constants.PaymentStatusPaid { + consignment.Codcollected = payment.Amount + } else { + consignment.Codamount += payment.Amount + consignment.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) } + + 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() - 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 if err := tx.Save(&booking).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to link booking to consignment") } - 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") - } - - // 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. + // Unchanged from before the fan-out: a parcel still on its way to a base + // keeps the rider marked busy; anything else frees them up. postPickupAvailability := constants.MilerAvailable - if consignmentStatus == constants.ConsignmentCollectedByMiler || - 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. + if riderMarkedBusy { 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 // refuses while any assignment is still Assigned/Accepted. On the handover // flow the assignment stays open on purpose and closes at inward-at-hub. - if consignmentStatus == constants.ConsignmentInwardedAtHub { + if !assignmentStillOpen { if err := tx.Model(&models.BookingAssignment{}). Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?", bookingID, milerUserID, @@ -1231,25 +1457,16 @@ func BookingPickupComplete(c *fiber.Ctx) error { return utils.Internal(c, "failed to complete pickup") } + // One notification for the milestone, not one per order: three tracking + // numbers arriving as three buzzes for a single visit is noise, and + // order_created deliberately rolls up on the timeline. + go cxstage.Notify(bookingID, nil, constants.CxStagePickedUp) + // If an OTP was issued here (parcel went straight out for delivery), it goes to // the receiver in this notification — the rider is told it at the door. When // the collected-state flow is on, no OTP exists yet and the notification is // just "collected"; the OTP rides the start-delivery notification instead. - var customer models.AppCustomer - if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" { - body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo) - payload := map[string]string{ - "booking_id": strconv.Itoa(bookingID), - "tracking_no": trackingNo, - } - if consignment.Deliveryotp != "" { - body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignment.Deliveryotp) - payload["delivery_otp"] = consignment.Deliveryotp - } - if notifyErr := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); notifyErr != nil { - utils.Warn("FCM: failed to notify customer on pickup", "booking_id", bookingID, "error", notifyErr) - } - } + notifyCustomerOnPickup(&booking, created, trackingNos) // 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 @@ -1259,22 +1476,74 @@ func BookingPickupComplete(c *fiber.Ctx) error { // // consignment_id is always present: the delivery leg is keyed on it, and // without it the app cannot name the parcel it is about to act on. + // + // The single-consignment fields still describe the FIRST order, unchanged, + // so the deployed rider app keeps working exactly as before. `consignments` + // is additive and carries the full set for a build that can show them. resp := fiber.Map{ - "tracking_no": trackingNo, - "consignment_id": consignment.Consignmentid, - "consignmentstatus": consignment.Status, - "status": consignment.Status, + "tracking_no": trackingNos[0], + "consignment_id": first.Consignmentid, + "consignmentstatus": first.Status, + "status": first.Status, "booking_no": booking.Bookingno, "booking_status": booking.Status, - "next_action": nextActionForConsignment(consignment.Status), + "next_action": nextActionForConsignment(first.Status), + "consignments": renderPickupOrders(created, trackingNos), } - if consignment.Status == constants.ConsignmentCreated || - consignment.Status == constants.ConsignmentInwardedAtHub { + if first.Status == constants.ConsignmentCreated || + first.Status == constants.ConsignmentInwardedAtHub { resp["next_hub"] = renderBase(handoverHub) } 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 { milerUserID := c.Locals("userid").(int) bookingID, err := strconv.Atoi(c.Params("bookingid")) diff --git a/controllers/otpController.go b/controllers/otpController.go deleted file mode 100644 index 2a4ac2b..0000000 --- a/controllers/otpController.go +++ /dev/null @@ -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") - } -} diff --git a/docs/customer-api-testing.md b/docs/customer-api-testing.md new file mode 100644 index 0000000..645ed20 --- /dev/null +++ b/docs/customer-api-testing.md @@ -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 +``` + +### 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: +``` +```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": "" } +``` + +**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": "", "deviceToken": "" } +``` + +### 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: +``` +```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=`. + +**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": "", "platform": "android", "appVersion": "1.0.0+12" } +``` + +**21. Unregister** — `DELETE https://api.doormile.com/api/v1/customer/devices/` + +### 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`. diff --git a/docs/customer-app-api.md b/docs/customer-app-api.md new file mode 100644 index 0000000..3dbf850 --- /dev/null +++ b/docs/customer-app-api.md @@ -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::`), 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 ` 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/+` 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. diff --git a/docs/openapi-customer.yaml b/docs/openapi-customer.yaml new file mode 100644 index 0000000..800e093 --- /dev/null +++ b/docs/openapi-customer.yaml @@ -0,0 +1,1495 @@ +openapi: 3.1.0 + +info: + title: Doormile — Customer App API + version: "1.0.0" + description: | + The `/customer/*` namespace consumed by the doormile_cx Flutter app. + + **A customer books a pickup, not a shipment.** One booking has 1..N + destinations; each destination becomes its own order, with its own tracking + number and its own journey, when the miler completes the pickup. No tracking + number exists at booking time — `POST /customer/bookings` returns a + reference only. + + **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 then is an estimate range. + + **Doormile is the carrier, not the seller.** Any money at the destination + door is the customer's own collection, made on their behalf. + + Every response — auth included — puts its payload in `data`. Timestamps are + UTC epoch milliseconds. Money is whole rupees, never paise. + + Implementation notes, decisions and the answers to the open questions: + `docs/customer-app-api.md`. + +servers: + - url: https://api.doormile.com/api/v1 + description: Production + - url: https://staging-api.doormile.com/api/v1 + description: Staging — same contract, seeded to mirror the client mock + +tags: + - name: Auth + description: 4-digit code to a phone or an email address. No password anywhere. + - name: Catalogue + description: Serviceability, pickup slots and limits. Drives the whole booking form. + - name: Places + description: Geocoding, proxied — the app is never handed a map key. + - name: Fare + description: Estimate range for one pickup visit. + - name: Bookings + description: Create, list, track, cancel and complete the details of a pickup. + - name: Devices + description: Push registration. + - name: Account + description: Profile and saved addresses. Answers §13.5 of the requirements. + - name: Ops + description: Staging-only QA support. + +security: + - bearerAuth: [] + +paths: + + # ── §4 Auth ──────────────────────────────────────────────────────────────── + + /customer/auth/otp/request: + post: + tags: [Auth] + summary: Send a sign-in code + security: [] + description: | + Answers identically whether or not the identifier has an account. + Telling an anonymous caller "no account found" would turn this endpoint + into a directory of who is registered. + + `sent: false` with a `resendAfterSeconds` is not an error — the caller + is inside the 30-second cooldown and the screen already renders a + countdown. It does not consume one of the five hourly codes. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [identifier] + properties: + identifier: + type: string + description: E.164 phone or email. `+91 98765 43210` with spaces is also accepted and normalised. + examples: ["+919876543210", "you@example.com"] + responses: + "200": + description: Code sent, or the caller is inside the cooldown + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + required: [sent, resendAfterSeconds, codeLength] + properties: + sent: { type: boolean } + resendAfterSeconds: + type: integer + description: Server-driven. The client stops hardcoding 30. + examples: [30] + codeLength: { type: integer, examples: [4] } + "400": { $ref: "#/components/responses/Invalid" } + "429": { $ref: "#/components/responses/RateLimited" } + + /customer/auth/signup: + post: + tags: [Auth] + summary: Create the account and send the code + security: [] + description: | + 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 would strand a returning customer who tapped Sign up out + of habit. + + The account row is created here, but no session exists until the code + comes back — an abandoned signup leaves a row and nothing else. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, phone] + properties: + name: { type: string, minLength: 2, examples: ["Joe Oommen"] } + phone: { type: string, examples: ["+919876543210"] } + email: { type: string, examples: ["joe@example.com"] } + responses: + "200": + description: Account ready and code sent + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + properties: + sent: { type: boolean } + resendAfterSeconds: { type: integer } + "400": + description: Validation failed. `invalid_name` when the name is under two characters. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "403": { $ref: "#/components/responses/Forbidden" } + "429": { $ref: "#/components/responses/RateLimited" } + + /customer/auth/otp/verify: + post: + tags: [Auth] + summary: Exchange a code for a session + security: [] + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + description: | + A code is single-use and dies after three wrong attempts. Verifying a + phone with no account behind it completes a signup, and `name` is + required in that case. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [identifier, code] + properties: + identifier: { type: string } + code: { type: string, examples: ["4821"] } + name: + type: string + description: Required only when this verify is completing a signup. + responses: + "200": + description: Signed in + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Session" } + "401": + description: "`invalid_otp` — wrong or expired code" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "403": { $ref: "#/components/responses/Forbidden" } + + /customer/auth/refresh: + post: + tags: [Auth] + summary: Rotate the session + security: [] + description: | + Rotation, not reuse: the presented token is revoked and a new pair + issued, so a token captured from an old device stops working the moment + the real device refreshes. A **revoked** token coming back revokes the + customer's whole session chain — a stale client and a stolen one are + indistinguishable, and killing the chain costs the honest customer one + sign-in and costs an attacker the session. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [refreshToken] + properties: + refreshToken: { type: string } + responses: + "200": + description: New access/refresh pair + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Session" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /customer/auth/logout: + post: + tags: [Auth] + summary: Sign out + description: | + Revokes the refresh token and unregisters the device's push token, so a + signed-out phone stops receiving another person's parcel updates. With + no `refreshToken` supplied it signs out everywhere, rather than leaving + a session the customer believes they ended. + requestBody: + content: + application/json: + schema: + type: object + properties: + refreshToken: { type: string } + deviceToken: { type: string } + responses: + "200": + description: Signed out + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + properties: + signedOut: { type: boolean } + "401": { $ref: "#/components/responses/Unauthorized" } + + /customer/auth/me: + get: + tags: [Auth] + summary: The signed-in customer, for cold-start session restore + responses: + "200": + description: The customer + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Customer" } + "401": { $ref: "#/components/responses/Unauthorized" } + + # ── §5 Catalogue ─────────────────────────────────────────────────────────── + + /customer/serviceability/states: + get: + tags: [Catalogue] + summary: States a destination may be sent to + security: [] + description: | + `districtCount` counts **available** districts only; the client hides + any state showing 0. A state with nothing open is still returned, so it + can carry its "Opening soon" transit tag. + + An empty list is a legitimate answer — the app has a designed no-service + state for it. + parameters: + - $ref: "#/components/parameters/IfNoneMatch" + responses: + "200": + description: Serviceable states + headers: + ETag: { schema: { type: string } } + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ListEnvelope" + - type: object + properties: + data: + type: array + items: + type: object + required: [code, name, districtCount] + properties: + code: { type: string, examples: ["TN"] } + name: { type: string, examples: ["Tamil Nadu"] } + districtCount: { type: integer, examples: [6] } + transitTag: + type: string + maxLength: 22 + examples: ["Ultra-fast transit"] + "304": { description: Unchanged since the caller's ETag } + + /customer/serviceability/states/{stateCode}/districts: + get: + tags: [Catalogue] + summary: Districts in a state, unavailable ones included + security: [] + description: | + Unavailable districts are returned deliberately. The picker filters them + out, but the app shows their names in a quiet "coming soon" line — + omitting them would delete real copy from the screen. `note` says why + one is closed, so the app never has to invent a reason. + parameters: + - name: stateCode + in: path + required: true + schema: { type: string } + examples: { tamilNadu: { value: "TN" } } + - $ref: "#/components/parameters/IfNoneMatch" + responses: + "200": + description: Districts + headers: + ETag: { schema: { type: string } } + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ListEnvelope" + - type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/District" } + "304": { description: Unchanged since the caller's ETag } + "404": + description: Unknown or retired state + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /customer/pickup-slots: + get: + tags: [Catalogue] + summary: Pickup windows offered at a location + security: [] + description: | + Capacity- and location-aware. The id resolves to a real + pickup window on the booking, which is what the assignment engine + consumes — a slot the customer can pick is a slot ops can staff. + + Windows already past, or starting within 45 minutes, are **not returned + at all** rather than returned as unavailable: a greyed-out 8am slot at + 6pm is noise. At most one slot carries `tag`, and only when it can + actually be booked. + + The list is advisory and cached 30s; capacity is re-checked at confirm + time and a lost race returns `409`. + parameters: + - { name: lat, in: query, schema: { type: number }, description: Pickup point latitude } + - { name: lng, in: query, schema: { type: number }, description: Pickup point longitude } + responses: + "200": + description: Available windows, roughly today and tomorrow + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ListEnvelope" + - type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/PickupSlot" } + + /customer/config/booking-limits: + get: + tags: [Catalogue] + summary: Caps on a single pickup + security: [] + description: | + Nothing in the UI hardcodes these; they live server-side 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. + Never returns 0 for either — a zero cap would reject every booking on the + platform. + parameters: + - { name: lat, in: query, schema: { type: number } } + - { name: lng, in: query, schema: { type: number } } + responses: + "200": + description: Limits + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + required: [maxPackages, maxDestinations] + properties: + maxPackages: { type: integer, examples: [20] } + maxDestinations: { type: integer, examples: [5] } + + # ── §6 Places ────────────────────────────────────────────────────────────── + + /customer/places/reverse-geocode: + get: + tags: [Places] + summary: Turn device coordinates into a pickup label + description: | + Proxied, never keyed — the legacy rider app shipped a Maps key in the + binary and it had to be revoked, so the customer app is handed results + rather than credentials. + + On an upstream failure this returns a coordinate-derived label rather + than an error: the coordinates are what the rider navigates to, the text + is what the customer reads, and they can edit it. A blocked booking form + would be worse. + parameters: + - { name: lat, in: query, required: true, schema: { type: number } } + - { name: lng, in: query, required: true, schema: { type: number } } + responses: + "200": + description: The place + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Place" } + "400": { $ref: "#/components/responses/Invalid" } + + /customer/places/search: + get: + tags: [Places] + summary: Search for a pickup point + description: | + An empty `q` is not an error: the search sheet opens on it and is + answered with the customer's own saved and recently used places + (at most 4). Results are biased to `lat`/`lng`. + parameters: + - { name: q, in: query, schema: { type: string } } + - { name: lat, in: query, schema: { type: number } } + - { name: lng, in: query, schema: { type: number } } + responses: + "200": + description: Matches, or recent places when `q` is empty + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ListEnvelope" + - type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Place" } + + # ── §7 Fare ──────────────────────────────────────────────────────────────── + + /customer/fare/estimate: + post: + tags: [Fare] + summary: Estimate the whole pickup + description: | + An estimate **range**, never a final price — the miler weighs each + package at the door and that is when the price settles. Called on every + route and package-count change, so it is cheap and cacheable. + + The result is the combined price for one visit, including the multi-stop + uplift for additional destinations. A failure here must not block a + booking; the client swallows it. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [destinations] + properties: + pickup: + type: object + properties: + lat: { type: number } + lng: { type: number } + destinations: + type: array + minItems: 1 + items: + type: object + required: [stateCode, districtCode, packageCount] + properties: + stateCode: { type: string, examples: ["TN"] } + districtCode: { type: string, examples: ["TN-MAA"] } + packageCount: { type: integer, minimum: 1 } + responses: + "200": + description: The estimate + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/FareEstimate" } + "400": { $ref: "#/components/responses/Invalid" } + + # ── §9 Bookings ──────────────────────────────────────────────────────────── + + /customer/bookings: + post: + tags: [Bookings] + summary: Create the pickup + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + description: | + Returns a `reference` (`DM-######`) and **no tracking number on any + destination** — those are minted per destination when the miler + completes the pickup. + + Only `stateCode`, `districtCode` and `packageCount` are required per + destination. Street, building, landmark, recipient and instructions are + optional and may be completed later by the customer + (`PATCH .../destinations/{index}`) or by the miler at the door. The UI + renders a missing one as "Not added — the Miler can confirm this at + pickup". + + `estimate` is what the customer was shown on Review. It is stored + verbatim for dispute audit and kept on the booking forever, including + after settlement — the receipt renders `amountPaid − fare.min` as the + weight adjustment. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [pickup, slotId, destinations] + properties: + pickup: + type: object + required: [title, sub, lat, lng] + properties: + title: { type: string, maxLength: 32, examples: ["12 Nehru Street"] } + sub: { type: string, examples: ["Gandhipuram, Coimbatore 641012"] } + lat: { type: number } + lng: { type: number } + slotId: { type: string, examples: ["slot_20260905_t1"] } + destinations: + type: array + minItems: 1 + items: + type: object + required: [stateCode, districtCode, packageCount] + properties: + stateCode: { type: string } + districtCode: { type: string } + packageCount: { type: integer, minimum: 1 } + details: { $ref: "#/components/schemas/DeliveryDetailsInput" } + estimate: + type: object + properties: + min: { type: integer } + max: { type: integer } + responses: + "201": + description: | + The full booking, with `stage: "booked"`, `status: "active"`, + `cancellable: true`, a one-entry history and no `trackingId`. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Booking" } + "400": + description: | + Validation failed. The `message` is shown verbatim — e.g. "Every + destination needs a serviceable state and district", "Pick a pickup + slot", "Up to 20 packages per pickup", "Up to 5 destinations per + pickup". + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "409": + description: "`conflict` — that pickup window just filled up" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: "`unserviceable` — a district closed, or the pickup point is outside an operating city" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + get: + tags: [Bookings] + summary: List the customer's pickups, newest first + description: | + Backs the Orders tabs, Home's recent list and pull-to-refresh. + **Keyset** pagination, not offset: an offset drifts when a new booking + lands mid-scroll and shows the customer the same row twice. + parameters: + - name: status + in: query + schema: { type: string, enum: [active, completed, cancelled] } + - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 50 } } + - { name: cursor, in: query, schema: { type: string }, description: "`nextCursor` from the previous page" } + responses: + "200": + description: A page of bookings + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ListEnvelope" + - type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Booking" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /customer/bookings/{reference}: + get: + tags: [Bookings] + summary: The canonical booking object + description: | + The tracking screen and the receipt are both rendered from this, and it + is polled while tracking is open. Supports `If-None-Match` → `304`, so + most of those polls are a header exchange. + parameters: + - $ref: "#/components/parameters/Reference" + - $ref: "#/components/parameters/IfNoneMatch" + responses: + "200": + description: The booking + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Booking" } + "304": { description: Unchanged since the caller's ETag } + "404": { $ref: "#/components/responses/NotFound" } + + /customer/bookings/{reference}/cancel: + post: + tags: [Bookings] + summary: Cancel the whole pickup + description: | + Cancels every destination — there is no partial cancellation in v1. + + Allowed up to and including `arrived`; from `picked_up` onward returns + `409`. `cancellable` on the booking mirrors the same policy so the UI can + hide the button, but **the server is the authority and re-checks** — the + button state is rendered from a response that may be seconds old. + + Free of charge in that window; no fee is computed or recorded anywhere. + + Cancelling a booking that is already cancelled returns `200`, not a + conflict: the customer asked for a state it is already in, and a retry + over a flaky network must not read as a failure. + parameters: + - $ref: "#/components/parameters/Reference" + requestBody: + content: + application/json: + schema: + type: object + properties: + reason: + type: string + description: | + Optional. Free text, or one of the app's five presets: + Booked by mistake · Package not ready · Sending it another + day · Changed the destination · Other. + responses: + "200": + description: Cancelled + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + properties: + reference: { type: string } + status: { type: string, const: cancelled } + cancelReason: { type: [string, "null"] } + "404": { $ref: "#/components/responses/NotFound" } + "409": + description: "`conflict` — this pickup can no longer be cancelled" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /customer/bookings/{reference}/destinations/{index}: + patch: + tags: [Bookings] + summary: Fill in a destination's details after booking + description: | + Any subset of fields. 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. + + Accepted until the parcels are collected; from `picked_up` onward + returns `409`, because after that the shipment's addresses are frozen on + the consignment and an edit would change what the customer sees without + changing where the parcel goes. + + The write lands on the same record the miler app reads addresses from, + so a correction made while the rider is en route reaches them. + parameters: + - $ref: "#/components/parameters/Reference" + - name: index + in: path + required: true + schema: { type: integer, minimum: 0 } + description: 0-based position of the destination within the booking. Stable for its lifetime. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DeliveryDetailsInput" } + responses: + "200": + description: The updated booking + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Booking" } + "404": { $ref: "#/components/responses/NotFound" } + "409": + description: "`conflict` — the packages have been collected" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /customer/orders/{trackingId}: + get: + tags: [Bookings] + summary: One order by tracking number + description: | + For push deep links (`doormile://track/DMX10482913`). Returns the full + booking object focused on that order — the client already parses this + shape, and a second shape for the same data is a second parser to keep + in step. + + A tracking number belonging to another customer answers `404`, not + `403`: confirming that a tracking number is real tells an enumerating + caller something they should not learn. + parameters: + - name: trackingId + in: path + required: true + schema: { type: string } + examples: { order: { value: "DMX10482913" } } + responses: + "200": + description: The booking that owns this order + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Booking" } + "404": { $ref: "#/components/responses/NotFound" } + + # ── §10 Devices ──────────────────────────────────────────────────────────── + + /customer/devices: + post: + tags: [Devices] + summary: Register a push token + description: | + One row per device token, not one column per customer — a phone and a + tablet both have to receive the delivery notification. + + A token that already exists is **reassigned** to the calling customer. + A shared handset, or one customer signing out and another in, is the only + case that matters here, and reassignment is the only outcome that does + not send one person's parcel updates to another person's phone. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: { type: string } + platform: { type: string, enum: [android, ios] } + appVersion: { type: string, examples: ["1.0.0+12"] } + responses: + "200": + description: Registered + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + properties: + registered: { type: boolean } + "400": { $ref: "#/components/responses/Invalid" } + + /customer/devices/{token}: + delete: + tags: [Devices] + summary: Unregister a push token + parameters: + - { name: token, in: path, required: true, schema: { type: string } } + responses: + "200": + description: Unregistered + content: + application/json: + schema: { $ref: "#/components/schemas/Envelope" } + + + # ── Account (§13.5) ──────────────────────────────────────────────────────── + + /customer/profile: + get: + tags: [Account] + summary: The signed-in customer's profile + responses: + "200": + description: The customer + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Customer" } + "401": { $ref: "#/components/responses/Unauthorized" } + put: + tags: [Account] + summary: Update the profile + description: | + Every field is optional and nullable-by-omission: an omitted key leaves + the stored value alone, an explicit value overwrites it. This matters — + the previous version cleared `email` and the last name on any call that + did not resend them, so editing a name silently wiped the email. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string, minLength: 2 } + email: { type: string } + defaultLatitude: { type: number } + defaultLongitude: { type: number } + defaultPincode: { type: string } + responses: + "200": + description: The updated customer + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Customer" } + "400": + description: "`invalid_name` when the name is under two characters" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /customer/locations: + get: + tags: [Account] + summary: Saved addresses + description: | + Returned in the same `title`/`sub` shape as a place search result, so an + address picked from Saved and one picked from Search are the same object + to the client. + responses: + "200": + description: Saved addresses, default first + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ListEnvelope" + - type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/SavedAddress" } + "401": { $ref: "#/components/responses/Unauthorized" } + post: + tags: [Account] + summary: Save an address + description: Capped at 10 per customer. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SavedAddressInput" } + responses: + "201": + description: Saved + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/SavedAddress" } + "400": + description: "`invalid` — missing fields, or the 10-address cap is reached" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /customer/locations/{id}: + put: + tags: [Account] + summary: Update a saved address + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SavedAddressInput" } + responses: + "200": + description: Updated + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/SavedAddress" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [Account] + summary: Remove a saved address + description: Soft delete — the row is retained, the address stops being offered. + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + "200": + description: Removed + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + properties: + id: { type: string } + deleted: { type: boolean } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + + # ── §11 QA ───────────────────────────────────────────────────────────────── + + /customer/ops/bookings/{reference}/stage: + post: + tags: [Ops] + summary: Force a booking to a stage (staging only) + description: | + Exists so every tracking state is reachable for design QA and the app's + debug stepper can be deleted. Reaching `out_for_delivery` honestly needs + a rider to accept, drive, weigh a parcel, hand it to a hub and start a + delivery run. + + **Double-gated**: refused unless `ENV != production` **and** + `CX_ALLOW_STAGE_OVERRIDE=true`. Two independent switches, because either + being wrong in production would let any customer mark their own parcel + delivered. Returns `404` when disabled, so its existence is not + advertised. + + It walks every intermediate stage rather than jumping, and writes through + the same recorder every real transition uses — so QA sees the real + projection over real event rows, not a special rendering path that could + pass while production is broken. + parameters: + - $ref: "#/components/parameters/Reference" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [stage] + properties: + stage: { $ref: "#/components/schemas/Stage" } + reason: + type: string + description: Recorded on the audit row, so a forced transition stays distinguishable from a real one forever after. + responses: + "200": + description: The booking at the requested stage + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Booking" } + "404": { description: Disabled in this environment, or unknown reference } + +components: + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + Reference: + name: reference + in: path + required: true + schema: { type: string, pattern: "^DM-" } + examples: { booking: { value: "DM-482913" } } + IdempotencyKey: + name: Idempotency-Key + in: header + required: false + schema: { type: string } + description: | + Any stable unique string the client picks per logical action. The first + request runs; a retry with the same key replays the stored response for + 24 hours, so a dropped ack on a bad network never becomes a duplicate + pickup. + IfNoneMatch: + name: If-None-Match + in: header + required: false + schema: { type: string } + + responses: + Invalid: + description: "`invalid` — validation failed" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Unauthorized: + description: "`unauthorized` — missing or expired access token" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Forbidden: + description: "`forbidden` — the token is valid but the resource is not the caller's" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + NotFound: + description: "`not_found`" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + RateLimited: + description: "`rate_limited` — includes `Retry-After`" + headers: + Retry-After: { schema: { type: integer } } + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + schemas: + + Envelope: + type: object + required: [success, data, message] + properties: + success: { type: boolean, const: true } + data: {} + message: + type: string + description: Always present on success, always empty. The field is never omitted. + const: "" + + ListEnvelope: + type: object + required: [success, data, total, nextCursor] + properties: + success: { type: boolean, const: true } + data: + type: array + description: Always an array, never null — the client types it as a list. + total: { type: integer } + nextCursor: + type: [string, "null"] + description: Null on the last page. + message: { type: string } + + Error: + type: object + required: [success, message, error] + properties: + success: { type: boolean, const: false } + message: + type: string + description: | + Customer-safe English, shown verbatim in the app's single error + state. Never an enum key, a stack trace or an HTML page. + error: + type: object + required: [code] + properties: + code: + type: string + enum: [invalid, invalid_name, invalid_otp, unauthorized, forbidden, + not_found, conflict, unserviceable, rate_limited, server_error] + + Customer: + type: object + required: [id, name, phone, email] + properties: + id: { type: string, examples: ["cust_10241"] } + name: { type: string, examples: ["Joe Oommen"] } + phone: { type: string, examples: ["+919876543210"] } + email: + type: string + description: Never null — the client types it as a non-nullable String. Empty when unknown. + + Session: + type: object + required: [accessToken, refreshToken, expiresIn, customer] + properties: + accessToken: { type: string } + refreshToken: { type: string } + expiresIn: { type: integer, description: Seconds. Access tokens last an hour; refresh tokens 60 days. } + customer: { $ref: "#/components/schemas/Customer" } + + District: + type: object + required: [code, name, available] + properties: + code: { type: string, examples: ["TN-CBE"] } + name: { type: string, examples: ["Coimbatore"] } + available: + type: boolean + description: False districts are not offered in the picker but are still listed. + note: { type: string, examples: ["Opening soon", "Paused this week"] } + hub: { type: string, examples: ["Coimbatore Central Hub"] } + promise: { type: string, examples: ["Next-day delivery", "2-day delivery"] } + lat: + type: number + description: | + The district's centre. Only `stateCode` and `districtCode` 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 when + unknown: null island is a real coordinate and renders as a pin off + the coast of Africa. + examples: [13.082680] + lng: { type: number, examples: [80.270718] } + + PickupSlot: + type: object + required: [id, day, window, available] + properties: + id: + type: string + description: Opaque; sent back as `slotId`. Encodes its date, so a stale slot resolves to that date and is rejected rather than silently booking today. + examples: ["slot_20260905_t1"] + day: { type: string, examples: ["Today", "Tomorrow", "Mon, 8 Sep"] } + window: { type: string, description: "En dash, spaced.", examples: ["2:00 – 4:00 PM"] } + available: { type: boolean } + note: { type: string, examples: ["Fully booked"] } + tag: + type: string + description: At most one slot in the list carries this, and only when it can actually be booked. + examples: ["Fastest pickup"] + milersNearby: { type: integer, description: Omitted at 0; the client hides the line. } + caption: { type: string, examples: ["Arriving in approx. 45 mins"] } + + Place: + type: object + required: [title, sub, lat, lng] + properties: + title: { type: string, maxLength: 32, examples: ["Brookefields Mall"] } + sub: { type: string, examples: ["Brookebond Road, Coimbatore 641001"] } + lat: { type: number } + lng: { type: number } + + FareEstimate: + type: object + required: [min, max, paymentMethod, parcel, routeKm] + properties: + min: { type: integer, description: "Whole rupees. 49 renders as ₹49.", examples: [167] } + max: { type: integer, examples: [267] } + paymentMethod: { type: string, examples: ["UPI · Cash at doorstep"] } + parcel: { type: string, examples: ["3 boxes (up to 3 kg each)"] } + routeKm: { type: number, examples: [6.4] } + + Stage: + type: string + description: | + The nine operational stages. The client rolls these into seven + milestones itself and silently falls back to `booked` on an unknown key, + so a new value needs a client release. + + Stages 0–5 belong to the booking. Stages 6–8 belong to each order and + may differ between destinations of the same booking. + enum: [booked, assigned, on_the_way, arrived, picked_up, order_created, + in_transit, out_for_delivery, delivered] + + DeliveryDetailsInput: + type: object + description: Every field optional. `null` clears a stored value; an omitted key leaves it alone. + properties: + street: { type: [string, "null"] } + building: { type: [string, "null"] } + landmark: { type: [string, "null"] } + recipientName: { type: [string, "null"] } + recipientPhone: { type: [string, "null"] } + instructions: { type: [string, "null"] } + pin: + type: [object, "null"] + properties: + lat: { type: number } + lng: { type: number } + codAmount: + type: [number, "null"] + description: | + Money the miler collects at this door **on the customer's behalf**. + Doormile is the carrier, not the seller — this is never Doormile's + money. + + Agent: + type: object + description: A miler, or the rider making the final delivery. + properties: + name: { type: string, examples: ["Arun Kumar"] } + vehicle: { type: string, examples: ["TN 37 BX 4412"] } + phone: + type: string + description: A masked-calling proxy when `MILER_CALL_PROXY` is configured, otherwise the rider's real number. + rating: { type: number, examples: [4.9] } + trips: { type: integer, examples: [1240] } + vehicleType: { type: string, examples: ["E-Scooter"] } + + Verification: + type: object + description: | + What the miler recorded at the door. Null until `picked_up` — the weight + and photographs cannot exist before someone was standing next to the + parcel. + required: [weightKg, photos, capturedAt, capturedBy] + properties: + weightKg: + type: number + description: The weight the price settled on. Rendered as "2.8 kg". + examples: [2.8] + photos: + type: array + description: Signed URLs, 30-minute TTL. One per package. Never permanent CDN links — a parcel photo can show the inside of someone's doorway. + items: { type: string, format: uri } + capturedAt: { type: integer, description: Epoch milliseconds, UTC. } + capturedBy: { type: string, examples: ["Arun Kumar"] } + + Destination: + type: object + required: [stateCode, stateName, districtCode, districtName, packageCount] + properties: + stateCode: { type: string, examples: ["TN"] } + stateName: + type: string + description: Always populated. The client renders "Chennai, Tamil Nadu" from these and never looks a code up. + examples: ["Tamil Nadu"] + districtCode: { type: string, examples: ["TN-MAA"] } + districtName: { type: string, examples: ["Chennai"] } + packageCount: { type: integer, examples: [2] } + district: + oneOf: + - { $ref: "#/components/schemas/District" } + - { type: "null" } + details: + type: object + description: Only the fields actually filled in. A missing one renders as "Not added — the Miler can confirm this at pickup". + trackingId: + type: [string, "null"] + description: Null until `order_created`. + examples: ["DMX10482913"] + stage: + oneOf: + - { $ref: "#/components/schemas/Stage" } + - { type: "null" } + description: Null until `order_created`; this order's own journey from there on. + verification: + oneOf: + - { $ref: "#/components/schemas/Verification" } + - { type: "null" } + + Booking: + type: object + description: | + The single most important response in this API — the tracking screen and + the receipt are both rendered from it. + required: [reference, stage, status, cancellable, createdAt, pickup, slotId, + destinations, routeKm, fare, history] + properties: + reference: { type: string, examples: ["DM-482913"] } + stage: { $ref: "#/components/schemas/Stage" } + status: + type: string + enum: [active, completed, cancelled] + description: Derived, but sent explicitly — a client that has to infer it will eventually infer it differently. + cancellable: + type: boolean + description: Mirrors the server policy so the UI can hide the button. The server re-checks on the cancel call; this is a hint, never the authority. + createdAt: { type: integer, description: Epoch milliseconds, UTC. } + pickup: + type: object + description: Present on EVERY booking, cancelled ones included. The client types it non-nullable and throws on null. + required: [title, sub, lat, lng] + properties: + title: { type: string } + sub: { type: string } + lat: { type: number } + lng: { type: number } + slotId: + type: string + description: Present on every booking, as above. + destinations: + type: array + items: { $ref: "#/components/schemas/Destination" } + miler: + oneOf: + - { $ref: "#/components/schemas/Agent" } + - { type: "null" } + description: Present from `assigned`. + deliveryAgent: + oneOf: + - { $ref: "#/components/schemas/Agent" } + - { type: "null" } + description: Present from `out_for_delivery`. + milerDistanceKm: + type: [number, "null"] + description: Live, from the rider's current position. Set during `on_the_way` and `arrived` only — after pickup it would describe a journey that already ended. + milerEtaMinutes: { type: [integer, "null"] } + milersInZone: + type: integer + description: How many riders are nearby. Shown while still finding a Miler. + routeKm: { type: number, examples: [6.4] } + expectedDelivery: + type: string + description: Display string, formatted server-side in IST. The latest promise across the destinations. + examples: ["Thu, 12 Sep"] + fare: + type: object + description: | + Kept on the booking forever, including after settlement — the + receipt renders `amountPaid − fare.min` as the weight adjustment, so + losing the original estimate would lose the explanation for the + difference. + properties: + min: { type: integer } + max: { type: integer } + paymentMethod: { type: string } + parcel: { type: string } + amountPaid: + type: [integer, "null"] + description: Settled total in whole rupees. Present from `picked_up`. + deliveredAt: + type: [integer, "null"] + description: When the LAST parcel landed. Null while any is still moving. + cancelReason: { type: [string, "null"] } + history: + type: array + description: | + Append-only and ordered oldest-first. One entry per stage actually + reached, with the real timestamp. **Nothing is synthesised or + backfilled** — a booking from before this surface existed has a short + history, and a short honest one beats a long invented one. + items: + type: object + required: [stage, at] + properties: + stage: { $ref: "#/components/schemas/Stage" } + at: { type: integer, description: Epoch milliseconds, UTC. } + + SavedAddress: + type: object + description: Same two-line shape as a Place, plus the recipient and the default flag. + required: [id, title, sub, lat, lng, isDefault] + properties: + id: { type: string } + label: { type: string, examples: ["Home", "Office"] } + title: { type: string } + sub: { type: string } + recipientName: { type: string } + recipientPhone: { type: string } + lat: { type: number } + lng: { type: number } + isDefault: { type: boolean } + + SavedAddressInput: + type: object + required: [address, pincode, latitude, longitude] + properties: + label: { type: string } + address: { type: string } + landmark: { type: string } + city: { type: string } + state: { type: string } + pincode: { type: string } + latitude: { type: number } + longitude: { type: number } + receivername: { type: string } + receiverphone: { type: string } + isdefault: { type: boolean } + + PushPayload: + type: object + description: | + FCM/APNs data payload for a customer-visible milestone change. Not an + endpoint — documented here because the client parses it. + + `on_the_way` and `order_created` deliberately do NOT produce a + notification; they roll up on the timeline. A customer buzzed for every + operational transition stops reading them and misses the one that + mattered. + properties: + type: { type: string, const: stage_change } + reference: { type: string, examples: ["DM-482913"] } + trackingId: + type: [string, "null"] + description: Null before `order_created`. + stage: { $ref: "#/components/schemas/Stage" } + title: { type: string, examples: ["Out for delivery"] } + body: { type: string, examples: ["Arriving today at the delivery address."] } + deepLink: + type: string + description: Sent from day one even though the client has no intent filter yet — a notification already in someone's tray should open the right screen once it does. + examples: ["doormile://track/DM-482913"] diff --git a/dto/auth.go b/dto/auth.go index 1d910f7..411b04f 100644 --- a/dto/auth.go +++ b/dto/auth.go @@ -1,41 +1,10 @@ package dto -type CustomerRegisterRequest struct { - Firstname string `json:"firstname" xml:"firstname" form:"firstname"` - Lastname string `json:"lastname" xml:"lastname" form:"lastname"` - Phone string `json:"phone" xml:"phone" form:"phone"` - Email string `json:"email" xml:"email" form:"email"` - Pin string `json:"pin" xml:"pin" form:"pin"` // 4 or 6 digit PIN - Configid int `json:"configid"` -} - -type CustomerLoginRequest struct { - Phone string `json:"phone" xml:"phone" form:"phone"` - Configid int `json:"configid"` -} - -type CustomerPinVerifyRequest struct { - Phone string `json:"phone" xml:"phone" form:"phone"` - Pin string `json:"pin" xml:"pin" form:"pin"` - Configid int `json:"configid"` - DeviceToken string `json:"device_token"` -} - -type CustomerResetPinRequest struct { - Phone string `json:"phone" xml:"phone" form:"phone"` - NewPin string `json:"new_pin" xml:"new_pin" form:"new_pin"` - Configid int `json:"configid"` -} - -type SendEmailOtpRequest struct { - Email string `json:"email" xml:"email" form:"email"` - Phone string `json:"phone" xml:"phone" form:"phone"` -} - -type VerifyEmailOtpRequest struct { - Email string `json:"email" xml:"email" form:"email"` - Otp string `json:"otp" xml:"otp" form:"otp"` -} +// The customer app has no password and no PIN: it authenticates on a 4-digit +// code sent to a phone or an email address, and its request shapes are declared +// inline in controllers/cxAuthController.go alongside the handlers that read +// them. The PIN register/login/verify/reset request types that used to live +// here went with that flow. type MilerLoginRequest struct { Phone string `json:"phone" xml:"phone" form:"phone"` diff --git a/internal/assignment/crm_assignment.go b/internal/assignment/crm_assignment.go index 621d778..925920e 100644 --- a/internal/assignment/crm_assignment.go +++ b/internal/assignment/crm_assignment.go @@ -8,6 +8,7 @@ import ( "doormile/constants" "doormile/db" + "doormile/internal/cxstage" "doormile/internal/routing" "doormile/models" "doormile/utils" @@ -227,8 +228,24 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate, return fmt.Errorf("update MilerProfile availability: %w", err) } + // The customer's "Miler assigned" milestone, in the same transaction as the + // assignment it describes. The auto-assignment path is how most B2C + // bookings get a rider, so without this the customer app's timeline would + // only ever advance for manually assigned pickups. + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: booking.Bookingid, + Stage: constants.CxStageAssigned, + ActorType: constants.CxActorSystem, + Source: "internal/assignment.commitAssignment", + }); err != nil { + tx.Rollback() + return fmt.Errorf("record assigned stage: %w", err) + } + tx.Commit() + go cxstage.Notify(booking.Bookingid, nil, constants.CxStageAssigned) + utils.Info("CRMAssignment: assigned", "booking_id", booking.Bookingid, "miler_id", milerUserID, diff --git a/internal/cxstage/stage.go b/internal/cxstage/stage.go new file mode 100644 index 0000000..316eff6 --- /dev/null +++ b/internal/cxstage/stage.go @@ -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 +} diff --git a/internal/cxstage/stage_test.go b/internal/cxstage/stage_test.go new file mode 100644 index 0000000..ebf8173 --- /dev/null +++ b/internal/cxstage/stage_test.go @@ -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) + } + } +} diff --git a/internal/sms/sms.go b/internal/sms/sms.go new file mode 100644 index 0000000..241e829 --- /dev/null +++ b/internal/sms/sms.go @@ -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 +} diff --git a/internal/sms/sms_test.go b/internal/sms/sms_test.go new file mode 100644 index 0000000..b384bd9 --- /dev/null +++ b/internal/sms/sms_test.go @@ -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 +} diff --git a/internal/storage/spaces.go b/internal/storage/spaces.go index c2f5922..5848d5d 100644 --- a/internal/storage/spaces.go +++ b/internal/storage/spaces.go @@ -223,3 +223,77 @@ func awsEncode(s string, encodeSlash bool) string { } return b.String() } + +// PresignGet issues a short-lived, signed GET URL for one object. +// +// Parcel photographs are shown to the customer on the receipt, and a parcel +// photograph frames the inside of someone's doorway. A permanent CDN link to +// one is a permanent link anybody who ever saw it can keep, so the customer +// surface serves these through a signature that expires instead. +// +// Falls back to the plain CDN URL when the bucket credentials are not +// configured: an unsigned photo the customer can see beats a receipt with a +// missing image, and the objects are currently written public-read anyway. +// Once parcel photos are switched to a private ACL this becomes the only way +// to read one — which is the point of routing them through here now. +func PresignGet(objectKey string, expiry time.Duration) (string, error) { + cfg := loadSpacesConfig() + if cfg.accessKey == "" || cfg.secretKey == "" || cfg.bucket == "" { + if cfg.cdnBase != "" { + return cfg.cdnBase + "/" + objectKey, nil + } + return "", fmt.Errorf("object storage not configured") + } + + const ( + service = "s3" + algorithm = "AWS4-HMAC-SHA256" + ) + + host := cfg.bucket + "." + cfg.endpoint + + now := time.Now().UTC() + amzDate := now.Format("20060102T150405Z") + dateStamp := now.Format("20060102") + expSecs := int(expiry.Seconds()) + if expSecs <= 0 { + expSecs = 900 + } + + canonicalURI := "/" + encodePath(objectKey) + credentialScope := dateStamp + "/" + cfg.region + "/" + service + "/aws4_request" + credential := cfg.accessKey + "/" + credentialScope + signedHeaders := "host" + + q := [][2]string{ + {"X-Amz-Algorithm", algorithm}, + {"X-Amz-Credential", credential}, + {"X-Amz-Date", amzDate}, + {"X-Amz-Expires", fmt.Sprintf("%d", expSecs)}, + {"X-Amz-SignedHeaders", signedHeaders}, + } + canonicalQuery := canonicalizeQuery(q) + canonicalHeaders := "host:" + host + "\n" + + canonicalRequest := strings.Join([]string{ + "GET", + canonicalURI, + canonicalQuery, + canonicalHeaders, + signedHeaders, + "UNSIGNED-PAYLOAD", + }, "\n") + + stringToSign := strings.Join([]string{ + algorithm, + amzDate, + credentialScope, + hexSHA256(canonicalRequest), + }, "\n") + + signingKey := deriveSigningKey(cfg.secretKey, dateStamp, cfg.region, service) + signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign)) + + return "https://" + host + canonicalURI + "?" + canonicalQuery + + "&X-Amz-Signature=" + signature, nil +} diff --git a/internal/storage/spaces_test.go b/internal/storage/spaces_test.go new file mode 100644 index 0000000..2dc40ee --- /dev/null +++ b/internal/storage/spaces_test.go @@ -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) + } + } +} diff --git a/main.go b/main.go index 6566c26..4ddc62e 100644 --- a/main.go +++ b/main.go @@ -167,6 +167,13 @@ func main() { AllowMethods: "GET,POST,PUT,DELETE,PATCH,OPTIONS", })) + // Echo X-Request-Id on every response, errors included, minting one when the + // caller did not send it. The customer app funnels every failure into a + // single retryable error state, so without this a support conversation + // about "it failed" has nothing to correlate against the logs. Registered + // before the logger so the id is available to it. + app.Use(middlewares.RequestID()) + // Structured Zap logger middleware app.Use(middlewares.ZapLogger()) diff --git a/middlewares/city_gate.go b/middlewares/city_gate.go index 9f15c5b..eed5b2b 100644 --- a/middlewares/city_gate.go +++ b/middlewares/city_gate.go @@ -39,3 +39,21 @@ func CityGateMiddleware(c *fiber.Ctx) error { "code": "CITY_NOT_SUPPORTED", }) } + +// PincodeInOperatingCity reports whether a pincode falls in a city Doormile +// runs in, and names it. +// +// Exported because the customer app's booking request does not carry a +// pickuppincode at all — its pickup point is a title/sub/lat/lng from the place +// search, so CityGateMiddleware's body sniff finds nothing and waves it +// through. A middleware that silently no-ops on the one caller it matters most +// for is worse than no middleware, so the customer handler resolves the pickup +// point to a pincode itself and asks this directly. +func PincodeInOperatingCity(pincode string) (string, bool) { + pincode = strings.TrimSpace(pincode) + if len(pincode) < 3 { + return "", false + } + city, ok := operatingCityPrefixes[pincode[:3]] + return city, ok +} diff --git a/middlewares/idempotency.go b/middlewares/idempotency.go index 83665ea..785d685 100644 --- a/middlewares/idempotency.go +++ b/middlewares/idempotency.go @@ -2,6 +2,8 @@ package middlewares import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "strconv" "strings" @@ -37,8 +39,7 @@ func Idempotency() fiber.Handler { if key == "" || db.Rdb == nil { return c.Next() } - uid, _ := c.Locals("userid").(int) - base := fmt.Sprintf("idem:%d:%s", uid, key) + base := "idem:" + idempotencyScope(c) + ":" + key ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -80,3 +81,32 @@ func Idempotency() fiber.Handler { 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::"). 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]) +} diff --git a/middlewares/idempotency_test.go b/middlewares/idempotency_test.go new file mode 100644 index 0000000..a9db02f --- /dev/null +++ b/middlewares/idempotency_test.go @@ -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) + } + } +} diff --git a/middlewares/logger.go b/middlewares/logger.go index 26fee72..5994a9a 100644 --- a/middlewares/logger.go +++ b/middlewares/logger.go @@ -19,21 +19,33 @@ func ZapLogger() fiber.Handler { method := c.Method() path := c.Path() + // Client identity and the request id travel with every log line. + // + // X-Client / X-Platform are what the customer app sends + // ("doormile-cx/1.0.0+12", "android"), and they are the only way to + // answer "is this failing on one build or everywhere" — which is the + // first question asked when an app-side report arrives. The request id + // is what correlates a customer saying "it failed" with the line that + // recorded the real error behind the customer-safe message. + // + // Recorded as empty rather than omitted when absent, so a caller that + // sends nothing is visibly a caller that sends nothing. + fields := []interface{}{ + "method", method, + "path", path, + "status", status, + "latency", latency, + "client", c.Get("X-Client"), + "platform", c.Get("X-Platform"), + } + if id, ok := c.Locals("requestid").(string); ok && id != "" { + fields = append(fields, "requestid", id) + } + if err != nil { - utils.Error("API request error", - "method", method, - "path", path, - "status", status, - "latency", latency, - "error", err.Error(), - ) + utils.Error("API request error", append(fields, "error", err.Error())...) } else { - utils.Info("API request success", - "method", method, - "path", path, - "status", status, - "latency", latency, - ) + utils.Info("API request success", fields...) } return err diff --git a/middlewares/requestid.go b/middlewares/requestid.go new file mode 100644 index 0000000..172ad08 --- /dev/null +++ b/middlewares/requestid.go @@ -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() + } +} diff --git a/migrations/migrate.go b/migrations/migrate.go index e26f5b7..d943141 100644 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -47,6 +47,19 @@ func Migrate(db *gorm.DB) error { &models.MilerDutyLog{}, &models.MilerBreakLog{}, &models.MilerSupportTicket{}, + + // Customer app (doormile_cx). All additive: new tables plus new + // nullable/zero-default columns on pickupbookings, so an existing row + // and every console-created booking stay valid with no backfill. + &models.ServiceableState{}, + &models.ServiceableDistrict{}, + &models.PickupSlotTemplate{}, + &models.CustomerBookingLimit{}, + &models.BookingDestination{}, + &models.BookingParcelPhoto{}, + &models.BookingStageEvent{}, + &models.CustomerRefreshToken{}, + &models.CustomerDevice{}, ) if err != nil { @@ -94,5 +107,47 @@ func Migrate(db *gorm.DB) error { utils.Info("✅ consignments_status_check constraint includes Collected_By_Miler") } + // Human-facing identifiers for the customer app: DM-###### for a pickup + // booking, DMX######## for an order. Sequence-backed rather than random, + // because both columns are UNIQUE and a random short id collides long + // before a short id runs out — a collision here is a failed booking at the + // moment of payment, not a retry. + // + // No CYCLE and no MAXVALUE on purpose: past 999999 the format simply grows + // a digit (DM-1000000) instead of wrapping onto an id that already exists. + // Existing rows keep their old DM-BK-/DM-TRK- strings; nothing parses + // either format, so the two coexist safely. + if res := db.Exec(`CREATE SEQUENCE IF NOT EXISTS cx_booking_reference_seq START 100000 INCREMENT 1`); res.Error != nil { + utils.Error("❌ Failed to create cx_booking_reference_seq", "error", res.Error) + } else { + utils.Info("✅ cx_booking_reference_seq ready") + } + if res := db.Exec(`CREATE SEQUENCE IF NOT EXISTS cx_tracking_seq START 10000000 INCREMENT 1`); res.Error != nil { + utils.Error("❌ Failed to create cx_tracking_seq", "error", res.Error) + } else { + utils.Info("✅ cx_tracking_seq ready") + } + + // One booking must not hold two destinations at the same position: the + // customer addresses a destination by index in + // PATCH /customer/bookings/{ref}/destinations/{index}, so a duplicate seq + // makes that route ambiguous and would let an edit land on the wrong + // address. + if res := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bookingdestinations_booking_seq + ON bookingdestinations (bookingid, seq)`); res.Error != nil { + utils.Error("❌ Failed to create bookingdestinations (bookingid, seq) unique index", "error", res.Error) + } else { + utils.Info("✅ bookingdestinations (bookingid, seq) unique index ready") + } + + // The timeline is read as "every event for this booking, oldest first" on + // every tracking poll, which is the hottest customer read there is. + if res := db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookingstageevents_booking_time + ON bookingstageevents (bookingid, occurredat)`); res.Error != nil { + utils.Error("❌ Failed to create bookingstageevents (bookingid, occurredat) index", "error", res.Error) + } else { + utils.Info("✅ bookingstageevents (bookingid, occurredat) index ready") + } + return nil } diff --git a/models/booking.go b/models/booking.go index d1d4205..f506811 100644 --- a/models/booking.go +++ b/models/booking.go @@ -83,10 +83,58 @@ type PickupBooking struct { Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` + // ── Customer-app (doormile_cx) columns ────────────────────────────────── + // All additive and nullable/zero-valued, so every existing row and every + // console-created booking stays valid without a backfill. + + // Slotid is the pickup window the customer chose, as the opaque id the app + // sent back ("slot_20260905_t1"). Preferredpickupfrom/to carry the same + // window as real times for the assignment engine; this keeps the customer's + // own choice recorded verbatim, so a slot definition edited later cannot + // silently rewrite what they picked. + Slotid string `json:"slotid" gorm:"column:slotid;size:40"` + + // Customerstage is the operational stage the customer app renders, one of + // the nine keys in constants.CxStage*. Distinct from Status, which is the + // booking lifecycle the console and the miler app read: Status stops moving + // at Converted_To_Consignment while the parcels keep going, and the two + // vocabularies are not a renaming of each other. Derived from the same + // writes, stored so a read is one row rather than a replay of the history. + Customerstage string `json:"customerstage" gorm:"column:customerstage;size:24"` + // Customerstatus is active / completed / cancelled. Derived, but sent + // explicitly — making the client infer it from a stage is how two surfaces + // end up disagreeing about whether an order is finished. + Customerstatus string `json:"customerstatus" gorm:"column:customerstatus;size:16"` + + // The estimate range the customer was actually shown at booking time, in + // whole rupees. Kept forever, including after settlement: the receipt + // renders amountPaid − estimate min as the weight adjustment, and a price + // dispute needs the number that was on screen, not a re-run of today's + // pricing rules. + Estimateminrupees int `json:"estimateminrupees" gorm:"column:estimateminrupees;default:0"` + Estimatemaxrupees int `json:"estimatemaxrupees" gorm:"column:estimatemaxrupees;default:0"` + // Routekm is the pickup→destination distance the estimate was priced on; + // it drives the route outline and the receipt. + Routekm float64 `json:"routekm" gorm:"column:routekm;default:0"` + + // Pickuptitle/Pickupsub are the two-line pickup label the customer picked + // from the place search ("12 Nehru Street" / "Gandhipuram, Coimbatore + // 641012"). Pickupaddress remains the single flat string the rest of the + // system uses; these keep the split the app renders without it having to + // re-parse one back into two. + Pickuptitle string `json:"pickuptitle" gorm:"column:pickuptitle;size:64"` + Pickupsub string `json:"pickupsub" gorm:"column:pickupsub"` + + // Cancelreason is free text or one of the app's five presets. Nullable in + // spirit — an empty string means the customer gave no reason, which is + // allowed. + Cancelreason string `json:"cancelreason" gorm:"column:cancelreason;size:120"` + // Relations Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"` ServiceOptions []BookingServiceOption `json:"serviceoptions" gorm:"foreignKey:Bookingid"` Payments []BookingPayment `json:"payments" gorm:"foreignKey:Bookingid"` + Destinations []BookingDestination `json:"destinations" gorm:"foreignKey:Bookingid"` } func (PickupBooking) TableName() string { @@ -94,8 +142,16 @@ func (PickupBooking) TableName() string { } type BookingParcel struct { - Bookingparcelid int `json:"bookingparcelid" gorm:"primaryKey;column:bookingparcelid"` - Bookingid int `json:"bookingid" gorm:"column:bookingid"` + Bookingparcelid int `json:"bookingparcelid" gorm:"primaryKey;column:bookingparcelid"` + Bookingid int `json:"bookingid" gorm:"column:bookingid"` + // Bookingdestinationid says which destination this package is going to. + // Null on console-created and pre-existing bookings, which have exactly one + // delivery address and therefore no ambiguity. On a customer-app booking it + // is what lets the miler weigh three packages for Chennai and one for + // Kochi and have each weight settle against the right order — without it, + // a multi-destination pickup has one pile of parcels and no way to say + // which parcel belongs to which tracking number. + Bookingdestinationid *int `json:"bookingdestinationid" gorm:"column:bookingdestinationid;index"` Itemcategory string `json:"itemcategory" gorm:"column:itemcategory"` Itemdescription string `json:"itemdescription" gorm:"column:itemdescription"` Declaredvalue float64 `json:"declaredvalue" gorm:"column:declaredvalue"` diff --git a/models/customer_app.go b/models/customer_app.go new file mode 100644 index 0000000..b4f33a5 --- /dev/null +++ b/models/customer_app.go @@ -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" } diff --git a/models/customer_app_test.go b/models/customer_app_test.go new file mode 100644 index 0000000..7d877ee --- /dev/null +++ b/models/customer_app_test.go @@ -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) + } +} diff --git a/routes/routes.go b/routes/routes.go index 89374ce..966419a 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -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.Post("/register", controllers.RegisterCustomer(cfg)) - customer.Post("/login", authThrottle, controllers.LoginCustomer) - customer.Post("/verify-pin", authThrottle, controllers.VerifyCustomerPin(cfg)) - customer.Post("/reset-pin", authThrottle, controllers.ResetCustomerPin) - customer.Post("/send-email-otp", authThrottle, controllers.SendCustomerEmailOtp(cfg)) - customer.Post("/verify-email-otp", authThrottle, controllers.VerifyCustomerEmailOtp()) + + // Auth — a 4-digit code to a phone or an email address, no password + // anywhere. Throttled on the shared budget with every other credential + // endpoint so an attacker cannot reset it by rotating between them. + customer.Post("/auth/otp/request", authThrottle, controllers.CxRequestOtp(cfg)) + customer.Post("/auth/signup", authThrottle, controllers.CxSignup(cfg)) + // Idempotent: the client retries on flaky networks, and a replayed verify + // must return the original session rather than mint a second one. + customer.Post("/auth/otp/verify", authThrottle, middlewares.Idempotency(), controllers.CxVerifyOtp(cfg)) + customer.Post("/auth/refresh", authThrottle, controllers.CxRefresh(cfg)) + + // Serviceability and configuration are read before sign-in: the booking + // form is explorable without an account, and gating the state picker behind + // auth would make the app's first screen a login wall. + customer.Get("/serviceability/states", controllers.GetCxStates) + customer.Get("/serviceability/states/:stateCode/districts", controllers.GetCxDistricts) + customer.Get("/pickup-slots", controllers.GetCxPickupSlots) + customer.Get("/config/booking-limits", controllers.GetCxBookingLimits) // Authenticated Customer App routes customerAuth := customer.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(9)) + + customerAuth.Get("/auth/me", controllers.CxMe) + customerAuth.Post("/auth/logout", controllers.CxLogout) + customerAuth.Get("/profile", controllers.GetCustomerProfile) customerAuth.Put("/profile", controllers.UpdateCustomerProfile) @@ -98,14 +126,38 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { customerAuth.Put("/locations/:id", controllers.UpdateCustomerLocation) customerAuth.Delete("/locations/:id", controllers.DeleteCustomerLocation) - customerAuth.Put("/device-token", controllers.SaveCustomerDeviceToken) + // Push registration. One row per device token, not one column per customer: + // a phone and a tablet both have to receive the delivery notification. + customerAuth.Post("/devices", controllers.RegisterCxDevice) + customerAuth.Delete("/devices/:token", controllers.UnregisterCxDevice) - customerAuth.Post("/bookings", middlewares.CityGateMiddleware, controllers.CreateCustomerBooking) - customerAuth.Get("/bookings", controllers.GetCustomerBookings) - customerAuth.Get("/bookings/:bookingid", controllers.GetCustomerBookingDetails) - customerAuth.Post("/bookings/:bookingid/cancel", controllers.CancelCustomerBooking) - customerAuth.Get("/bookings/:bookingid/price", controllers.GetCustomerBookingQuote) - customerAuth.Get("/track/:trackingno", controllers.TrackConsignment) + // Places are proxied, never keyed: the legacy rider app shipped a Maps key + // in the binary and it had to be revoked. The customer app is handed + // results, not credentials. + customerAuth.Get("/places/reverse-geocode", controllers.ReverseGeocodeCx(cfg)) + customerAuth.Get("/places/search", controllers.SearchCxPlaces(cfg)) + + // Called on every route and package-count change, so it is cheap and + // cacheable — and a failed estimate never blocks a booking. + customerAuth.Post("/fare/estimate", controllers.EstimateCxFare) + + // Idempotency-Key on create: the client retries over bad networks and a + // duplicate pickup is unacceptable. + customerAuth.Post("/bookings", middlewares.CityGateMiddleware, middlewares.Idempotency(), controllers.CreateCxBooking) + customerAuth.Get("/bookings", controllers.GetCxBookings) + customerAuth.Get("/bookings/:reference", controllers.GetCxBookingDetail) + customerAuth.Post("/bookings/:reference/cancel", controllers.CancelCxBooking) + customerAuth.Patch("/bookings/:reference/destinations/:index", controllers.PatchCxDestination) + + // One order by tracking number, for push deep links (doormile://track/…). + customerAuth.Get("/orders/:trackingId", controllers.GetCxOrder) + + // QA only. Refused outright unless ENV is non-production AND + // CX_ALLOW_STAGE_OVERRIDE=true — two independent switches, because either + // one being wrong in production would let any customer mark their own + // parcel delivered. It exists so every tracking state is reachable for + // design QA and the app's debug stepper can be deleted. + customerAuth.Post("/ops/bookings/:reference/stage", controllers.ForceCxStage) // -------------------- // MILER APIS diff --git a/routes/routes_customer_test.go b/routes/routes_customer_test.go new file mode 100644 index 0000000..b90882a --- /dev/null +++ b/routes/routes_customer_test.go @@ -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) + } +} diff --git a/scratch/cx_readonly_probe.go b/scratch/cx_readonly_probe.go new file mode 100644 index 0000000..387e10b --- /dev/null +++ b/scratch/cx_readonly_probe.go @@ -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) + } +} diff --git a/seed_customer_app.sql b/seed_customer_app.sql new file mode 100644 index 0000000..c112f54 --- /dev/null +++ b/seed_customer_app.sql @@ -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'); diff --git a/utils/epoch.go b/utils/epoch.go new file mode 100644 index 0000000..faafcd5 --- /dev/null +++ b/utils/epoch.go @@ -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")) +} diff --git a/utils/epoch_test.go b/utils/epoch_test.go new file mode 100644 index 0000000..9c033b5 --- /dev/null +++ b/utils/epoch_test.go @@ -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) + } +} diff --git a/utils/helper.go b/utils/helper.go index 5ce166d..f37299f 100644 --- a/utils/helper.go +++ b/utils/helper.go @@ -80,7 +80,18 @@ type Claims struct { } func GenerateToken(userID int, email string, roleID int, tenantID int, configID int, secret string) (string, error) { - expirationTime := time.Now().Add(24 * time.Hour) + return GenerateTokenWithTTL(userID, email, roleID, tenantID, configID, secret, 24*time.Hour) +} + +// GenerateTokenWithTTL is GenerateToken with an explicit lifetime. +// +// The customer app pairs a short access token with a long-lived refresh token, +// so a stolen access token expires in an hour rather than a day, while the +// customer still stays signed in for months. The miler and console surfaces +// keep the 24-hour default: they have no refresh endpoint, and shortening their +// token would sign a rider out mid-shift. +func GenerateTokenWithTTL(userID int, email string, roleID int, tenantID int, configID int, secret string, ttl time.Duration) (string, error) { + expirationTime := time.Now().Add(ttl) claims := &Claims{ UserID: userID, Email: email, diff --git a/utils/response_cx.go b/utils/response_cx.go new file mode 100644 index 0000000..f90407e --- /dev/null +++ b/utils/response_cx.go @@ -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") +}