backend requirements onthe xustomer app

This commit is contained in:
2026-09-07 10:55:11 +05:30
parent 35675d8a9b
commit 1b2690b21a
56 changed files with 13342 additions and 1071 deletions

147
CLAUDE.md
View File

@@ -90,12 +90,14 @@ Five distinct front doors into this system. Only the backend API surface for
each has been directly inspected this session (via `routes.go`); the actual each has been directly inspected this session (via `routes.go`); the actual
client codebases (Flutter, React) have not been opened in this session. client codebases (Flutter, React) have not been opened in this session.
1. **Customer app (B2C)** — Flutter. Auth via Firebase OTP (phone). Backend 1. **Customer app (B2C)** — Flutter, being replaced: `doormile_customer_app`
surface: 19 customer routes **[verified this session]** (PIN auth, single-destination bookings) is retired in favour of
(`customer`/`customerAuth` groups) — booking creation, tracking, `doormile_cx`. Backend surface rebuilt 2026-09-05 to the Customer App v1
`AppCustomer`/`AppCustomerLocation`. **[carried forward]**: reported built contract: **28 customer routes** (`customer`/`customerAuth` groups) — OTP
and verified in prior sessions; the last live end-to-end test was auth with refresh, serviceability/slots/limits, place proxy, fare estimate,
blocked here — see §9. multi-destination pickups, per-order tracking, push devices. See §8.5. Auth
is a 4-digit OTP to phone or email, NOT Firebase and NOT the miler PIN flow;
the SMS gateway is still unplugged, which is the same wall §9 describes.
2. **Miler app** — Flutter, for delivery riders. Backend surface: 38 routes 2. **Miler app** — Flutter, for delivery riders. Backend surface: 38 routes
**[verified this session]** (`miler`/`milerAuth` groups) — duty **[verified this session]** (`miler`/`milerAuth` groups) — duty
start/stop, GPS pings, assignment accept/reject/cancel, delivery start/stop, GPS pings, assignment accept/reject/cancel, delivery
@@ -462,6 +464,139 @@ has not run against a real DB. `go build`, `go vet` and `go test ./...` all pass
--- ---
## 8.5 Customer app v1 — the `/customer/*` rebuild (2026-09-05)
**[verified this session]** — implements *Doormile — Backend Requirements
(Customer App v1)* for the new `doormile_cx` Flutter client. Full contract,
decisions and the written answers to the requirement doc's open questions:
[`docs/customer-app-api.md`](docs/customer-app-api.md). Spec:
[`docs/openapi-customer.yaml`](docs/openapi-customer.yaml).
**The structural change: a customer books a PICKUP, not a shipment.** One
booking → 1..N destinations → one consignment and one tracking number per
destination, minted when the miler completes the pickup. `pickupbookings`
carried exactly one delivery address in its own columns, so there was nowhere to
put a second; `bookingdestinations` is what closes that.
**The compatibility rule that makes it safe — do not break it:** destination 0
is mirrored onto the booking's flat `delivery*` columns. The miler app, the hub
console, the routing code and the hyperlocal check all read those columns and
none of them changed. A booking with **no** destination rows (every
console/express booking, every pre-existing row) produces exactly one
consignment through the same loop, byte-for-byte as before. Single-destination
is one leg, never a special case.
**Two prior surfaces were replaced, on Suriya's call (2026-09-05).** The PIN
auth (`/customer/register|login|verify-pin|reset-pin`, plus the email-OTP pair)
and the single-destination booking create/list/detail/cancel/price and
`/customer/track/:trackingno` are gone — `doormile_customer_app` is being
retired in favour of `doormile_cx`. `controllers/otpController.go` was deleted
with them. Customer routes: 19 → 28.
**Identifier formats changed platform-wide.** `generateBookingNo()` now mints
`DM-######` and `generateTrackingNo()` mints `DMX########`, both off Postgres
sequences (`cx_booking_reference_seq`, `cx_tracking_seq`, created in
`migrations/migrate.go`). The old generators used four random bytes; both
columns are `UNIQUE` and a random short id collides long before the space runs
out. Existing rows keep their `DM-BK-`/`DM-TRK-` strings — nothing parses either
format, so the two coexist and the console just shows the new one for new work.
### Conventions added — reuse these, don't reimplement
- **`utils.CxOK` / `CxCreated` / `CxList` / `CxFail`** (`utils/response_cx.go`)
are the ONLY response helpers for `/customer/*`. Deliberately separate from
`utils.OK`/`Fail`: the customer contract always sends `message` (empty on
success) and nests the code under `error.code`, while miler/console put `code`
at the top level. Never mix them on one surface.
- **`utils.EpochMillis(t)`** (`utils/epoch.go`) is the ONLY way a timestamp
leaves `/customer/*`. This DB stores IST wall-clock digits (see `DBNow`), so
`t.UnixMilli()` is off by 5h30m — the same defect that produced "yesterday's
work shown as today" on the miler app. `utils/epoch_test.go` asserts it for
both taggings the driver can produce.
- **`internal/cxstage`** is the ONE place a customer stage is written. `Record`
takes the caller's `*gorm.DB` — a stage event must commit or roll back with
the operational write it describes. It dedupes per (booking, destination,
stage), and `Notify` fires only after commit.
- **`renderCxBooking` + `loadCxBundle`** (`controllers/cxBookingView.go`) build
the canonical booking object. Every read that returns a booking goes through
them; `loadCxBundle` is a fixed number of queries regardless of page size.
- **`cxDestinationForConsignment(id)`** resolves a consignment to its booking.
Use it instead of `WHERE consignmentid = ?` on `pickupbookings` — that column
names only the FIRST order of a multi-destination pickup (see the bugs below).
- **`cxPickupLegs(tx, booking)`** splits a booking into the journeys to create
at pickup-complete. It is what decides single-vs-fan-out; nothing downstream
needs to know which it got.
### Stage derivation (the actual work)
Nine stages, lowercase snake_case, in `constants.CxStage*`. The client parses
them verbatim and **silently falls back to `booked` on an unknown key** — never
add or rename one without a client release. A booking rolls up from its
**slowest** order once parcels split, or a customer sees "Delivered" while a
parcel is still at a hub. Nothing is backfilled: a pre-existing booking gets a
short honest history rather than an invented one.
`cxstage.Release` is the one place a stage moves **backwards** — a miler
cancelling returns the pickup to the pool rather than cancelling it, and without
walking the stage back the customer keeps seeing a rider who is not coming.
### Four pre-existing bugs fixed in passing
All the same root cause, all found because the fan-out forced every consignment
lookup to be re-read. Each would have broken multi-destination pickups outright:
1. **`MilerDeliverConsignment` could not close orders 2..N** — its ownership
check was `WHERE consignmentid = ? AND assignedmileruserid = ?` on
`pickupbookings`, so a rider delivering the second parcel of a three-stop
visit got "assigned consignment not found" and could not complete at all.
2. **`MilerStartDelivery` notified nobody for orders 2..N** — same join, so no
push and no receiver OTP.
3. **`MilerInwardConsignmentAtHub` left assignments open for orders 2..N** — the
rider could not go off duty (`MilerEndDuty` refuses on an open assignment)
and the leg's distance/earnings recorded as zero.
4. **`GET /miler/bookings` showed only the first order** — one row per booking
keyed on that same column, so the fan-out would have minted orders no rider
could see or deliver. `milerStopsForBooking` now emits one stop per order
after collection, one visit before it, and exactly one row (unchanged) for a
booking with no destination rows.
Also: **`CityGateMiddleware` was a no-op for customer bookings.** It sniffs the
body for `pickuppincode`, which the new request shape does not carry, so every
customer booking sailed past the operating-city gate. Now checked in the handler
via the exported `middlewares.PincodeInOperatingCity`.
### Blockers and gaps — state these plainly if asked
- **No SMS provider exists.** `internal/sms` is the seam (a `Sender` interface,
a logging sink, `sms.Register()`); until a gateway is plugged in, OTP codes go
to the application log and nowhere else. **This is the single blocker on real
customer sign-in** — and it is the same wall §9's E2E test hit. Staging has
`CX_STAGING_OTP` (refused when `ENV=production`), which unblocks automated
tests.
- **No integration test has hit any of these endpoints.** `go build`, `go vet`
and `go test ./...` pass; new unit tests cover the pure logic (stage rollup,
epoch conversion, phone normalisation, weight fallback). None of that proves
behaviour against a real DB/Redis/NATS.
- **The migration has not run against a real database.** Additive, so it should
be safe — but that is not the same as having run.
- **Failed delivery is invisible to the customer.** `MilerSkipDelivery` works
operationally, but there is no tenth stage key for it and an unknown key
renders as `booked`, so a failed attempt leaves the parcel showing "Out for
delivery". Needs product + a client release.
- **Latency (p95 ≤ 400ms) is unmeasured.** Reads are batched and pricing is
Redis-warmed, but that is an argument, not a measurement.
- **No retention policy** for parcel photos or PII — nothing prunes either. The
30-minute signed-URL TTL limits link lifetime, not object lifetime.
### New env vars
`GEOCODER_URL`, `GEOCODER_EMAIL` (place proxy — the app is never handed a map
key, after the legacy rider app's key had to be revoked), `MILER_CALL_PROXY`
(masked calling; empty exposes the rider's real number — **set before launch**),
`CX_STAGING_OTP`, `CX_ALLOW_STAGE_OVERRIDE`.
---
## 9. Current blockers & open work (whole-project level) ## 9. Current blockers & open work (whole-project level)
**[carried forward]** **[carried forward]**

View File

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

92
config/config_test.go Normal file
View File

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

View File

@@ -168,3 +168,74 @@ const (
ExceptionResolved = "Resolved" ExceptionResolved = "Resolved"
ExceptionClosed = "Closed" ExceptionClosed = "Closed"
) )
// Customer-app stages. Nine operational stages, spelt exactly as the customer
// client parses them: lowercase snake_case, on the wire verbatim. The client
// rolls these up into seven milestones itself and falls back to "booked" on an
// unknown key, silently — so adding a value here without an app release makes a
// parcel look un-started. Never rename one; add and coordinate.
//
// Stages 0-5 belong to the booking. Stages 6-8 belong to each order and may
// differ between destinations of the same booking.
const (
CxStageBooked = "booked" // 0 — pickup requested
CxStageAssigned = "assigned" // 1 — a miler accepted it
CxStageOnTheWay = "on_the_way" // 2 — rider en route, distance/ETA live
CxStageArrived = "arrived" // 3 — rider at the door; LAST cancellable stage
CxStagePickedUp = "picked_up" // 4 — weighed, photographed, price settled
CxStageOrderCreated = "order_created" // 5 — one tracking number minted per destination
CxStageInTransit = "in_transit" // 6 — per order from here on
CxStageOutForDelivery = "out_for_delivery" // 7 — delivery agent carrying it
CxStageDelivered = "delivered" // 8 — handed over
)
// Customer-facing booking status. Derived from the stage but sent explicitly,
// because a client that has to infer it will eventually infer it differently.
const (
CxStatusActive = "active"
CxStatusCompleted = "completed"
CxStatusCancelled = "cancelled"
)
// Who caused a stage transition. Recorded on every bookingstageevents row: the
// customer timeline is derived from that table, so it has to be real, and a
// cancellation the customer did not make is unexplainable without this.
const (
CxActorMiler = "miler"
CxActorOps = "ops"
CxActorCustomer = "customer"
CxActorSystem = "system"
)
// CxStageOrder is the rank of each stage, used to decide whether a transition
// moves forward and whether cancellation is still open. Cancellation closes
// after arrived, so anything at or past picked_up is refused.
var CxStageOrder = map[string]int{
CxStageBooked: 0,
CxStageAssigned: 1,
CxStageOnTheWay: 2,
CxStageArrived: 3,
CxStagePickedUp: 4,
CxStageOrderCreated: 5,
CxStageInTransit: 6,
CxStageOutForDelivery: 7,
CxStageDelivered: 8,
}
// CxStageRank returns the rank of a stage, or -1 when the stage is unknown or
// empty. A booking written before this surface existed has no stage at all,
// and -1 keeps it strictly behind every real stage rather than tying with
// "booked".
func CxStageRank(stage string) int {
if r, ok := CxStageOrder[stage]; ok {
return r
}
return -1
}
// CxCancellable reports whether a booking at this stage may still be cancelled.
// The UI mirrors this to hide the button, but the server re-checks on the
// cancel call — the button state is a hint, never the authority.
func CxCancellable(stage string) bool {
return CxStageRank(stage) <= CxStageOrder[CxStageArrived]
}

116
constants/constants_test.go Normal file
View File

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

View File

@@ -15,6 +15,7 @@ import (
"doormile/db" "doormile/db"
"doormile/dto" "doormile/dto"
"doormile/internal/assignment" "doormile/internal/assignment"
"doormile/internal/cxstage"
"doormile/internal/notify" "doormile/internal/notify"
"doormile/models" "doormile/models"
"doormile/utils" "doormile/utils"
@@ -2770,6 +2771,18 @@ func AdminCancelBooking(c *fiber.Ctx) error {
return utils.Internal(c, "failed to cancel booking") return utils.Internal(c, "failed to cancel booking")
} }
// Tell the customer's projection too. Without this the pickup keeps
// rendering as active and cancellable in the customer app, because
// customerstatus was written as "active" at booking time and nothing here
// ever moved it. Best-effort and outside the save above: an ops cancel that
// has already committed must not be reported as failed because the
// customer-side write did not land. No-op for console-created bookings.
if err := cxstage.Cancel(db.DB, booking.Bookingid, "Cancelled by Doormile operations",
constants.CxActorOps, opsActorID(c), "POST /admin/bookings/{id}/cancel"); err != nil {
utils.Error("AdminCancelBooking: could not update the customer projection",
"booking_id", booking.Bookingid, "error", err)
}
if booking.Assignedmileruserid != nil { if booking.Assignedmileruserid != nil {
db.DB.Model(&models.MilerProfile{}). db.DB.Model(&models.MilerProfile{}).
Where("userid = ?", *booking.Assignedmileruserid). Where("userid = ?", *booking.Assignedmileruserid).
@@ -2849,6 +2862,15 @@ func AdminBulkCancelBookings(c *fiber.Ctx) error {
continue continue
} }
// Same reason as AdminCancelBooking: without this the pickup keeps
// rendering as active and cancellable in the customer app. No-op for
// console-created bookings.
if err := cxstage.Cancel(db.DB, booking.Bookingid, "Cancelled by Doormile operations",
constants.CxActorOps, opsActorID(c), "POST /admin/bookings/bulk-cancel"); err != nil {
utils.Error("AdminBulkCancelBookings: could not update the customer projection",
"booking_id", booking.Bookingid, "error", err)
}
if booking.Assignedmileruserid != nil { if booking.Assignedmileruserid != nil {
db.DB.Model(&models.MilerProfile{}). db.DB.Model(&models.MilerProfile{}).
Where("userid = ?", *booking.Assignedmileruserid). Where("userid = ?", *booking.Assignedmileruserid).
@@ -3895,3 +3917,13 @@ func InternalReassign(c *fiber.Ctx) error {
"booking_id": booking.Bookingid, "booking_id": booking.Bookingid,
}) })
} }
// opsActorID returns the console user behind an ops action, for the customer's
// audit trail. Nil when the request carries no user id, which is a legitimate
// state for an internal caller rather than something to fail on.
func opsActorID(c *fiber.Ctx) *int {
if uid, ok := c.Locals("userid").(int); ok && uid != 0 {
return &uid
}
return nil
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

713
controllers/cxHttp_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -443,8 +443,13 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
// the base gate — and ridercharges the order amount, both written the same way // the base gate — and ridercharges the order amount, both written the same way
// MilerDeliverConsignment writes them for a final-mile leg. Without this an // MilerDeliverConsignment writes them for a final-mile leg. Without this an
// intercity rider's every job reported zero distance and zero value. // intercity rider's every job reported zero distance and zero value.
var booking models.PickupBooking // Resolved through bookingdestinations, not through
if tx.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil { // pickupbookings.consignmentid. That column names only the FIRST order of a
// multi-destination pickup, so joining on it found nothing for orders 2..N
// — and an intercity rider handing in the second parcel of a three-stop
// pickup had their assignment left open and their distance recorded as zero.
if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil {
booking := *bookingPtr
dropLat, dropLon := lat, lon dropLat, dropLon := lat, lon
if dropLat == 0 && dropLon == 0 { if dropLat == 0 && dropLon == 0 {
dropLat, dropLon = hub.Latitude, hub.Longitude dropLat, dropLon = hub.Latitude, hub.Longitude
@@ -479,10 +484,25 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
return utils.Internal(c, "failed to update miler availability") return utils.Internal(c, "failed to update miler availability")
} }
// The customer's "In transit" milestone. Recorded against THIS order, not
// the booking, because the other parcels from the same visit may still be
// in the rider's hands.
notifyInTransit, err := recordCxConsignmentStage(tx, consignment.Consignmentid,
constants.ConsignmentInwardedAtHub, constants.CxActorMiler, &milerUserID,
"POST /miler/consignments/{id}/inward-at-hub")
if err != nil {
tx.Rollback()
utils.Error("MilerInwardConsignmentAtHub: could not record in_transit",
"consignment_id", consignment.Consignmentid, "error", err)
return utils.Internal(c, "failed to record the handover")
}
if err := tx.Commit().Error; err != nil { if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to record the handover") return utils.Internal(c, "failed to record the handover")
} }
notifyInTransit()
// Best-effort, on an already-bound subject — a dropped event must never fail // Best-effort, on an already-bound subject — a dropped event must never fail
// a handover the rider has physically completed. // a handover the rider has physically completed.
if db.Js != nil { if db.Js != nil {

View File

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

View File

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

View File

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

View File

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

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

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

1495
docs/openapi-customer.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

@@ -167,6 +167,13 @@ func main() {
AllowMethods: "GET,POST,PUT,DELETE,PATCH,OPTIONS", AllowMethods: "GET,POST,PUT,DELETE,PATCH,OPTIONS",
})) }))
// Echo X-Request-Id on every response, errors included, minting one when the
// caller did not send it. The customer app funnels every failure into a
// single retryable error state, so without this a support conversation
// about "it failed" has nothing to correlate against the logs. Registered
// before the logger so the id is available to it.
app.Use(middlewares.RequestID())
// Structured Zap logger middleware // Structured Zap logger middleware
app.Use(middlewares.ZapLogger()) app.Use(middlewares.ZapLogger())

View File

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

View File

@@ -2,6 +2,8 @@ package middlewares
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@@ -37,8 +39,7 @@ func Idempotency() fiber.Handler {
if key == "" || db.Rdb == nil { if key == "" || db.Rdb == nil {
return c.Next() return c.Next()
} }
uid, _ := c.Locals("userid").(int) base := "idem:" + idempotencyScope(c) + ":" + key
base := fmt.Sprintf("idem:%d:%s", uid, key)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
@@ -80,3 +81,32 @@ func Idempotency() fiber.Handler {
return nil return nil
} }
} }
// idempotencyScope namespaces a key so one caller's stored response can never
// be replayed to another.
//
// For an authenticated request the caller's own user id is the scope, which is
// what this middleware has always used.
//
// An UNAUTHENTICATED request has no user id, and defaulting to 0 would put
// every anonymous caller in one namespace: two customers who happened to pick
// the same Idempotency-Key on POST /customer/auth/otp/verify would collide, and
// the second would be handed the first's access token, refresh token and
// customer record. So an anonymous request is scoped by the request path and a
// hash of its body instead — the same body replays, a different body does not,
// and one person's session can never be served to another.
// The authenticated form is byte-identical to what this middleware has always
// produced ("idem:<uid>:<key>"). Changing it would orphan every in-flight key
// in Redis at deploy time, and a rider retrying a pickup-complete across that
// boundary would execute it a second time instead of replaying — the exact
// double-collection this middleware exists to prevent.
func idempotencyScope(c *fiber.Ctx) string {
if uid, ok := c.Locals("userid").(int); ok && uid != 0 {
return fmt.Sprintf("%d", uid)
}
// Anonymous callers get their own namespace shape, which no previous key
// can collide with: every key written before this change had a numeric
// scope, and this one never is.
sum := sha256.Sum256(append([]byte(c.Path()+"\x00"), c.Body()...))
return "anon-" + hex.EncodeToString(sum[:16])
}

View File

@@ -0,0 +1,122 @@
package middlewares
import (
"net/http/httptest"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
)
// The idempotency key namespace is a security boundary, not bookkeeping.
//
// POST /customer/auth/otp/verify is UNAUTHENTICATED, so c.Locals("userid") is
// absent there. Scoping on it anyway put every anonymous caller in one
// namespace: two customers picking the same Idempotency-Key would collide and
// the second would be handed the first's access token, refresh token and
// customer record. These tests pin the fix.
// scopeFor runs idempotencyScope inside a real request and returns what it
// produced.
func scopeFor(t *testing.T, authedUserID int, path, body string) string {
t.Helper()
app := fiber.New(fiber.Config{DisableStartupMessage: true})
app.Post("/*", func(c *fiber.Ctx) error {
if authedUserID != 0 {
c.Locals("userid", authedUserID)
}
return c.SendString(idempotencyScope(c))
})
req := httptest.NewRequest("POST", path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, 5000)
if err != nil {
t.Fatalf("test request: %v", err)
}
defer resp.Body.Close()
buf := make([]byte, 256)
n, _ := resp.Body.Read(buf)
return string(buf[:n])
}
// Two anonymous callers sending DIFFERENT bodies must never share a namespace,
// even with an identical Idempotency-Key. This is the session-handover bug.
func TestAnonymousCallersNeverShareAnIdempotencyNamespace(t *testing.T) {
alice := scopeFor(t, 0, "/customer/auth/otp/verify",
`{"identifier":"+919876543210","code":"1111"}`)
bob := scopeFor(t, 0, "/customer/auth/otp/verify",
`{"identifier":"+919000000001","code":"2222"}`)
if alice == bob {
t.Fatalf("two different anonymous requests share the scope %q — "+
"one customer's session could be replayed to another", alice)
}
if alice == "" || bob == "" {
t.Fatal("empty scope: every request must land in some namespace")
}
}
// The same anonymous caller repeating the SAME request must replay — that is
// the whole point of idempotency.
func TestIdenticalAnonymousRequestReplays(t *testing.T) {
body := `{"identifier":"+919876543210","code":"4821"}`
first := scopeFor(t, 0, "/customer/auth/otp/verify", body)
again := scopeFor(t, 0, "/customer/auth/otp/verify", body)
if first != again {
t.Errorf("the same request produced two scopes (%q, %q) — a retry would "+
"re-execute instead of replaying", first, again)
}
}
// The authenticated format is byte-identical to what this middleware has always
// produced. Changing it would orphan every in-flight key in Redis at deploy
// time, and a rider retrying a pickup-complete across that boundary would
// collect COD twice instead of replaying.
func TestAuthenticatedScopeFormatIsUnchanged(t *testing.T) {
got := scopeFor(t, 42, "/miler/bookings/7/pickup-complete", `{}`)
if got != "42" {
t.Errorf("authenticated scope = %q, want %q — the stored key format must "+
"not change across a deploy", got, "42")
}
}
// An anonymous scope can never collide with an authenticated one: the old
// format is always numeric, the new one never is.
func TestAnonymousScopeCannotCollideWithAnAuthenticatedOne(t *testing.T) {
anon := scopeFor(t, 0, "/customer/auth/otp/verify", `{"code":"1"}`)
if !strings.HasPrefix(anon, "anon-") {
t.Errorf("anonymous scope = %q, want an 'anon-' prefix so it cannot look "+
"like a user id", anon)
}
}
// The operating-city gate, exported because the customer booking shape carries
// no pincode for CityGateMiddleware to sniff.
func TestPincodeInOperatingCity(t *testing.T) {
cases := []struct {
pincode string
wantCity string
wantOK bool
}{
{"641012", "Coimbatore", true},
{"600001", "Chennai", true},
{"560034", "Bengaluru", true},
{"500081", "Hyderabad", true},
{"629001", "Nagercoil", true},
{"110001", "", false}, // Delhi — not an operating city
{"12", "", false}, // too short to classify
{"", "", false},
}
for _, tc := range cases {
city, ok := PincodeInOperatingCity(tc.pincode)
if ok != tc.wantOK || city != tc.wantCity {
t.Errorf("PincodeInOperatingCity(%q) = (%q, %v), want (%q, %v)",
tc.pincode, city, ok, tc.wantCity, tc.wantOK)
}
}
}

View File

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

33
middlewares/requestid.go Normal file
View File

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

View File

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

View File

@@ -83,10 +83,58 @@ type PickupBooking struct {
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
// ── Customer-app (doormile_cx) columns ──────────────────────────────────
// All additive and nullable/zero-valued, so every existing row and every
// console-created booking stays valid without a backfill.
// Slotid is the pickup window the customer chose, as the opaque id the app
// sent back ("slot_20260905_t1"). Preferredpickupfrom/to carry the same
// window as real times for the assignment engine; this keeps the customer's
// own choice recorded verbatim, so a slot definition edited later cannot
// silently rewrite what they picked.
Slotid string `json:"slotid" gorm:"column:slotid;size:40"`
// Customerstage is the operational stage the customer app renders, one of
// the nine keys in constants.CxStage*. Distinct from Status, which is the
// booking lifecycle the console and the miler app read: Status stops moving
// at Converted_To_Consignment while the parcels keep going, and the two
// vocabularies are not a renaming of each other. Derived from the same
// writes, stored so a read is one row rather than a replay of the history.
Customerstage string `json:"customerstage" gorm:"column:customerstage;size:24"`
// Customerstatus is active / completed / cancelled. Derived, but sent
// explicitly — making the client infer it from a stage is how two surfaces
// end up disagreeing about whether an order is finished.
Customerstatus string `json:"customerstatus" gorm:"column:customerstatus;size:16"`
// The estimate range the customer was actually shown at booking time, in
// whole rupees. Kept forever, including after settlement: the receipt
// renders amountPaid − estimate min as the weight adjustment, and a price
// dispute needs the number that was on screen, not a re-run of today's
// pricing rules.
Estimateminrupees int `json:"estimateminrupees" gorm:"column:estimateminrupees;default:0"`
Estimatemaxrupees int `json:"estimatemaxrupees" gorm:"column:estimatemaxrupees;default:0"`
// Routekm is the pickup→destination distance the estimate was priced on;
// it drives the route outline and the receipt.
Routekm float64 `json:"routekm" gorm:"column:routekm;default:0"`
// Pickuptitle/Pickupsub are the two-line pickup label the customer picked
// from the place search ("12 Nehru Street" / "Gandhipuram, Coimbatore
// 641012"). Pickupaddress remains the single flat string the rest of the
// system uses; these keep the split the app renders without it having to
// re-parse one back into two.
Pickuptitle string `json:"pickuptitle" gorm:"column:pickuptitle;size:64"`
Pickupsub string `json:"pickupsub" gorm:"column:pickupsub"`
// Cancelreason is free text or one of the app's five presets. Nullable in
// spirit — an empty string means the customer gave no reason, which is
// allowed.
Cancelreason string `json:"cancelreason" gorm:"column:cancelreason;size:120"`
// Relations // Relations
Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"` Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"`
ServiceOptions []BookingServiceOption `json:"serviceoptions" gorm:"foreignKey:Bookingid"` ServiceOptions []BookingServiceOption `json:"serviceoptions" gorm:"foreignKey:Bookingid"`
Payments []BookingPayment `json:"payments" gorm:"foreignKey:Bookingid"` Payments []BookingPayment `json:"payments" gorm:"foreignKey:Bookingid"`
Destinations []BookingDestination `json:"destinations" gorm:"foreignKey:Bookingid"`
} }
func (PickupBooking) TableName() string { func (PickupBooking) TableName() string {
@@ -94,8 +142,16 @@ func (PickupBooking) TableName() string {
} }
type BookingParcel struct { type BookingParcel struct {
Bookingparcelid int `json:"bookingparcelid" gorm:"primaryKey;column:bookingparcelid"` Bookingparcelid int `json:"bookingparcelid" gorm:"primaryKey;column:bookingparcelid"`
Bookingid int `json:"bookingid" gorm:"column:bookingid"` Bookingid int `json:"bookingid" gorm:"column:bookingid"`
// Bookingdestinationid says which destination this package is going to.
// Null on console-created and pre-existing bookings, which have exactly one
// delivery address and therefore no ambiguity. On a customer-app booking it
// is what lets the miler weigh three packages for Chennai and one for
// Kochi and have each weight settle against the right order — without it,
// a multi-destination pickup has one pile of parcels and no way to say
// which parcel belongs to which tracking number.
Bookingdestinationid *int `json:"bookingdestinationid" gorm:"column:bookingdestinationid;index"`
Itemcategory string `json:"itemcategory" gorm:"column:itemcategory"` Itemcategory string `json:"itemcategory" gorm:"column:itemcategory"`
Itemdescription string `json:"itemdescription" gorm:"column:itemdescription"` Itemdescription string `json:"itemdescription" gorm:"column:itemdescription"`
Declaredvalue float64 `json:"declaredvalue" gorm:"column:declaredvalue"` Declaredvalue float64 `json:"declaredvalue" gorm:"column:declaredvalue"`

241
models/customer_app.go Normal file
View File

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

175
models/customer_app_test.go Normal file
View File

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

View File

@@ -78,18 +78,46 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
}) })
// -------------------- // --------------------
// CUSTOMER APIS // CUSTOMER APIS — doormile_cx
// -------------------- // --------------------
// The customer books a PICKUP, not a shipment: one visit, 1..N
// destinations, and a tracking number per destination minted only when the
// miler completes the pickup. Everything under /customer/* answers in the
// customer envelope ({success, data, message}; errors carry error.code) —
// auth included, deliberately unlike /miler/verify-pin, whose payload sits
// outside `data` and cost the miler client a release to discover.
//
// This replaces the PIN-based customer surface (register / login /
// verify-pin / reset-pin, and the single-destination booking create, list,
// detail and cancel). Those were retired rather than moved: a booking with
// one delivery address in its own columns is not a shape the product has
// any more.
customer := api.Group("/customer") customer := api.Group("/customer")
customer.Post("/register", controllers.RegisterCustomer(cfg))
customer.Post("/login", authThrottle, controllers.LoginCustomer) // Auth — a 4-digit code to a phone or an email address, no password
customer.Post("/verify-pin", authThrottle, controllers.VerifyCustomerPin(cfg)) // anywhere. Throttled on the shared budget with every other credential
customer.Post("/reset-pin", authThrottle, controllers.ResetCustomerPin) // endpoint so an attacker cannot reset it by rotating between them.
customer.Post("/send-email-otp", authThrottle, controllers.SendCustomerEmailOtp(cfg)) customer.Post("/auth/otp/request", authThrottle, controllers.CxRequestOtp(cfg))
customer.Post("/verify-email-otp", authThrottle, controllers.VerifyCustomerEmailOtp()) customer.Post("/auth/signup", authThrottle, controllers.CxSignup(cfg))
// Idempotent: the client retries on flaky networks, and a replayed verify
// must return the original session rather than mint a second one.
customer.Post("/auth/otp/verify", authThrottle, middlewares.Idempotency(), controllers.CxVerifyOtp(cfg))
customer.Post("/auth/refresh", authThrottle, controllers.CxRefresh(cfg))
// Serviceability and configuration are read before sign-in: the booking
// form is explorable without an account, and gating the state picker behind
// auth would make the app's first screen a login wall.
customer.Get("/serviceability/states", controllers.GetCxStates)
customer.Get("/serviceability/states/:stateCode/districts", controllers.GetCxDistricts)
customer.Get("/pickup-slots", controllers.GetCxPickupSlots)
customer.Get("/config/booking-limits", controllers.GetCxBookingLimits)
// Authenticated Customer App routes // Authenticated Customer App routes
customerAuth := customer.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(9)) customerAuth := customer.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(9))
customerAuth.Get("/auth/me", controllers.CxMe)
customerAuth.Post("/auth/logout", controllers.CxLogout)
customerAuth.Get("/profile", controllers.GetCustomerProfile) customerAuth.Get("/profile", controllers.GetCustomerProfile)
customerAuth.Put("/profile", controllers.UpdateCustomerProfile) customerAuth.Put("/profile", controllers.UpdateCustomerProfile)
@@ -98,14 +126,38 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
customerAuth.Put("/locations/:id", controllers.UpdateCustomerLocation) customerAuth.Put("/locations/:id", controllers.UpdateCustomerLocation)
customerAuth.Delete("/locations/:id", controllers.DeleteCustomerLocation) customerAuth.Delete("/locations/:id", controllers.DeleteCustomerLocation)
customerAuth.Put("/device-token", controllers.SaveCustomerDeviceToken) // Push registration. One row per device token, not one column per customer:
// a phone and a tablet both have to receive the delivery notification.
customerAuth.Post("/devices", controllers.RegisterCxDevice)
customerAuth.Delete("/devices/:token", controllers.UnregisterCxDevice)
customerAuth.Post("/bookings", middlewares.CityGateMiddleware, controllers.CreateCustomerBooking) // Places are proxied, never keyed: the legacy rider app shipped a Maps key
customerAuth.Get("/bookings", controllers.GetCustomerBookings) // in the binary and it had to be revoked. The customer app is handed
customerAuth.Get("/bookings/:bookingid", controllers.GetCustomerBookingDetails) // results, not credentials.
customerAuth.Post("/bookings/:bookingid/cancel", controllers.CancelCustomerBooking) customerAuth.Get("/places/reverse-geocode", controllers.ReverseGeocodeCx(cfg))
customerAuth.Get("/bookings/:bookingid/price", controllers.GetCustomerBookingQuote) customerAuth.Get("/places/search", controllers.SearchCxPlaces(cfg))
customerAuth.Get("/track/:trackingno", controllers.TrackConsignment)
// Called on every route and package-count change, so it is cheap and
// cacheable — and a failed estimate never blocks a booking.
customerAuth.Post("/fare/estimate", controllers.EstimateCxFare)
// Idempotency-Key on create: the client retries over bad networks and a
// duplicate pickup is unacceptable.
customerAuth.Post("/bookings", middlewares.CityGateMiddleware, middlewares.Idempotency(), controllers.CreateCxBooking)
customerAuth.Get("/bookings", controllers.GetCxBookings)
customerAuth.Get("/bookings/:reference", controllers.GetCxBookingDetail)
customerAuth.Post("/bookings/:reference/cancel", controllers.CancelCxBooking)
customerAuth.Patch("/bookings/:reference/destinations/:index", controllers.PatchCxDestination)
// One order by tracking number, for push deep links (doormile://track/…).
customerAuth.Get("/orders/:trackingId", controllers.GetCxOrder)
// QA only. Refused outright unless ENV is non-production AND
// CX_ALLOW_STAGE_OVERRIDE=true — two independent switches, because either
// one being wrong in production would let any customer mark their own
// parcel delivered. It exists so every tracking state is reachable for
// design QA and the app's debug stepper can be deleted.
customerAuth.Post("/ops/bookings/:reference/stage", controllers.ForceCxStage)
// -------------------- // --------------------
// MILER APIS // MILER APIS

View File

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

View File

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

170
seed_customer_app.sql Normal file
View File

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

103
utils/epoch.go Normal file
View File

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

116
utils/epoch_test.go Normal file
View File

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

View File

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

97
utils/response_cx.go Normal file
View File

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