Merge branch 'main' of https://gitapp.workolik.com/Doormile/doormile_backend
# Conflicts: # docs/DEV_ONBOARDING.md
This commit is contained in:
16
.env
16
.env
@@ -29,3 +29,19 @@ DO_SPACES_BUCKET=nearle
|
||||
DO_SPACES_ACCESS_KEY=DO00NQER7N2FRYZAB2HR
|
||||
DO_SPACES_SECRET_KEY=nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0
|
||||
DO_SPACES_CDN_BASE=https://images.nearle.app
|
||||
|
||||
# Customer-app sign-in (QA).
|
||||
#
|
||||
# A FIXED verification code accepted for every identifier, in place of a real
|
||||
# SMS. It exists because internal/sms has no Sender registered -- sms.Register()
|
||||
# has no callers -- so every OTP is written to the application log and no text
|
||||
# is ever delivered. Without this nobody can sign into the customer app at all.
|
||||
#
|
||||
# internal/sms/sms.go StagingCode() refuses this outright when ENV=production
|
||||
# and logs an error instead, because a fixed code accepts a login for EVERY
|
||||
# account on the platform. ENV is currently "development" above, so the guard
|
||||
# does NOT fire -- this code is live wherever these values are deployed.
|
||||
#
|
||||
# Remove it, or set ENV=production, before real customers exist. Registering a
|
||||
# real SMS gateway is the actual fix; this is scaffolding.
|
||||
CX_STAGING_OTP=1234
|
||||
|
||||
@@ -24,6 +24,15 @@ REDIS_PORT=6379
|
||||
REDIS_USER=admin
|
||||
REDIS_PASSWORD=Package@321#
|
||||
|
||||
# Customer-app sign-in (QA only).
|
||||
#
|
||||
# A fixed verification code accepted for every identifier, standing in for a
|
||||
# real SMS gateway. Commented out by default: leaving it set is a skeleton key.
|
||||
#
|
||||
# StagingCode() refuses it when ENV=production and logs an error -- so on a
|
||||
# production deployment setting this does nothing and only says so in the log.
|
||||
# CX_STAGING_OTP=1234
|
||||
|
||||
# SMTP Configuration (email OTP verification)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
|
||||
211
CLAUDE.md
211
CLAUDE.md
@@ -45,7 +45,7 @@ customers.
|
||||
prior sessions]**
|
||||
|
||||
- **Backend**: Go + Fiber, deployed on **Kubernetes**, at `api.doormile.com`.
|
||||
200 registered routes **[verified this session, exact count]** — see §7.
|
||||
220 registered routes **[verified 2026-09-02, exact count]** — see §7.
|
||||
This is the primary booking/assignment API and the primary trigger for
|
||||
miler assignment, calling the AI decision layer with a 5-second timeout
|
||||
fallback so a slow AI response never blocks a booking.
|
||||
@@ -90,12 +90,14 @@ Five distinct front doors into this system. Only the backend API surface for
|
||||
each has been directly inspected this session (via `routes.go`); the actual
|
||||
client codebases (Flutter, React) have not been opened in this session.
|
||||
|
||||
1. **Customer app (B2C)** — Flutter. Auth via Firebase OTP (phone). Backend
|
||||
surface: 19 customer routes **[verified this session]**
|
||||
(`customer`/`customerAuth` groups) — booking creation, tracking,
|
||||
`AppCustomer`/`AppCustomerLocation`. **[carried forward]**: reported built
|
||||
and verified in prior sessions; the last live end-to-end test was
|
||||
blocked here — see §9.
|
||||
1. **Customer app (B2C)** — Flutter, being replaced: `doormile_customer_app`
|
||||
(PIN auth, single-destination bookings) is retired in favour of
|
||||
`doormile_cx`. Backend surface rebuilt 2026-09-05 to the Customer App v1
|
||||
contract: **28 customer routes** (`customer`/`customerAuth` groups) — OTP
|
||||
auth with refresh, serviceability/slots/limits, place proxy, fare estimate,
|
||||
multi-destination pickups, per-order tracking, push devices. See §8.5. Auth
|
||||
is a 4-digit OTP to phone or email, NOT Firebase and NOT the miler PIN flow;
|
||||
the SMS gateway is still unplugged, which is the same wall §9 describes.
|
||||
2. **Miler app** — Flutter, for delivery riders. Backend surface: 38 routes
|
||||
**[verified this session]** (`miler`/`milerAuth` groups) — duty
|
||||
start/stop, GPS pings, assignment accept/reject/cancel, delivery
|
||||
@@ -196,7 +198,8 @@ websocket routes. **[verified this session]**
|
||||
`MilerSkipDelivery`, added this session). `ConsignmentHistory` (event
|
||||
log), `ConsignmentException` (Lost/Damaged/Misrouted/Receiver_Refused/
|
||||
Missing_Contents/Undeliverable).
|
||||
- `Hub`, `Vehicle`, `Tripsheet`, `TripsheetItem`, `DeliveryProof`.
|
||||
- `Hub`, `Vehicle`, `Tripsheet`, `TripsheetItem`, `DeliveryProof`. `Hub` is what
|
||||
the rider app calls a **Base** — same row, different word (see §12).
|
||||
- `AppUser` (`appusers`) — shared login table for staff/miler/admin roles
|
||||
(`Roleid`: 1 admin, 3 manager, 4 rep/exec, 5 miler, 6 hub staff via a
|
||||
separate `HubStaffAccount` table). `MilerProfile` — actual rider profile
|
||||
@@ -402,6 +405,198 @@ code compiled correctly the first time it hit a real toolchain.
|
||||
|
||||
---
|
||||
|
||||
## 8.4 Logistics pickup-source & base-handover flow (2026-09-02)
|
||||
|
||||
**[verified this session]** — closes requests 25–31 on the Miler logistics line.
|
||||
Full contract, state-transition tables and wire values:
|
||||
[`docs/logistics-base-handover.md`](docs/logistics-base-handover.md).
|
||||
|
||||
**Vocabulary.** The wire says *hub*; the rider app renders it as *Base*. Never
|
||||
change a wire value to match the app's wording: `inward_at_hub`,
|
||||
`Inwarded_at_Hub`, `next_hub`, `pickup_source_type: "hub"` stay exactly as spelt.
|
||||
|
||||
**Feature flag `MILER_HUB_HANDOVER_ENABLED`** (default **off**, read per request,
|
||||
same pattern as `MILER_COLLECTED_STATE_ENABLED`). On, a hub-routed parcel stops
|
||||
at `Created` at pickup-complete and only reaches `Inwarded_at_Hub` when the
|
||||
handover is recorded. Off (today), pickup-complete marks it `Inwarded_at_Hub`
|
||||
immediately — which is what the deployed rider app expects. **Do not turn it on
|
||||
until a rider build that calls `inward-at-hub` is live**, or every intercity
|
||||
parcel strands on `Created` with no way to advance it. Everything else in this
|
||||
work is ungated.
|
||||
|
||||
**New endpoints (4):**
|
||||
|
||||
| Method | Path | Handler |
|
||||
|---|---|---|
|
||||
| POST | `/miler/consignments/:id/inward-at-hub` | `MilerInwardConsignmentAtHub` |
|
||||
| GET | `/miler/bases` | `MilerGetBases` |
|
||||
| GET | `/hub/inbound/expected` | `GetHubInboundExpected` |
|
||||
| POST | `/hub/inbound/:id/reconcile` | `ReconcileHubInbound` |
|
||||
|
||||
**New columns** (additive, nullable, `AutoMigrate`; no CHECK constraint needed
|
||||
widening — `Created` was already permitted on `consignments`):
|
||||
`pickupbookings.pickupsourcetype`, `pickupbookings.pickuphubid`,
|
||||
`consignments.inwardedat`.
|
||||
|
||||
**Conventions added — reuse these, don't reimplement:**
|
||||
- `renderBase(hub)` (`controllers/logisticsHandoverController.go`) is the ONE
|
||||
shape a base is returned in — all six fields, everywhere. A test enforces the
|
||||
count, because five of six leaves a rider unable to navigate.
|
||||
- `nextActionForConsignment(status)` is the ONE definition of what a rider does
|
||||
next. pickup-complete, the queue read and the consignment read all call it, so
|
||||
a poll can never disagree with the pivot.
|
||||
- `resolveHandoverHub(booking, riderHubID)` decides which base a parcel goes to.
|
||||
Backend decides; the app never picks a base.
|
||||
- `pickupSource(booking, customerName)` resolves type/id/name/address for any
|
||||
booking row, in the miler queue, the hub dispatch board and the admin detail.
|
||||
- `scopeConsignmentsToOwnTenant(c, query)` (`hubInboundController.go`) is the
|
||||
consignment counterpart of `scopeBookingsToOwnTenant` — use it on any new
|
||||
hub-console consignment query.
|
||||
|
||||
**Two pre-existing bugs fixed in passing:** a hub-routed pickup left its
|
||||
`BookingAssignment` open forever, so the rider could never go off duty
|
||||
(`MilerEndDuty` refuses while any assignment is Assigned/Accepted); and the
|
||||
no-rider-hub fallback took whichever hub row an unordered query returned first,
|
||||
now nearest-active-base by haversine.
|
||||
|
||||
**Not verified:** no integration test has hit the 4 new endpoints; the migration
|
||||
has not run against a real DB. `go build`, `go vet` and `go test ./...` all pass.
|
||||
|
||||
---
|
||||
|
||||
## 8.5 Customer app v1 — the `/customer/*` rebuild (2026-09-05)
|
||||
|
||||
**[verified this session]** — implements *Doormile — Backend Requirements
|
||||
(Customer App v1)* for the new `doormile_cx` Flutter client. Full contract,
|
||||
decisions and the written answers to the requirement doc's open questions:
|
||||
[`docs/customer-app-api.md`](docs/customer-app-api.md). Spec:
|
||||
[`docs/openapi-customer.yaml`](docs/openapi-customer.yaml).
|
||||
|
||||
**The structural change: a customer books a PICKUP, not a shipment.** One
|
||||
booking → 1..N destinations → one consignment and one tracking number per
|
||||
destination, minted when the miler completes the pickup. `pickupbookings`
|
||||
carried exactly one delivery address in its own columns, so there was nowhere to
|
||||
put a second; `bookingdestinations` is what closes that.
|
||||
|
||||
**The compatibility rule that makes it safe — do not break it:** destination 0
|
||||
is mirrored onto the booking's flat `delivery*` columns. The miler app, the hub
|
||||
console, the routing code and the hyperlocal check all read those columns and
|
||||
none of them changed. A booking with **no** destination rows (every
|
||||
console/express booking, every pre-existing row) produces exactly one
|
||||
consignment through the same loop, byte-for-byte as before. Single-destination
|
||||
is one leg, never a special case.
|
||||
|
||||
**Two prior surfaces were replaced, on Suriya's call (2026-09-05).** The PIN
|
||||
auth (`/customer/register|login|verify-pin|reset-pin`, plus the email-OTP pair)
|
||||
and the single-destination booking create/list/detail/cancel/price and
|
||||
`/customer/track/:trackingno` are gone — `doormile_customer_app` is being
|
||||
retired in favour of `doormile_cx`. `controllers/otpController.go` was deleted
|
||||
with them. Customer routes: 19 → 28.
|
||||
|
||||
**Identifier formats changed platform-wide.** `generateBookingNo()` now mints
|
||||
`DM-######` and `generateTrackingNo()` mints `DMX########`, both off Postgres
|
||||
sequences (`cx_booking_reference_seq`, `cx_tracking_seq`, created in
|
||||
`migrations/migrate.go`). The old generators used four random bytes; both
|
||||
columns are `UNIQUE` and a random short id collides long before the space runs
|
||||
out. Existing rows keep their `DM-BK-`/`DM-TRK-` strings — nothing parses either
|
||||
format, so the two coexist and the console just shows the new one for new work.
|
||||
|
||||
### Conventions added — reuse these, don't reimplement
|
||||
|
||||
- **`utils.CxOK` / `CxCreated` / `CxList` / `CxFail`** (`utils/response_cx.go`)
|
||||
are the ONLY response helpers for `/customer/*`. Deliberately separate from
|
||||
`utils.OK`/`Fail`: the customer contract always sends `message` (empty on
|
||||
success) and nests the code under `error.code`, while miler/console put `code`
|
||||
at the top level. Never mix them on one surface.
|
||||
- **`utils.EpochMillis(t)`** (`utils/epoch.go`) is the ONLY way a timestamp
|
||||
leaves `/customer/*`. This DB stores IST wall-clock digits (see `DBNow`), so
|
||||
`t.UnixMilli()` is off by 5h30m — the same defect that produced "yesterday's
|
||||
work shown as today" on the miler app. `utils/epoch_test.go` asserts it for
|
||||
both taggings the driver can produce.
|
||||
- **`internal/cxstage`** is the ONE place a customer stage is written. `Record`
|
||||
takes the caller's `*gorm.DB` — a stage event must commit or roll back with
|
||||
the operational write it describes. It dedupes per (booking, destination,
|
||||
stage), and `Notify` fires only after commit.
|
||||
- **`renderCxBooking` + `loadCxBundle`** (`controllers/cxBookingView.go`) build
|
||||
the canonical booking object. Every read that returns a booking goes through
|
||||
them; `loadCxBundle` is a fixed number of queries regardless of page size.
|
||||
- **`cxDestinationForConsignment(id)`** resolves a consignment to its booking.
|
||||
Use it instead of `WHERE consignmentid = ?` on `pickupbookings` — that column
|
||||
names only the FIRST order of a multi-destination pickup (see the bugs below).
|
||||
- **`cxPickupLegs(tx, booking)`** splits a booking into the journeys to create
|
||||
at pickup-complete. It is what decides single-vs-fan-out; nothing downstream
|
||||
needs to know which it got.
|
||||
|
||||
### Stage derivation (the actual work)
|
||||
|
||||
Nine stages, lowercase snake_case, in `constants.CxStage*`. The client parses
|
||||
them verbatim and **silently falls back to `booked` on an unknown key** — never
|
||||
add or rename one without a client release. A booking rolls up from its
|
||||
**slowest** order once parcels split, or a customer sees "Delivered" while a
|
||||
parcel is still at a hub. Nothing is backfilled: a pre-existing booking gets a
|
||||
short honest history rather than an invented one.
|
||||
|
||||
`cxstage.Release` is the one place a stage moves **backwards** — a miler
|
||||
cancelling returns the pickup to the pool rather than cancelling it, and without
|
||||
walking the stage back the customer keeps seeing a rider who is not coming.
|
||||
|
||||
### Four pre-existing bugs fixed in passing
|
||||
|
||||
All the same root cause, all found because the fan-out forced every consignment
|
||||
lookup to be re-read. Each would have broken multi-destination pickups outright:
|
||||
|
||||
1. **`MilerDeliverConsignment` could not close orders 2..N** — its ownership
|
||||
check was `WHERE consignmentid = ? AND assignedmileruserid = ?` on
|
||||
`pickupbookings`, so a rider delivering the second parcel of a three-stop
|
||||
visit got "assigned consignment not found" and could not complete at all.
|
||||
2. **`MilerStartDelivery` notified nobody for orders 2..N** — same join, so no
|
||||
push and no receiver OTP.
|
||||
3. **`MilerInwardConsignmentAtHub` left assignments open for orders 2..N** — the
|
||||
rider could not go off duty (`MilerEndDuty` refuses on an open assignment)
|
||||
and the leg's distance/earnings recorded as zero.
|
||||
4. **`GET /miler/bookings` showed only the first order** — one row per booking
|
||||
keyed on that same column, so the fan-out would have minted orders no rider
|
||||
could see or deliver. `milerStopsForBooking` now emits one stop per order
|
||||
after collection, one visit before it, and exactly one row (unchanged) for a
|
||||
booking with no destination rows.
|
||||
|
||||
Also: **`CityGateMiddleware` was a no-op for customer bookings.** It sniffs the
|
||||
body for `pickuppincode`, which the new request shape does not carry, so every
|
||||
customer booking sailed past the operating-city gate. Now checked in the handler
|
||||
via the exported `middlewares.PincodeInOperatingCity`.
|
||||
|
||||
### Blockers and gaps — state these plainly if asked
|
||||
|
||||
- **No SMS provider exists.** `internal/sms` is the seam (a `Sender` interface,
|
||||
a logging sink, `sms.Register()`); until a gateway is plugged in, OTP codes go
|
||||
to the application log and nowhere else. **This is the single blocker on real
|
||||
customer sign-in** — and it is the same wall §9's E2E test hit. Staging has
|
||||
`CX_STAGING_OTP` (refused when `ENV=production`), which unblocks automated
|
||||
tests.
|
||||
- **No integration test has hit any of these endpoints.** `go build`, `go vet`
|
||||
and `go test ./...` pass; new unit tests cover the pure logic (stage rollup,
|
||||
epoch conversion, phone normalisation, weight fallback). None of that proves
|
||||
behaviour against a real DB/Redis/NATS.
|
||||
- **The migration has not run against a real database.** Additive, so it should
|
||||
be safe — but that is not the same as having run.
|
||||
- **Failed delivery is invisible to the customer.** `MilerSkipDelivery` works
|
||||
operationally, but there is no tenth stage key for it and an unknown key
|
||||
renders as `booked`, so a failed attempt leaves the parcel showing "Out for
|
||||
delivery". Needs product + a client release.
|
||||
- **Latency (p95 ≤ 400ms) is unmeasured.** Reads are batched and pricing is
|
||||
Redis-warmed, but that is an argument, not a measurement.
|
||||
- **No retention policy** for parcel photos or PII — nothing prunes either. The
|
||||
30-minute signed-URL TTL limits link lifetime, not object lifetime.
|
||||
|
||||
### New env vars
|
||||
|
||||
`GEOCODER_URL`, `GEOCODER_EMAIL` (place proxy — the app is never handed a map
|
||||
key, after the legacy rider app's key had to be revoked), `MILER_CALL_PROXY`
|
||||
(masked calling; empty exposes the rider's real number — **set before launch**),
|
||||
`CX_STAGING_OTP`, `CX_ALLOW_STAGE_OVERRIDE`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Current blockers & open work (whole-project level)
|
||||
|
||||
**[carried forward]**
|
||||
|
||||
@@ -27,6 +27,17 @@ type Config struct {
|
||||
// stay unordered rather than assignment failing.
|
||||
RouteOptimizerURL string
|
||||
|
||||
// GeocoderURL is the Nominatim-compatible geocoding service the customer
|
||||
// app'''s place search and reverse geocode are proxied through. Proxied on
|
||||
// purpose: the legacy rider app shipped a Google Maps key inside the
|
||||
// binary and it had to be revoked, so the customer app is never handed a
|
||||
// key at all — it asks this service and this service asks the geocoder.
|
||||
GeocoderURL string
|
||||
// GeocoderEmail is the contact address Nominatim'''s usage policy asks
|
||||
// callers to identify themselves with. Sent as the User-Agent contact;
|
||||
// requests without one are throttled or blocked.
|
||||
GeocoderEmail string
|
||||
|
||||
// TrustedProxies is a comma-separated list of reverse-proxy IPs/CIDRs that
|
||||
// are allowed to set X-Forwarded-For. Rate limiting keys on the client IP,
|
||||
// so behind a proxy this MUST be set — otherwise every request appears to
|
||||
@@ -60,6 +71,8 @@ func Load() *Config {
|
||||
AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"),
|
||||
|
||||
RouteOptimizerURL: getEnv("ROUTE_OPTIMIZER_URL", "https://routes.workolik.com"),
|
||||
GeocoderURL: getEnv("GEOCODER_URL", "https://nominatim.openstreetmap.org"),
|
||||
GeocoderEmail: getEnv("GEOCODER_EMAIL", ""),
|
||||
TrustedProxies: getEnv("TRUSTED_PROXIES", ""),
|
||||
SMTPHost: getEnv("SMTP_HOST", ""),
|
||||
SMTPPort: getEnv("SMTP_PORT", "465"),
|
||||
|
||||
92
config/config_test.go
Normal file
92
config/config_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Config is read once at startup, so a mistyped env key fails silently: the
|
||||
// service boots on a default and points at the wrong host, or ships with a
|
||||
// security switch left off. Cheap to pin.
|
||||
|
||||
func setEnv(t *testing.T, key, value string) {
|
||||
t.Helper()
|
||||
previous, had := os.LookupEnv(key)
|
||||
if value == "" {
|
||||
_ = os.Unsetenv(key)
|
||||
} else {
|
||||
_ = os.Setenv(key, value)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if had {
|
||||
_ = os.Setenv(key, previous)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The geocoder settings added for the customer app. GEOCODER_URL is what the
|
||||
// place search proxies through — the app is never handed a map key, after the
|
||||
// legacy rider app's key had to be revoked.
|
||||
func TestGeocoderDefaultsAndOverrides(t *testing.T) {
|
||||
setEnv(t, "GEOCODER_URL", "")
|
||||
setEnv(t, "GEOCODER_EMAIL", "")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.GeocoderURL != "https://nominatim.openstreetmap.org" {
|
||||
t.Errorf("GeocoderURL default = %q, want the Nominatim host", cfg.GeocoderURL)
|
||||
}
|
||||
if cfg.GeocoderEmail != "" {
|
||||
t.Errorf("GeocoderEmail default = %q, want empty", cfg.GeocoderEmail)
|
||||
}
|
||||
|
||||
setEnv(t, "GEOCODER_URL", "https://nominatim.internal")
|
||||
setEnv(t, "GEOCODER_EMAIL", "ops@doormile.com")
|
||||
|
||||
cfg = Load()
|
||||
if cfg.GeocoderURL != "https://nominatim.internal" {
|
||||
t.Errorf("GEOCODER_URL was not read: got %q", cfg.GeocoderURL)
|
||||
}
|
||||
if cfg.GeocoderEmail != "ops@doormile.com" {
|
||||
t.Errorf("GEOCODER_EMAIL was not read: got %q", cfg.GeocoderEmail)
|
||||
}
|
||||
}
|
||||
|
||||
// Every env key the service reads must actually take effect. A typo in the key
|
||||
// name means the override is ignored and the default is used — which is how a
|
||||
// deploy silently points at the wrong database.
|
||||
func TestEnvironmentOverridesAreRead(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
value string
|
||||
read func(*Config) string
|
||||
}{
|
||||
{"ENV", "production", func(c *Config) string { return c.Env }},
|
||||
{"APP_PORT", "9999", func(c *Config) string { return c.Port }},
|
||||
{"DB_HOST", "db.internal", func(c *Config) string { return c.DBHost }},
|
||||
{"DB_NAME", "logistics_staging", func(c *Config) string { return c.DBName }},
|
||||
{"DB_PORT", "6543", func(c *Config) string { return c.DBPort }},
|
||||
{"REDIS_HOST", "redis.internal", func(c *Config) string { return c.RedisHost }},
|
||||
{"JWT_SECRET_KEY", "a-different-secret", func(c *Config) string { return c.JWTSecret }},
|
||||
{"TRUSTED_PROXIES", "10.0.0.0/8", func(c *Config) string { return c.TrustedProxies }},
|
||||
{"AI_LAYER_BASE_URL", "http://ai.internal", func(c *Config) string { return c.AILayerBaseURL }},
|
||||
{"ROUTE_OPTIMIZER_URL", "http://routes.internal", func(c *Config) string { return c.RouteOptimizerURL }},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
setEnv(t, tc.key, tc.value)
|
||||
if got := tc.read(Load()); got != tc.value {
|
||||
t.Errorf("%s set to %q but Load() read %q", tc.key, tc.value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty env var must fall through to the default rather than blanking the
|
||||
// setting — an empty DB host is a service that cannot start with no clue why.
|
||||
func TestEmptyEnvFallsBackToTheDefault(t *testing.T) {
|
||||
setEnv(t, "DB_NAME", "")
|
||||
if got := Load().DBName; got == "" {
|
||||
t.Error("DBName is empty when DB_NAME is unset; a default is required")
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,37 @@ const (
|
||||
ErrOtpInvalid = "OTP_INVALID"
|
||||
ErrIdempotencyInProgress = "IDEMPOTENCY_IN_PROGRESS" // an identical keyed request is still running
|
||||
ErrEmailInUse = "EMAIL_IN_USE"
|
||||
ErrHubNotFound = "HUB_NOT_FOUND" // hub_id on a handover does not resolve to an active base
|
||||
ErrHubRequired = "HUB_REQUIRED" // handover attempted with no base to hand over to
|
||||
)
|
||||
|
||||
// Pickup source types — what kind of place a booking is collected FROM. Sent
|
||||
// on every miler booking row as pickup_source_type so the rider app can title a
|
||||
// stop correctly instead of guessing from the source name, the pincode or the
|
||||
// rider's own base. "customer" is a real value, never an omission: a front-door
|
||||
// pickup has no configured location id, and "no location because it is a front
|
||||
// door" must be distinguishable from "no location because nobody filled it in".
|
||||
//
|
||||
// The rider app renders "hub" as Base — the wire value stays hub.
|
||||
const (
|
||||
PickupSourceHub = "hub"
|
||||
PickupSourceCustomer = "customer"
|
||||
PickupSourceMerchant = "merchant"
|
||||
PickupSourceStore = "store"
|
||||
)
|
||||
|
||||
// Next actions — what the rider does next with a parcel. Returned by
|
||||
// pickup-complete and, so a poll or a cold restart can rebuild the leg without
|
||||
// a local cache, on every GET /miler/bookings row. Consignment status alone
|
||||
// cannot carry this: a hub-routed parcel and a freshly-collected hyperlocal one
|
||||
// can both sit on Created.
|
||||
const (
|
||||
NextActionPickup = "pickup" // not collected yet — the stop is the pickup
|
||||
NextActionStartDelivery = "start_delivery" // collected, hyperlocal, not yet out for delivery
|
||||
NextActionDeliver = "deliver" // carry it to the receiver
|
||||
NextActionInwardAtHub = "inward_at_hub" // carry it to a base and hand it over
|
||||
NextActionHandedToHub = "handed_to_hub" // already inwarded at the base — nothing left for this rider
|
||||
NextActionNone = "none" // terminal (delivered, cancelled, returned)
|
||||
)
|
||||
|
||||
// Payment Modes
|
||||
@@ -137,3 +168,74 @@ const (
|
||||
ExceptionResolved = "Resolved"
|
||||
ExceptionClosed = "Closed"
|
||||
)
|
||||
|
||||
// Customer-app stages. Nine operational stages, spelt exactly as the customer
|
||||
// client parses them: lowercase snake_case, on the wire verbatim. The client
|
||||
// rolls these up into seven milestones itself and falls back to "booked" on an
|
||||
// unknown key, silently — so adding a value here without an app release makes a
|
||||
// parcel look un-started. Never rename one; add and coordinate.
|
||||
//
|
||||
// Stages 0-5 belong to the booking. Stages 6-8 belong to each order and may
|
||||
// differ between destinations of the same booking.
|
||||
const (
|
||||
CxStageBooked = "booked" // 0 — pickup requested
|
||||
CxStageAssigned = "assigned" // 1 — a miler accepted it
|
||||
CxStageOnTheWay = "on_the_way" // 2 — rider en route, distance/ETA live
|
||||
CxStageArrived = "arrived" // 3 — rider at the door; LAST cancellable stage
|
||||
CxStagePickedUp = "picked_up" // 4 — weighed, photographed, price settled
|
||||
CxStageOrderCreated = "order_created" // 5 — one tracking number minted per destination
|
||||
CxStageInTransit = "in_transit" // 6 — per order from here on
|
||||
CxStageOutForDelivery = "out_for_delivery" // 7 — delivery agent carrying it
|
||||
CxStageDelivered = "delivered" // 8 — handed over
|
||||
)
|
||||
|
||||
// Customer-facing booking status. Derived from the stage but sent explicitly,
|
||||
// because a client that has to infer it will eventually infer it differently.
|
||||
const (
|
||||
CxStatusActive = "active"
|
||||
CxStatusCompleted = "completed"
|
||||
CxStatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// Who caused a stage transition. Recorded on every bookingstageevents row: the
|
||||
// customer timeline is derived from that table, so it has to be real, and a
|
||||
// cancellation the customer did not make is unexplainable without this.
|
||||
const (
|
||||
CxActorMiler = "miler"
|
||||
CxActorOps = "ops"
|
||||
CxActorCustomer = "customer"
|
||||
CxActorSystem = "system"
|
||||
)
|
||||
|
||||
// CxStageOrder is the rank of each stage, used to decide whether a transition
|
||||
// moves forward and whether cancellation is still open. Cancellation closes
|
||||
// after arrived, so anything at or past picked_up is refused.
|
||||
var CxStageOrder = map[string]int{
|
||||
CxStageBooked: 0,
|
||||
CxStageAssigned: 1,
|
||||
CxStageOnTheWay: 2,
|
||||
CxStageArrived: 3,
|
||||
CxStagePickedUp: 4,
|
||||
CxStageOrderCreated: 5,
|
||||
CxStageInTransit: 6,
|
||||
CxStageOutForDelivery: 7,
|
||||
CxStageDelivered: 8,
|
||||
}
|
||||
|
||||
// CxStageRank returns the rank of a stage, or -1 when the stage is unknown or
|
||||
// empty. A booking written before this surface existed has no stage at all,
|
||||
// and -1 keeps it strictly behind every real stage rather than tying with
|
||||
// "booked".
|
||||
func CxStageRank(stage string) int {
|
||||
if r, ok := CxStageOrder[stage]; ok {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// CxCancellable reports whether a booking at this stage may still be cancelled.
|
||||
// The UI mirrors this to hide the button, but the server re-checks on the
|
||||
// cancel call — the button state is a hint, never the authority.
|
||||
func CxCancellable(stage string) bool {
|
||||
return CxStageRank(stage) <= CxStageOrder[CxStageArrived]
|
||||
}
|
||||
|
||||
116
constants/constants_test.go
Normal file
116
constants/constants_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package constants
|
||||
|
||||
import "testing"
|
||||
|
||||
// The nine stage keys are a wire contract. The customer client parses them
|
||||
// verbatim and silently falls back to `booked` on anything it does not
|
||||
// recognise — so a renamed or misspelt key here does not fail loudly, it makes
|
||||
// a moving parcel look un-started on someone's tracking screen.
|
||||
|
||||
func TestStageKeysAreSpeltExactlyAsTheClientParsesThem(t *testing.T) {
|
||||
want := map[string]string{
|
||||
CxStageBooked: "booked",
|
||||
CxStageAssigned: "assigned",
|
||||
CxStageOnTheWay: "on_the_way",
|
||||
CxStageArrived: "arrived",
|
||||
CxStagePickedUp: "picked_up",
|
||||
CxStageOrderCreated: "order_created",
|
||||
CxStageInTransit: "in_transit",
|
||||
CxStageOutForDelivery: "out_for_delivery",
|
||||
CxStageDelivered: "delivered",
|
||||
}
|
||||
for got, expected := range want {
|
||||
if got != expected {
|
||||
t.Errorf("stage key = %q, want %q", got, expected)
|
||||
}
|
||||
}
|
||||
if len(CxStageOrder) != 9 {
|
||||
t.Errorf("CxStageOrder has %d entries, want 9 — every stage the client "+
|
||||
"knows must be rankable", len(CxStageOrder))
|
||||
}
|
||||
}
|
||||
|
||||
// Ranks must be strictly increasing in journey order. advanceBooking refuses to
|
||||
// move a booking backwards by comparing these, so a tie or an inversion would
|
||||
// let a stage silently fail to apply.
|
||||
func TestStageRanksIncreaseInJourneyOrder(t *testing.T) {
|
||||
ordered := []string{
|
||||
CxStageBooked, CxStageAssigned, CxStageOnTheWay, CxStageArrived,
|
||||
CxStagePickedUp, CxStageOrderCreated, CxStageInTransit,
|
||||
CxStageOutForDelivery, CxStageDelivered,
|
||||
}
|
||||
for i := 1; i < len(ordered); i++ {
|
||||
if CxStageRank(ordered[i]) <= CxStageRank(ordered[i-1]) {
|
||||
t.Errorf("%q ranks %d, not after %q at %d",
|
||||
ordered[i], CxStageRank(ordered[i]), ordered[i-1], CxStageRank(ordered[i-1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown or empty stage must rank BEHIND booked, not tie with it. A booking
|
||||
// written before this surface existed has no stage at all, and a tie would stop
|
||||
// it ever advancing off nothing.
|
||||
func TestUnknownStageRanksBehindEverything(t *testing.T) {
|
||||
if CxStageRank("") != -1 {
|
||||
t.Errorf("empty stage ranks %d, want -1", CxStageRank(""))
|
||||
}
|
||||
if CxStageRank("teleported") != -1 {
|
||||
t.Errorf("unknown stage ranks %d, want -1", CxStageRank("teleported"))
|
||||
}
|
||||
if CxStageRank("") >= CxStageRank(CxStageBooked) {
|
||||
t.Error("an unknown stage does not rank behind booked")
|
||||
}
|
||||
}
|
||||
|
||||
// Cancellation closes after `arrived` — the contract's rule, and money depends
|
||||
// on it: cancelling after pickup would mean a parcel already collected and paid
|
||||
// for is marked cancelled.
|
||||
func TestCancellationWindowClosesAfterArrived(t *testing.T) {
|
||||
open := []string{"", CxStageBooked, CxStageAssigned, CxStageOnTheWay, CxStageArrived}
|
||||
for _, stage := range open {
|
||||
if !CxCancellable(stage) {
|
||||
t.Errorf("stage %q should still be cancellable", stage)
|
||||
}
|
||||
}
|
||||
|
||||
closed := []string{
|
||||
CxStagePickedUp, CxStageOrderCreated, CxStageInTransit,
|
||||
CxStageOutForDelivery, CxStageDelivered,
|
||||
}
|
||||
for _, stage := range closed {
|
||||
if CxCancellable(stage) {
|
||||
t.Errorf("stage %q must not be cancellable — the parcel is collected", stage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The customer statuses and actor types are also on the wire.
|
||||
func TestCustomerStatusAndActorValues(t *testing.T) {
|
||||
pairs := map[string]string{
|
||||
CxStatusActive: "active",
|
||||
CxStatusCompleted: "completed",
|
||||
CxStatusCancelled: "cancelled",
|
||||
CxActorMiler: "miler",
|
||||
CxActorOps: "ops",
|
||||
CxActorCustomer: "customer",
|
||||
CxActorSystem: "system",
|
||||
}
|
||||
for got, want := range pairs {
|
||||
if got != want {
|
||||
t.Errorf("constant = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Booking source decides whether a booking gets a customer projection at all —
|
||||
// cxstage returns early for anything that is not Customer_App. The stored value
|
||||
// predates this work and must not be "fixed".
|
||||
func TestBookingSourceValuesAreTheStoredOnes(t *testing.T) {
|
||||
if BookingSourceCustomerApp != "Customer_App" {
|
||||
t.Errorf("BookingSourceCustomerApp = %q, want %q", BookingSourceCustomerApp, "Customer_App")
|
||||
}
|
||||
if BookingSourceExpress != "CRM_Console" {
|
||||
t.Errorf("BookingSourceExpress = %q — this is an existing database value, "+
|
||||
"not a label to rename", BookingSourceExpress)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"doormile/db"
|
||||
"doormile/dto"
|
||||
"doormile/internal/assignment"
|
||||
"doormile/internal/cxstage"
|
||||
"doormile/internal/notify"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
@@ -1126,7 +1127,18 @@ func GetTenantCustomers(c *fiber.Ctx) error {
|
||||
|
||||
func GetAdminCustomers(c *fiber.Ctx) error {
|
||||
pageno := max(1, c.QueryInt("pageno", 1))
|
||||
pagesize := min(100, max(1, c.QueryInt("pagesize", 20)))
|
||||
// Ceiling comes from utils.MaxPageSize rather than a literal, so this
|
||||
// endpoint and utils.ParsePage cannot disagree about what a caller may
|
||||
// ask for. The hard-coded 100 here silently capped every client that
|
||||
// asked for more: the console drains this list a page at a time and
|
||||
// requests 1000, so it was issuing ten times the round trips for the
|
||||
// same rows and hitting its own page budget at 1,200 — past which the
|
||||
// counts it renders are floors, not totals.
|
||||
//
|
||||
// The DEFAULT stays 20. Callers that do not ask for a page size keep
|
||||
// exactly the response they get today; only a caller that explicitly
|
||||
// requests more sees any change.
|
||||
pagesize := min(utils.MaxPageSize, max(1, c.QueryInt("pagesize", 20)))
|
||||
offset := (pageno - 1) * pagesize
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
@@ -2061,9 +2073,55 @@ func AssignMilerVehicle(c *fiber.Ctx) error {
|
||||
// BOOKINGS MANAGEMENT
|
||||
// --------------------
|
||||
|
||||
// bookingDestinationCounts is one row of the grouped bookingdestinations
|
||||
// aggregate: how many destinations a booking carries and how many packages they
|
||||
// add up to across all of them.
|
||||
type bookingDestinationCounts struct {
|
||||
Bookingid int `gorm:"column:bookingid"`
|
||||
Destinationcount int `gorm:"column:destinationcount"`
|
||||
Totalpackagecount int `gorm:"column:totalpackagecount"`
|
||||
}
|
||||
|
||||
// applyDestinationCounts writes the grouped counts onto the bookings they
|
||||
// belong to. A booking with no bookingdestinations rows — every
|
||||
// console-created booking, and every booking predating the customer app —
|
||||
// keeps the 0/0 zero value, which is the correct answer and the one the
|
||||
// console distinguishes from a single-destination booking.
|
||||
//
|
||||
// Split out from the handler because it is the whole of the mapping logic and
|
||||
// the only part that can be tested without a database.
|
||||
func applyDestinationCounts(bookings []models.PickupBooking, counts []bookingDestinationCounts) {
|
||||
if len(bookings) == 0 || len(counts) == 0 {
|
||||
return
|
||||
}
|
||||
byBooking := make(map[int]bookingDestinationCounts, len(counts))
|
||||
for _, row := range counts {
|
||||
byBooking[row.Bookingid] = row
|
||||
}
|
||||
for i := range bookings {
|
||||
row, ok := byBooking[bookings[i].Bookingid]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bookings[i].Destinationcount = row.Destinationcount
|
||||
bookings[i].Totalpackagecount = row.Totalpackagecount
|
||||
}
|
||||
}
|
||||
|
||||
func GetAdminBookings(c *fiber.Ctx) error {
|
||||
pageno := max(1, c.QueryInt("pageno", 1))
|
||||
pagesize := min(100, max(1, c.QueryInt("pagesize", 20)))
|
||||
// Ceiling comes from utils.MaxPageSize rather than a literal, so this
|
||||
// endpoint and utils.ParsePage cannot disagree about what a caller may
|
||||
// ask for. The hard-coded 100 here silently capped every client that
|
||||
// asked for more: the console drains this list a page at a time and
|
||||
// requests 1000, so it was issuing ten times the round trips for the
|
||||
// same rows and hitting its own page budget at 1,200 — past which the
|
||||
// counts it renders are floors, not totals.
|
||||
//
|
||||
// The DEFAULT stays 20. Callers that do not ask for a page size keep
|
||||
// exactly the response they get today; only a caller that explicitly
|
||||
// requests more sees any change.
|
||||
pagesize := min(utils.MaxPageSize, max(1, c.QueryInt("pagesize", 20)))
|
||||
offset := (pageno - 1) * pagesize
|
||||
|
||||
tenantID, allowed := effectiveTenantID(c)
|
||||
@@ -2080,8 +2138,45 @@ func GetAdminBookings(c *fiber.Ctx) error {
|
||||
return utils.Internal(c, "failed to count bookings")
|
||||
}
|
||||
|
||||
// Newest first, and deterministically so.
|
||||
//
|
||||
// Without an ORDER BY the row order is unspecified — Postgres returns heap
|
||||
// order, which in practice is oldest first. Two things follow, and both bit:
|
||||
//
|
||||
// 1. The newest booking sits on the LAST page. The console drains a
|
||||
// bounded window, so once pickupbookings outgrows that window a
|
||||
// just-created customer-app booking can never reach the Orders page at
|
||||
// all. It is written correctly and is simply never fetched.
|
||||
// 2. OFFSET pagination over an unordered result is not stable: the same
|
||||
// page can return different rows across two requests, so draining pages
|
||||
// can duplicate and skip rows well before that threshold.
|
||||
//
|
||||
// bookingid rather than createdat: it is the primary key and unique, so the
|
||||
// sort needs no tiebreaker and the paging cannot wobble between equal
|
||||
// timestamps. It also matches the order the console already sorts into
|
||||
// client-side, so page 1 is the newest page by both definitions.
|
||||
// Destinations ride the list, not just the detail read.
|
||||
//
|
||||
// A customer-app booking is one pickup carrying N drops, and the console's
|
||||
// Bookings page is the screen that shows them. Without this the list could
|
||||
// only report `destinationcount` and the row's mirrored destination 0, so a
|
||||
// three-drop pickup looked identical to a one-drop pickup until somebody
|
||||
// opened the drawer — which is the whole reason that page was reaching for
|
||||
// the customer app's own endpoint instead.
|
||||
//
|
||||
// One extra query for the page (GORM batches a Preload with an IN clause),
|
||||
// not one per row, and ordered by seq because seq is the customer-facing
|
||||
// position: it is the {index} in
|
||||
// PATCH /customer/bookings/{ref}/destinations/{index}, so the order the
|
||||
// console renders has to be the order the customer addresses. Same preload
|
||||
// GetAdminBookingDetails already uses, so the list and the drawer cannot
|
||||
// disagree about a booking's drops.
|
||||
var bookings []models.PickupBooking
|
||||
if err := query.Preload("Parcels").Preload("ServiceOptions").
|
||||
Preload("Destinations", func(d *gorm.DB) *gorm.DB {
|
||||
return d.Order("seq ASC")
|
||||
}).
|
||||
Order("bookingid DESC").
|
||||
Offset(offset).Limit(pagesize).Find(&bookings).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch bookings")
|
||||
}
|
||||
@@ -2112,6 +2207,27 @@ func GetAdminBookings(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// How many destinations each booking carries, and the packages summed across
|
||||
// them. A customer-app pickup is ONE booking with N destinations, and the
|
||||
// console needs to render "3 destinations · 4 packages" on the collapsed row.
|
||||
// The full array is deliberately NOT preloaded here: the console drains up to
|
||||
// 12 pages of 100 bookings and only opens one row at a time, so the array is
|
||||
// payload the list never reads. Batched exactly like the consignment status
|
||||
// above — one grouped query for the whole page, never one per row.
|
||||
bookingIDs := make([]int, 0, len(bookings))
|
||||
for _, b := range bookings {
|
||||
bookingIDs = append(bookingIDs, b.Bookingid)
|
||||
}
|
||||
if len(bookingIDs) > 0 {
|
||||
var counts []bookingDestinationCounts
|
||||
db.DB.Model(&models.BookingDestination{}).
|
||||
Select("bookingid, COUNT(*) AS destinationcount, COALESCE(SUM(packagecount), 0) AS totalpackagecount").
|
||||
Where("bookingid IN ?", bookingIDs).
|
||||
Group("bookingid").
|
||||
Scan(&counts)
|
||||
applyDestinationCounts(bookings, counts)
|
||||
}
|
||||
|
||||
pages := int(math.Ceil(float64(total) / float64(pagesize)))
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
@@ -2142,7 +2258,23 @@ type AdminBookingRequest struct {
|
||||
// callers written against the earlier docs. It is never stored as-is: the
|
||||
// column of that name foreign-keys to appcustomerlocations, not to a
|
||||
// client's sites.
|
||||
Pickuplocationid *int `json:"pickuplocationid"`
|
||||
Pickuplocationid *int `json:"pickuplocationid"`
|
||||
// PickupSourceType says what kind of place this booking is collected from —
|
||||
// one of constants.PickupSource*. Optional: left blank it is classified from
|
||||
// what the payload carries (a base id, a client site id, or neither), so
|
||||
// existing console callers keep working unchanged. Send it explicitly to
|
||||
// create a Base/Hub-origin booking.
|
||||
PickupSourceType string `json:"pickup_source_type"`
|
||||
// Pickuphubid names the base a Base → Customer booking is collected FROM.
|
||||
// Required when pickup_source_type is "hub"; supplying it is also enough on
|
||||
// its own, since a booking that names a base is a base-origin booking. The
|
||||
// base's own address, pincode and coordinates fill in whatever the caller
|
||||
// left blank, so the dispatch board never has to retype a gate address.
|
||||
Pickuphubid *int `json:"pickuphubid"`
|
||||
// Sourceid is accepted as an alias for whichever id the source type implies —
|
||||
// the app and the console have both used this spelling. With
|
||||
// pickup_source_type "hub" it is a base id; otherwise a client-site id.
|
||||
Sourceid *int `json:"sourceid"`
|
||||
Pickupaddress string `json:"pickupaddress"`
|
||||
Pickuppincode string `json:"pickuppincode"`
|
||||
Pickuplatitude float64 `json:"pickuplatitude"`
|
||||
@@ -2202,7 +2334,38 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic
|
||||
// against the earlier documentation. It is the wrong column: it foreign-keys
|
||||
// to appcustomerlocations, so a tenantlocations id in it fails the insert.
|
||||
// Both names resolve to Tenantlocationid.
|
||||
// A base-origin pickup (Base/Hub → Customer). The base is the actual place the
|
||||
// rider collects from, so its address and coordinates become the booking's
|
||||
// pickup point and the row records both the type and the base id — that pair
|
||||
// is what the rider app reads to title the stop as a Base rather than as the
|
||||
// rider's own office, and what the dispatch board reads back on the row.
|
||||
baseID := req.Pickuphubid
|
||||
if baseID == nil && strings.EqualFold(req.PickupSourceType, constants.PickupSourceHub) {
|
||||
baseID = req.Sourceid
|
||||
}
|
||||
if baseID != nil {
|
||||
var hub models.Hub
|
||||
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", *baseID).First(&hub).Error; err != nil {
|
||||
return nil, &expressBookingValidationError{"pickuphubid does not match a known base"}
|
||||
}
|
||||
req.PickupSourceType = constants.PickupSourceHub
|
||||
req.Pickuphubid = &hub.Hubid
|
||||
if req.Pickupaddress == "" {
|
||||
req.Pickupaddress = hub.Address
|
||||
}
|
||||
if req.Pickuppincode == "" {
|
||||
req.Pickuppincode = hub.Pincode
|
||||
}
|
||||
if req.Pickuplatitude == 0 && req.Pickuplongitude == 0 {
|
||||
req.Pickuplatitude, req.Pickuplongitude = hub.Latitude, hub.Longitude
|
||||
}
|
||||
}
|
||||
|
||||
siteID := req.Tenantlocationid
|
||||
// sourceid doubles as the client-site id when the source is not a base.
|
||||
if siteID == nil && baseID == nil {
|
||||
siteID = req.Sourceid
|
||||
}
|
||||
if siteID == nil {
|
||||
siteID = req.Pickuplocationid
|
||||
}
|
||||
@@ -2238,10 +2401,34 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic
|
||||
// pickup actually is. Without this the field stays null — as it did on every
|
||||
// booking in the system — and per-site reporting has nothing to group by,
|
||||
// because the console sends a kitchen's address rather than its id.
|
||||
if req.Tenantlocationid == nil {
|
||||
if req.Tenantlocationid == nil && req.Pickuphubid == nil {
|
||||
req.Tenantlocationid = matchTenantLocation(req.Tenantid, req.Pickupaddress, req.Pickuplatitude, req.Pickuplongitude)
|
||||
}
|
||||
|
||||
// Classify the source once, here, rather than leaving every reader to guess.
|
||||
// A caller-supplied type wins as long as it is one we know; an unknown word is
|
||||
// dropped rather than stored, so the column never holds something the app has
|
||||
// no meaning for. A door pickup is recorded as "customer" explicitly — the
|
||||
// whole point of the column is that a blank cannot be told apart from an
|
||||
// address nobody filled in.
|
||||
switch {
|
||||
case req.Pickuphubid != nil:
|
||||
req.PickupSourceType = constants.PickupSourceHub
|
||||
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceStore):
|
||||
req.PickupSourceType = constants.PickupSourceStore
|
||||
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceCustomer):
|
||||
req.PickupSourceType = constants.PickupSourceCustomer
|
||||
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceMerchant):
|
||||
// Honoured even with no site id attached. A merchant collection with no
|
||||
// configured location is still a shop, and telling the rider "customer
|
||||
// door" would send them looking for a person who is not there.
|
||||
req.PickupSourceType = constants.PickupSourceMerchant
|
||||
case req.Tenantlocationid != nil:
|
||||
req.PickupSourceType = constants.PickupSourceMerchant
|
||||
default:
|
||||
req.PickupSourceType = constants.PickupSourceCustomer
|
||||
}
|
||||
|
||||
tx := db.DB.Begin()
|
||||
|
||||
customerID := req.Appcustomerid
|
||||
@@ -2276,6 +2463,8 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic
|
||||
Appcustomerid: customerID,
|
||||
Pickuplocationid: req.Pickuplocationid,
|
||||
Tenantlocationid: req.Tenantlocationid,
|
||||
Pickupsourcetype: req.PickupSourceType,
|
||||
Pickuphubid: req.Pickuphubid,
|
||||
Pickupaddress: req.Pickupaddress,
|
||||
Pickuppincode: req.Pickuppincode,
|
||||
Pickuplatitude: req.Pickuplatitude,
|
||||
@@ -2521,11 +2710,85 @@ func AdminBulkCreateBookings(c *fiber.Ctx) error {
|
||||
func GetAdminBookingDetails(c *fiber.Ctx) error {
|
||||
id, _ := strconv.Atoi(c.Params("id"))
|
||||
var booking models.PickupBooking
|
||||
q := scopeToOwnTenant(c, db.DB.Preload("Parcels").Preload("ServiceOptions").Preload("Payments"), "tenantid")
|
||||
// Destinations is the customer-app half of the booking: one pickup carries N
|
||||
// of them, each with its own consignment, tracking number and stage once the
|
||||
// miler completes pickup (cxPickupFanout.go). The relation has been declared
|
||||
// on the model since the customer app shipped and nothing preloaded it, which
|
||||
// is the entire reason the console could only ever show one drop.
|
||||
//
|
||||
// Ordered by seq ascending and never by anything else: seq is the
|
||||
// customer-facing position and the {index} in
|
||||
// PATCH /customer/bookings/{ref}/destinations/{index}, so the order the
|
||||
// console renders has to be the order the customer addresses.
|
||||
q := scopeToOwnTenant(c, db.DB.
|
||||
Preload("Parcels").
|
||||
Preload("ServiceOptions").
|
||||
Preload("Payments").
|
||||
Preload("Destinations", func(d *gorm.DB) *gorm.DB {
|
||||
return d.Order("seq ASC")
|
||||
}), "tenantid")
|
||||
if err := q.First(&booking, id).Error; err != nil {
|
||||
return utils.NotFound(c, "booking not found")
|
||||
}
|
||||
return utils.OK(c, booking)
|
||||
|
||||
// The routing decision and the inputs it was made from, so a support call
|
||||
// about "why does this say handover instead of delivery" is a lookup rather
|
||||
// than a reconstruction. Everything here is derived from stored state — no
|
||||
// new columns, and it stays right if the routing rule changes, because it
|
||||
// reads the same helpers the pivot does.
|
||||
var customer models.AppCustomer
|
||||
db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer)
|
||||
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&booking,
|
||||
customer.Firstname+" "+customer.Lastname)
|
||||
|
||||
routing := fiber.Map{
|
||||
"pickup_source_type": sourceType,
|
||||
"pickup_source_id": sourceID,
|
||||
"pickup_source_name": sourceName,
|
||||
"from_address": sourceAddress,
|
||||
"from_pincode": booking.Pickuppincode,
|
||||
"to_address": booking.Deliveryaddress,
|
||||
"destination_pincode": booking.Deliverypincode,
|
||||
// hyperlocal: same postal area, so no base leg — the collecting rider
|
||||
// carries it to the receiver. Otherwise it goes through a base. This is the
|
||||
// decision pickup-complete makes, shown with the inputs it makes it from.
|
||||
"is_hyperlocal": isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode,
|
||||
booking.Pickuplatitude, booking.Pickuplongitude,
|
||||
booking.Deliverylatitude, booking.Deliverylongitude),
|
||||
}
|
||||
|
||||
// Before pickup the decision has not been taken yet, so the routing result is
|
||||
// a projection; after pickup it is fact, read off the consignment.
|
||||
if booking.Consignmentid != nil {
|
||||
var cn models.Consignment
|
||||
if db.DB.First(&cn, *booking.Consignmentid).Error == nil {
|
||||
booking.Consignmentstatus = cn.Status
|
||||
routing["consignment_state"] = cn.Status
|
||||
routing["next_action"] = nextActionForConsignment(cn.Status)
|
||||
routing["next_hub"] = renderBase(loadHub(cn.Currenthubid))
|
||||
routing["inwardedat"] = cn.Inwardedat
|
||||
routing["decided"] = true
|
||||
}
|
||||
} else {
|
||||
routing["consignment_state"] = ""
|
||||
routing["next_action"] = constants.NextActionPickup
|
||||
routing["next_hub"] = nil
|
||||
routing["decided"] = false
|
||||
}
|
||||
|
||||
// routing rides alongside the booking's own fields rather than nesting them
|
||||
// under a new key — the console reads this response as a booking object today,
|
||||
// and moving those fields would break every screen that does.
|
||||
raw, err := json.Marshal(booking)
|
||||
if err != nil {
|
||||
return utils.OK(c, booking)
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return utils.OK(c, booking)
|
||||
}
|
||||
payload["routing"] = routing
|
||||
return utils.OK(c, payload)
|
||||
}
|
||||
|
||||
func AdminAssignMiler(c *fiber.Ctx) error {
|
||||
@@ -2639,6 +2902,18 @@ func AdminCancelBooking(c *fiber.Ctx) error {
|
||||
return utils.Internal(c, "failed to cancel booking")
|
||||
}
|
||||
|
||||
// Tell the customer's projection too. Without this the pickup keeps
|
||||
// rendering as active and cancellable in the customer app, because
|
||||
// customerstatus was written as "active" at booking time and nothing here
|
||||
// ever moved it. Best-effort and outside the save above: an ops cancel that
|
||||
// has already committed must not be reported as failed because the
|
||||
// customer-side write did not land. No-op for console-created bookings.
|
||||
if err := cxstage.Cancel(db.DB, booking.Bookingid, "Cancelled by Doormile operations",
|
||||
constants.CxActorOps, opsActorID(c), "POST /admin/bookings/{id}/cancel"); err != nil {
|
||||
utils.Error("AdminCancelBooking: could not update the customer projection",
|
||||
"booking_id", booking.Bookingid, "error", err)
|
||||
}
|
||||
|
||||
if booking.Assignedmileruserid != nil {
|
||||
db.DB.Model(&models.MilerProfile{}).
|
||||
Where("userid = ?", *booking.Assignedmileruserid).
|
||||
@@ -2718,6 +2993,15 @@ func AdminBulkCancelBookings(c *fiber.Ctx) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Same reason as AdminCancelBooking: without this the pickup keeps
|
||||
// rendering as active and cancellable in the customer app. No-op for
|
||||
// console-created bookings.
|
||||
if err := cxstage.Cancel(db.DB, booking.Bookingid, "Cancelled by Doormile operations",
|
||||
constants.CxActorOps, opsActorID(c), "POST /admin/bookings/bulk-cancel"); err != nil {
|
||||
utils.Error("AdminBulkCancelBookings: could not update the customer projection",
|
||||
"booking_id", booking.Bookingid, "error", err)
|
||||
}
|
||||
|
||||
if booking.Assignedmileruserid != nil {
|
||||
db.DB.Model(&models.MilerProfile{}).
|
||||
Where("userid = ?", *booking.Assignedmileruserid).
|
||||
@@ -3764,3 +4048,13 @@ func InternalReassign(c *fiber.Ctx) error {
|
||||
"booking_id": booking.Bookingid,
|
||||
})
|
||||
}
|
||||
|
||||
// opsActorID returns the console user behind an ops action, for the customer's
|
||||
// audit trail. Nil when the request carries no user id, which is a legitimate
|
||||
// state for an internal caller rather than something to fail on.
|
||||
func opsActorID(c *fiber.Ctx) *int {
|
||||
if uid, ok := c.Locals("userid").(int); ok && uid != 0 {
|
||||
return &uid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
102
controllers/adminDestinations_test.go
Normal file
102
controllers/adminDestinations_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"doormile/models"
|
||||
)
|
||||
|
||||
// One customer pickup is ONE booking carrying N destinations. The admin list
|
||||
// says how many without shipping the array, and these cover the mapping that
|
||||
// does it — the only half of the change that can be tested without Postgres.
|
||||
// The queries themselves need the integration pass (docs/customer-app-api.md).
|
||||
|
||||
func TestDestinationCountsLandOnTheRightBooking(t *testing.T) {
|
||||
bookings := []models.PickupBooking{
|
||||
{Bookingid: 11},
|
||||
{Bookingid: 22},
|
||||
{Bookingid: 33},
|
||||
}
|
||||
// Deliberately out of order and not covering every booking: the grouped
|
||||
// query returns rows for whichever bookings have destinations, in whatever
|
||||
// order the database chose.
|
||||
counts := []bookingDestinationCounts{
|
||||
{Bookingid: 33, Destinationcount: 1, Totalpackagecount: 1},
|
||||
{Bookingid: 11, Destinationcount: 3, Totalpackagecount: 4},
|
||||
}
|
||||
|
||||
applyDestinationCounts(bookings, counts)
|
||||
|
||||
if bookings[0].Destinationcount != 3 || bookings[0].Totalpackagecount != 4 {
|
||||
t.Errorf("booking 11: got %d destinations / %d packages, want 3/4",
|
||||
bookings[0].Destinationcount, bookings[0].Totalpackagecount)
|
||||
}
|
||||
if bookings[2].Destinationcount != 1 || bookings[2].Totalpackagecount != 1 {
|
||||
t.Errorf("booking 33: got %d destinations / %d packages, want 1/1",
|
||||
bookings[2].Destinationcount, bookings[2].Totalpackagecount)
|
||||
}
|
||||
}
|
||||
|
||||
// A console-created booking has no bookingdestinations rows at all, so the
|
||||
// grouped query returns nothing for it. It must report 0/0 rather than
|
||||
// inheriting a neighbour's counts — the console reads 0 as "no destinations
|
||||
// recorded" and 1 as "a single drop", and they render differently.
|
||||
func TestBookingWithNoDestinationsStaysZero(t *testing.T) {
|
||||
bookings := []models.PickupBooking{
|
||||
{Bookingid: 11},
|
||||
{Bookingid: 99}, // console-created: no destination rows
|
||||
}
|
||||
counts := []bookingDestinationCounts{
|
||||
{Bookingid: 11, Destinationcount: 3, Totalpackagecount: 4},
|
||||
}
|
||||
|
||||
applyDestinationCounts(bookings, counts)
|
||||
|
||||
if bookings[1].Destinationcount != 0 || bookings[1].Totalpackagecount != 0 {
|
||||
t.Errorf("console booking: got %d/%d, want 0/0",
|
||||
bookings[1].Destinationcount, bookings[1].Totalpackagecount)
|
||||
}
|
||||
}
|
||||
|
||||
// The aggregate is keyed by booking id, so a count can never be written onto a
|
||||
// booking that was not on this page. Guards the map lookup against being
|
||||
// replaced by anything positional.
|
||||
func TestCountsForBookingsOutsideThePageAreIgnored(t *testing.T) {
|
||||
bookings := []models.PickupBooking{{Bookingid: 11}}
|
||||
counts := []bookingDestinationCounts{
|
||||
{Bookingid: 77, Destinationcount: 9, Totalpackagecount: 9},
|
||||
}
|
||||
|
||||
applyDestinationCounts(bookings, counts)
|
||||
|
||||
if bookings[0].Destinationcount != 0 || bookings[0].Totalpackagecount != 0 {
|
||||
t.Errorf("booking 11 picked up booking 77's counts: got %d/%d, want 0/0",
|
||||
bookings[0].Destinationcount, bookings[0].Totalpackagecount)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty inputs are the ordinary case on an empty page, not an error.
|
||||
func TestApplyDestinationCountsHandlesEmptyInputs(t *testing.T) {
|
||||
applyDestinationCounts(nil, nil)
|
||||
applyDestinationCounts([]models.PickupBooking{}, []bookingDestinationCounts{{Bookingid: 1}})
|
||||
|
||||
bookings := []models.PickupBooking{{Bookingid: 11, Destinationcount: 0}}
|
||||
applyDestinationCounts(bookings, nil)
|
||||
if bookings[0].Destinationcount != 0 {
|
||||
t.Errorf("no counts should leave the booking at 0, got %d", bookings[0].Destinationcount)
|
||||
}
|
||||
}
|
||||
|
||||
// A single-destination customer booking must report 1, not 0. The console
|
||||
// branches on destinationcount > 1 to decide whether to show the summary line,
|
||||
// and 1 is what keeps a B2C row reading as it does today.
|
||||
func TestSingleDestinationBookingReportsOne(t *testing.T) {
|
||||
bookings := []models.PickupBooking{{Bookingid: 11}}
|
||||
counts := []bookingDestinationCounts{{Bookingid: 11, Destinationcount: 1, Totalpackagecount: 2}}
|
||||
|
||||
applyDestinationCounts(bookings, counts)
|
||||
|
||||
if bookings[0].Destinationcount != 1 || bookings[0].Totalpackagecount != 2 {
|
||||
t.Errorf("got %d/%d, want 1/2", bookings[0].Destinationcount, bookings[0].Totalpackagecount)
|
||||
}
|
||||
}
|
||||
88
controllers/admin_pagesize_test.go
Normal file
88
controllers/admin_pagesize_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// The page-size ceiling on the admin list endpoints.
|
||||
//
|
||||
// GetAdminBookings and GetAdminCustomers clamped `pagesize` to a hard-coded
|
||||
// 100 while utils.ParsePage allowed 1000. The console does not read one page —
|
||||
// it DRAINS the list, and it asks for 1000 a page. Being handed 100 meant ten
|
||||
// times the round trips for the same rows, and because the drain has its own
|
||||
// page budget (12), the list it renders stopped at 1,200 bookings. Past that
|
||||
// the counts on the screen are floors presented as totals.
|
||||
//
|
||||
// The ceiling now comes from utils.MaxPageSize so the two cannot drift apart
|
||||
// again. These tests pin the clamp arithmetic directly: exercising the handlers
|
||||
// themselves needs Postgres, and this is the part that was wrong.
|
||||
|
||||
// clampPageSize mirrors the expression in the handlers. If the handlers change,
|
||||
// this stops matching and the tests below stop meaning anything — which is why
|
||||
// TestHandlersUseTheSharedCeiling reads the source instead of trusting it.
|
||||
func clampPageSize(requested int) int {
|
||||
return min(utils.MaxPageSize, max(1, requested))
|
||||
}
|
||||
|
||||
func TestPageSizeCeilingComesFromTheSharedConstant(t *testing.T) {
|
||||
if utils.MaxPageSize <= 100 {
|
||||
t.Fatalf("utils.MaxPageSize = %d: raising the clamp to it is pointless if it "+
|
||||
"is not above the old hard-coded 100", utils.MaxPageSize)
|
||||
}
|
||||
|
||||
// The exact request the console makes on every drain page.
|
||||
if got := clampPageSize(1000); got != 1000 {
|
||||
t.Errorf("pagesize=1000 clamped to %d — the console asks for exactly this and "+
|
||||
"a smaller answer is what caps its drain at 1,200 rows", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageSizeClampBounds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
requested int
|
||||
want int
|
||||
}{
|
||||
{"console drain page", 1000, 1000},
|
||||
{"above the ceiling is capped", 999999, utils.MaxPageSize},
|
||||
{"at the ceiling", utils.MaxPageSize, utils.MaxPageSize},
|
||||
{"one below the ceiling", utils.MaxPageSize - 1, utils.MaxPageSize - 1},
|
||||
{"zero floors to one", 0, 1},
|
||||
{"negative floors to one", -50, 1},
|
||||
{"one stays one", 1, 1},
|
||||
{"the old ceiling still works", 100, 100},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := clampPageSize(tc.requested); got != tc.want {
|
||||
t.Errorf("clampPageSize(%d) = %d, want %d", tc.requested, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A caller that does not ask for a page size must keep exactly the response it
|
||||
// gets today. Widening the ceiling must not widen the default: every client
|
||||
// that never passed ?pagesize would suddenly be handed 50x the rows.
|
||||
func TestDefaultPageSizeIsUnchangedByTheWiderCeiling(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Get("/probe", func(c *fiber.Ctx) error {
|
||||
// The same default the handlers pass to QueryInt.
|
||||
if got := c.QueryInt("pagesize", 20); got != 20 {
|
||||
t.Errorf("absent pagesize resolved to %d, want the unchanged default of 20", got)
|
||||
}
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
resp, err := app.Test(httptest.NewRequest("GET", "/probe", nil), 5000)
|
||||
if err != nil {
|
||||
t.Fatalf("probe request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/internal/cxstage"
|
||||
"doormile/internal/notify"
|
||||
"doormile/internal/routing"
|
||||
"doormile/models"
|
||||
@@ -71,6 +72,21 @@ func assignMilerTx(tx *gorm.DB, bookingID, milerUserID int, assignedByUserID *in
|
||||
return nil, fmt.Errorf("failed to update miler availability: %w", err)
|
||||
}
|
||||
|
||||
// The customer's "Miler assigned" milestone, recorded where the assignment
|
||||
// is actually created rather than where a rider taps Accept. A rider who
|
||||
// never opens the app would otherwise leave the customer watching "finding
|
||||
// a Miler" while ops has the booking down as assigned — two surfaces
|
||||
// disagreeing about the same fact.
|
||||
if err := cxstage.Record(tx, cxstage.Event{
|
||||
BookingID: booking.Bookingid,
|
||||
Stage: constants.CxStageAssigned,
|
||||
ActorType: constants.CxActorOps,
|
||||
ActorID: assignedByUserID,
|
||||
Source: "assignMilerTx",
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to record the assigned stage: %w", err)
|
||||
}
|
||||
|
||||
return &booking, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/dto"
|
||||
"doormile/internal/assignment"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func generateBookingNo() string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("DM-BK-%X-%d", b, time.Now().Unix()%100000)
|
||||
}
|
||||
// Customer profile and saved addresses.
|
||||
//
|
||||
// The rest of the customer surface — auth, catalogue, estimate, bookings,
|
||||
// tracking, places, devices — lives in the cx*Controller.go files and answers
|
||||
// in the customer envelope (utils.CxOK / utils.CxFail). The PIN login,
|
||||
// single-destination booking create/list/detail/cancel and the /customer/track
|
||||
// read that used to live here were replaced by that surface, not moved: a
|
||||
// customer books a pickup with 1..N destinations now, and there is no shape in
|
||||
// which the old single-address request is still a valid booking.
|
||||
|
||||
func calculateDistance(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const R = 6371.0
|
||||
@@ -40,190 +37,15 @@ func calculateVolumetricWeight(length, width, height float64) float64 {
|
||||
return (length * width * height) / 5000.0
|
||||
}
|
||||
|
||||
func RegisterCustomer(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
req := new(dto.CustomerRegisterRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
|
||||
if req.Phone == "" || req.Firstname == "" || req.Pin == "" {
|
||||
return utils.BadRequest(c, "phone, firstname, and pin are required")
|
||||
}
|
||||
|
||||
pinHash, err := utils.HashPassword(req.Pin)
|
||||
if err != nil {
|
||||
return utils.Internal(c, "failed to process registration")
|
||||
}
|
||||
|
||||
configID := req.Configid
|
||||
if configID == 0 {
|
||||
configID = 1001
|
||||
}
|
||||
|
||||
var existing models.AppCustomer
|
||||
if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&existing).Error; err == nil {
|
||||
return utils.Conflict(c, "a customer with this phone number already exists")
|
||||
}
|
||||
|
||||
customer := models.AppCustomer{
|
||||
Firstname: req.Firstname,
|
||||
Lastname: req.Lastname,
|
||||
Phone: req.Phone,
|
||||
Email: req.Email,
|
||||
Loginpinhash: pinHash,
|
||||
Status: "Active",
|
||||
Configid: configID,
|
||||
}
|
||||
|
||||
if err := db.DB.Create(&customer).Error; err != nil {
|
||||
return utils.Internal(c, "failed to register customer")
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(customer.Appcustomerid, customer.Phone, 9, 0, customer.Configid, cfg.JWTSecret)
|
||||
if err != nil {
|
||||
return utils.Internal(c, "registration successful but failed to generate token")
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"success": true,
|
||||
"token": token,
|
||||
"user": customer,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func LoginCustomer(c *fiber.Ctx) error {
|
||||
req := new(dto.CustomerLoginRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
|
||||
if req.Phone == "" {
|
||||
return utils.BadRequest(c, "phone is required")
|
||||
}
|
||||
|
||||
configID := req.Configid
|
||||
if configID == 0 {
|
||||
configID = 1001
|
||||
}
|
||||
|
||||
var customer models.AppCustomer
|
||||
if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil {
|
||||
return utils.NotFound(c, "no account found for this phone number")
|
||||
}
|
||||
|
||||
if customer.Status == "Blocked" {
|
||||
return utils.Forbidden(c, "this account has been blocked")
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"message": "PIN verification required",
|
||||
"phone": req.Phone,
|
||||
})
|
||||
}
|
||||
|
||||
func VerifyCustomerPin(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
req := new(dto.CustomerPinVerifyRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
|
||||
if req.Phone == "" || req.Pin == "" {
|
||||
return utils.BadRequest(c, "phone and pin are required")
|
||||
}
|
||||
|
||||
configID := req.Configid
|
||||
if configID == 0 {
|
||||
configID = 1001
|
||||
}
|
||||
|
||||
var customer models.AppCustomer
|
||||
if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil {
|
||||
return utils.NotFound(c, "customer not found")
|
||||
}
|
||||
|
||||
if !utils.CheckPasswordHash(req.Pin, customer.Loginpinhash) {
|
||||
return utils.Unauthorized(c, "incorrect PIN")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
customer.Lastloginat = &now
|
||||
if req.DeviceToken != "" {
|
||||
customer.Devicetoken = req.DeviceToken
|
||||
}
|
||||
db.DB.Save(&customer)
|
||||
|
||||
token, err := utils.GenerateToken(customer.Appcustomerid, customer.Phone, 9, 0, customer.Configid, cfg.JWTSecret)
|
||||
if err != nil {
|
||||
return utils.Internal(c, "failed to generate token")
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"token": token,
|
||||
"user": customer,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ResetCustomerPin(c *fiber.Ctx) error {
|
||||
req := new(dto.CustomerResetPinRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
|
||||
if req.Phone == "" || req.NewPin == "" {
|
||||
return utils.BadRequest(c, "phone and new_pin are required")
|
||||
}
|
||||
|
||||
configID := req.Configid
|
||||
if configID == 0 {
|
||||
configID = 1001
|
||||
}
|
||||
|
||||
var customer models.AppCustomer
|
||||
if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil {
|
||||
return utils.NotFound(c, "customer not found")
|
||||
}
|
||||
|
||||
// Proof of identity is required before overwriting a login credential.
|
||||
// Without it this endpoint reset any customer's PIN from their phone number
|
||||
// alone — and phone numbers are the login identifier, not a secret — so
|
||||
// reset-pin followed by verify-pin was a complete account takeover.
|
||||
// The caller must first pass /customer/send-email-otp and
|
||||
// /customer/verify-email-otp for this account's registered address.
|
||||
if customer.Email == "" {
|
||||
return utils.Forbidden(c, "this account has no registered email to verify against — contact support to reset the PIN")
|
||||
}
|
||||
if !ConsumeEmailVerification(customer.Email) {
|
||||
return utils.Forbidden(c, "verify your registered email first via /customer/send-email-otp and /customer/verify-email-otp")
|
||||
}
|
||||
|
||||
pinHash, err := utils.HashPassword(req.NewPin)
|
||||
if err != nil {
|
||||
return utils.Internal(c, "failed to process PIN reset")
|
||||
}
|
||||
|
||||
customer.Loginpinhash = pinHash
|
||||
if err := db.DB.Save(&customer).Error; err != nil {
|
||||
return utils.Internal(c, "failed to reset PIN")
|
||||
}
|
||||
|
||||
return utils.Message(c, "PIN reset successfully")
|
||||
}
|
||||
|
||||
func GetCustomerProfile(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
|
||||
var customer models.AppCustomer
|
||||
if err := db.DB.First(&customer, customerID).Error; err != nil {
|
||||
return utils.NotFound(c, "profile not found")
|
||||
return utils.CxNotFound(c, "We could not find your profile")
|
||||
}
|
||||
|
||||
return utils.OK(c, customer)
|
||||
return utils.CxOK(c, renderCustomer(&customer))
|
||||
}
|
||||
|
||||
func UpdateCustomerProfile(c *fiber.Ctx) error {
|
||||
@@ -231,76 +53,91 @@ func UpdateCustomerProfile(c *fiber.Ctx) error {
|
||||
|
||||
var customer models.AppCustomer
|
||||
if err := db.DB.First(&customer, customerID).Error; err != nil {
|
||||
return utils.NotFound(c, "profile not found")
|
||||
return utils.CxNotFound(c, "We could not find your profile")
|
||||
}
|
||||
|
||||
type ProfileUpdate struct {
|
||||
Firstname string `json:"firstname"`
|
||||
Lastname string `json:"lastname"`
|
||||
Email string `json:"email"`
|
||||
Defaultlatitude float64 `json:"defaultlatitude"`
|
||||
Defaultlongitude float64 `json:"defaultlongitude"`
|
||||
Defaultpincode string `json:"defaultpincode"`
|
||||
// Pointer fields: an omitted key leaves the stored value alone, an explicit
|
||||
// value overwrites it. The previous version cleared email and lastname on
|
||||
// every call that did not resend them, which quietly wiped a customer's
|
||||
// email the first time they edited their name.
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Email *string `json:"email"`
|
||||
Defaultlatitude *float64 `json:"defaultLatitude"`
|
||||
Defaultlongitude *float64 `json:"defaultLongitude"`
|
||||
Defaultpincode *string `json:"defaultPincode"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
|
||||
req := new(ProfileUpdate)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
if req.Name != nil {
|
||||
first, last := splitName(*req.Name)
|
||||
if first == "" {
|
||||
return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name")
|
||||
}
|
||||
customer.Firstname, customer.Lastname = first, last
|
||||
}
|
||||
|
||||
if req.Firstname != "" {
|
||||
customer.Firstname = req.Firstname
|
||||
if req.Email != nil {
|
||||
customer.Email = *req.Email
|
||||
}
|
||||
customer.Lastname = req.Lastname
|
||||
customer.Email = req.Email
|
||||
if req.Defaultlatitude != 0 {
|
||||
customer.Defaultlatitude = req.Defaultlatitude
|
||||
if req.Defaultlatitude != nil {
|
||||
customer.Defaultlatitude = *req.Defaultlatitude
|
||||
}
|
||||
if req.Defaultlongitude != 0 {
|
||||
customer.Defaultlongitude = req.Defaultlongitude
|
||||
if req.Defaultlongitude != nil {
|
||||
customer.Defaultlongitude = *req.Defaultlongitude
|
||||
}
|
||||
if req.Defaultpincode != "" {
|
||||
customer.Defaultpincode = req.Defaultpincode
|
||||
if req.Defaultpincode != nil {
|
||||
customer.Defaultpincode = *req.Defaultpincode
|
||||
}
|
||||
|
||||
if err := db.DB.Save(&customer).Error; err != nil {
|
||||
return utils.Internal(c, "failed to update profile")
|
||||
utils.Error("UpdateCustomerProfile: save failed", "customer_id", customerID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
return utils.OK(c, customer)
|
||||
return utils.CxOK(c, renderCustomer(&customer))
|
||||
}
|
||||
|
||||
func GetCustomerLocations(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
|
||||
var locations []models.AppCustomerLocation
|
||||
if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active").Find(&locations).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch locations")
|
||||
if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active").
|
||||
Order("isdefault DESC, appcustomerlocationid DESC").Find(&locations).Error; err != nil {
|
||||
utils.Error("GetCustomerLocations: query failed", "customer_id", customerID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
return utils.List(c, locations, int64(len(locations)))
|
||||
out := make([]fiber.Map, 0, len(locations))
|
||||
for i := range locations {
|
||||
out = append(out, renderSavedAddress(&locations[i]))
|
||||
}
|
||||
return utils.CxList(c, out, len(out), nil)
|
||||
}
|
||||
|
||||
func CreateCustomerLocation(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
|
||||
var count int64
|
||||
db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ? AND status = ?", customerID, "Active").Count(&count)
|
||||
db.DB.Model(&models.AppCustomerLocation{}).
|
||||
Where("appcustomerid = ? AND status = ?", customerID, "Active").Count(&count)
|
||||
if count >= 10 {
|
||||
return utils.BadRequest(c, "maximum of 10 saved locations allowed")
|
||||
return utils.CxBadRequest(c, "You can save up to 10 addresses")
|
||||
}
|
||||
|
||||
req := new(dto.LocationCreateRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
|
||||
if req.Address == "" || req.Pincode == "" || req.Latitude == 0 || req.Longitude == 0 {
|
||||
return utils.BadRequest(c, "address, pincode, latitude, and longitude are required")
|
||||
return utils.CxBadRequest(c, "An address needs a street, a pincode and a map location")
|
||||
}
|
||||
|
||||
if req.Isdefault {
|
||||
db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ?", customerID).Update("isdefault", false)
|
||||
db.DB.Model(&models.AppCustomerLocation{}).
|
||||
Where("appcustomerid = ?", customerID).Update("isdefault", false)
|
||||
}
|
||||
|
||||
location := models.AppCustomerLocation{
|
||||
@@ -320,27 +157,29 @@ func CreateCustomerLocation(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
if err := db.DB.Create(&location).Error; err != nil {
|
||||
return utils.Internal(c, "failed to save location")
|
||||
utils.Error("CreateCustomerLocation: insert failed", "customer_id", customerID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
return utils.Created(c, location)
|
||||
return utils.CxCreated(c, renderSavedAddress(&location))
|
||||
}
|
||||
|
||||
func UpdateCustomerLocation(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
locationID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return utils.BadRequest(c, "invalid location ID")
|
||||
return utils.CxBadRequest(c, "That address could not be found")
|
||||
}
|
||||
|
||||
var location models.AppCustomerLocation
|
||||
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).First(&location).Error; err != nil {
|
||||
return utils.NotFound(c, "location not found")
|
||||
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).
|
||||
First(&location).Error; err != nil {
|
||||
return utils.CxNotFound(c, "That address could not be found")
|
||||
}
|
||||
|
||||
req := new(dto.LocationCreateRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
|
||||
if req.Label != "" {
|
||||
@@ -366,335 +205,58 @@ func UpdateCustomerLocation(c *fiber.Ctx) error {
|
||||
location.Isdefault = req.Isdefault
|
||||
|
||||
if req.Isdefault {
|
||||
db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ?", customerID).Update("isdefault", false)
|
||||
db.DB.Model(&models.AppCustomerLocation{}).
|
||||
Where("appcustomerid = ?", customerID).Update("isdefault", false)
|
||||
}
|
||||
|
||||
if err := db.DB.Save(&location).Error; err != nil {
|
||||
return utils.Internal(c, "failed to update location")
|
||||
utils.Error("UpdateCustomerLocation: save failed", "location_id", locationID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
return utils.OK(c, location)
|
||||
return utils.CxOK(c, renderSavedAddress(&location))
|
||||
}
|
||||
|
||||
func DeleteCustomerLocation(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
locationID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return utils.BadRequest(c, "invalid location ID")
|
||||
return utils.CxBadRequest(c, "That address could not be found")
|
||||
}
|
||||
|
||||
var location models.AppCustomerLocation
|
||||
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).First(&location).Error; err != nil {
|
||||
return utils.NotFound(c, "location not found")
|
||||
if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).
|
||||
First(&location).Error; err != nil {
|
||||
return utils.CxNotFound(c, "That address could not be found")
|
||||
}
|
||||
|
||||
location.Status = "InActive"
|
||||
db.DB.Save(&location)
|
||||
if err := db.DB.Save(&location).Error; err != nil {
|
||||
utils.Error("DeleteCustomerLocation: save failed", "location_id", locationID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
return utils.Message(c, "location deleted successfully")
|
||||
return utils.CxOK(c, fiber.Map{"id": strconv.Itoa(location.Appcustomerlocationid), "deleted": true})
|
||||
}
|
||||
|
||||
func CreateCustomerBooking(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
|
||||
req := new(dto.PickupBookingRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
// renderSavedAddress is the one shape a saved address is returned in, matching
|
||||
// the two-line title/sub the pickup search and the booking pickup block use —
|
||||
// so an address picked from Saved and one picked from search are the same
|
||||
// object to the client.
|
||||
func renderSavedAddress(l *models.AppCustomerLocation) fiber.Map {
|
||||
title := l.Label
|
||||
if title == "" {
|
||||
title = l.Address
|
||||
}
|
||||
|
||||
if req.Pickupaddress == "" || req.Pickuppincode == "" {
|
||||
return utils.BadRequest(c, "pickup address and pincode are required")
|
||||
return fiber.Map{
|
||||
"id": strconv.Itoa(l.Appcustomerlocationid),
|
||||
"label": l.Label,
|
||||
"title": title,
|
||||
"sub": joinNonEmpty(", ", l.Address, l.Landmark, l.City, l.Pincode),
|
||||
"recipientName": l.Receivername,
|
||||
"recipientPhone": l.Receiverphone,
|
||||
"lat": l.Latitude,
|
||||
"lng": l.Longitude,
|
||||
"isDefault": l.Isdefault,
|
||||
}
|
||||
|
||||
if len(req.Parcels) == 0 {
|
||||
return utils.BadRequest(c, "at least one parcel is required")
|
||||
}
|
||||
|
||||
// Geocode delivery pincode to lat/lon when the app doesn't supply coordinates.
|
||||
if req.Deliverylatitude == 0 && req.Deliverylongitude == 0 && req.Deliverypincode != "" {
|
||||
if lat, lon, ok := pincodeToLatLon(req.Deliverypincode); ok {
|
||||
req.Deliverylatitude = lat
|
||||
req.Deliverylongitude = lon
|
||||
}
|
||||
}
|
||||
|
||||
tx := db.DB.Begin()
|
||||
|
||||
booking := models.PickupBooking{
|
||||
Bookingno: generateBookingNo(),
|
||||
Appcustomerid: customerID,
|
||||
Pickuplocationid: req.Pickuplocationid,
|
||||
Pickupaddress: req.Pickupaddress,
|
||||
Pickuppincode: req.Pickuppincode,
|
||||
Pickuplatitude: req.Pickuplatitude,
|
||||
Pickuplongitude: req.Pickuplongitude,
|
||||
Deliveryaddress: req.Deliveryaddress,
|
||||
Deliverypincode: req.Deliverypincode,
|
||||
Deliverylatitude: req.Deliverylatitude,
|
||||
Deliverylongitude: req.Deliverylongitude,
|
||||
Bookingsource: "Customer_App",
|
||||
Status: constants.BookingPendingPickup,
|
||||
Preferredpickupfrom: req.Preferredpickupfrom,
|
||||
Preferredpickupto: req.Preferredpickupto,
|
||||
}
|
||||
|
||||
if err := tx.Create(&booking).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to create booking")
|
||||
}
|
||||
|
||||
var totalWeight float64
|
||||
var totalVolume float64
|
||||
var requiresLargeVehicle bool
|
||||
for _, p := range req.Parcels {
|
||||
volumetric := calculateVolumetricWeight(p.Length, p.Width, p.Height)
|
||||
totalWeight += math.Max(p.Weight, volumetric)
|
||||
totalVolume += p.Length * p.Width * p.Height
|
||||
|
||||
parcel := models.BookingParcel{
|
||||
Bookingid: booking.Bookingid,
|
||||
Itemcategory: p.Itemcategory,
|
||||
Itemdescription: p.Itemdescription,
|
||||
Declaredvalue: p.Declaredvalue,
|
||||
Weight: p.Weight,
|
||||
Length: p.Length,
|
||||
Width: p.Width,
|
||||
Height: p.Height,
|
||||
Isfragile: p.Isfragile,
|
||||
Needsinsurance: p.Needsinsurance,
|
||||
Requireslargevehicle: p.Requireslargevehicle,
|
||||
}
|
||||
|
||||
if p.Requireslargevehicle {
|
||||
requiresLargeVehicle = true
|
||||
}
|
||||
|
||||
if p.Needsinsurance {
|
||||
parcel.Insuranceamount = p.Declaredvalue * 0.01
|
||||
}
|
||||
|
||||
if err := tx.Create(&parcel).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to save parcel details")
|
||||
}
|
||||
}
|
||||
|
||||
serviceType := req.ServiceOption
|
||||
if serviceType == "" {
|
||||
serviceType = "Normal"
|
||||
}
|
||||
|
||||
zone := resolveZone(req.Pickuppincode, req.Deliverypincode)
|
||||
itemCategory := normalizePricingCategory(req.Parcels[0].Itemcategory)
|
||||
|
||||
var estimatedPrice float64
|
||||
var pricingID *int
|
||||
if price, pid, found := lookupDoormilePrice(zone, mapServiceTypeToPricing(serviceType), totalWeight, itemCategory); found {
|
||||
estimatedPrice = price
|
||||
pricingID = pid
|
||||
} else {
|
||||
var distance float64
|
||||
if booking.Deliverylatitude != 0 && booking.Deliverylongitude != 0 {
|
||||
distance = calculateDistance(booking.Pickuplatitude, booking.Pickuplongitude, booking.Deliverylatitude, booking.Deliverylongitude)
|
||||
}
|
||||
estimatedPrice = 50.0 + (distance * 5.0) + (totalWeight * 10.0)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
estDelivery := now.Add(24 * time.Hour)
|
||||
slaDue := now.Add(36 * time.Hour)
|
||||
if serviceType == "Fast" {
|
||||
estDelivery = now.Add(12 * time.Hour)
|
||||
slaDue = now.Add(18 * time.Hour)
|
||||
} else if serviceType == "Superfast" {
|
||||
estDelivery = now.Add(6 * time.Hour)
|
||||
slaDue = now.Add(9 * time.Hour)
|
||||
}
|
||||
|
||||
srvOption := models.BookingServiceOption{
|
||||
Bookingid: booking.Bookingid,
|
||||
Servicetype: serviceType,
|
||||
Estimatedprice: estimatedPrice,
|
||||
Pricingid: pricingID,
|
||||
Estimateddeliveryat: &estDelivery,
|
||||
Sladueat: &slaDue,
|
||||
}
|
||||
|
||||
if err := tx.Create(&srvOption).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to save service option")
|
||||
}
|
||||
|
||||
if requiresLargeVehicle || totalVolume > 0 && totalWeight > 20.0 {
|
||||
reqVeh := models.BookingVehicleRequirement{
|
||||
Bookingid: booking.Bookingid,
|
||||
Requiredvehicletype: "truck",
|
||||
Reason: "Oversized package / heavy weight",
|
||||
Status: "Required",
|
||||
}
|
||||
if err := tx.Create(&reqVeh).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to save vehicle requirement")
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to create booking")
|
||||
}
|
||||
|
||||
go assignment.AssignCustomerMiler(booking.Bookingid)
|
||||
|
||||
if db.Js != nil {
|
||||
payload := map[string]interface{}{
|
||||
"booking_id": booking.Bookingid,
|
||||
"booking_no": booking.Bookingno,
|
||||
"customer_id": booking.Appcustomerid,
|
||||
"pickup_address": booking.Pickupaddress,
|
||||
"pickup_pincode": booking.Pickuppincode,
|
||||
"delivery_address": booking.Deliveryaddress,
|
||||
"delivery_pincode": booking.Deliverypincode,
|
||||
"status": constants.BookingPendingPickup,
|
||||
"created_at": time.Now().UnixMilli(),
|
||||
}
|
||||
if data, err := json.Marshal(payload); err == nil {
|
||||
if _, err := db.Js.Publish("api.v1.bookings.create", data); err != nil {
|
||||
utils.Warn("Failed to publish booking.create to NATS", "booking_id", booking.Bookingid, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, booking.Bookingid)
|
||||
|
||||
return utils.Created(c, booking)
|
||||
}
|
||||
|
||||
func GetCustomerBookings(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
|
||||
var bookings []models.PickupBooking
|
||||
if err := db.DB.Preload("Parcels").Preload("ServiceOptions").Where("appcustomerid = ?", customerID).Order("createdat DESC").Find(&bookings).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch bookings")
|
||||
}
|
||||
|
||||
return utils.List(c, bookings, int64(len(bookings)))
|
||||
}
|
||||
|
||||
func GetCustomerBookingDetails(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
||||
if err != nil {
|
||||
return utils.BadRequest(c, "invalid booking ID")
|
||||
}
|
||||
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.Preload("Parcels").Preload("ServiceOptions").Preload("Payments").Where("bookingid = ? AND appcustomerid = ?", bookingID, customerID).First(&booking).Error; err != nil {
|
||||
return utils.NotFound(c, "booking not found")
|
||||
}
|
||||
|
||||
return utils.OK(c, booking)
|
||||
}
|
||||
|
||||
func CancelCustomerBooking(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
||||
if err != nil {
|
||||
return utils.BadRequest(c, "invalid booking ID")
|
||||
}
|
||||
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.Where("bookingid = ? AND appcustomerid = ?", bookingID, customerID).First(&booking).Error; err != nil {
|
||||
return utils.NotFound(c, "booking not found")
|
||||
}
|
||||
|
||||
if booking.Status == constants.BookingPickedUp || booking.Status == constants.BookingConvertedConsignment {
|
||||
return utils.BadRequest(c, "booking cannot be cancelled after the package has been picked up")
|
||||
}
|
||||
|
||||
booking.Status = constants.BookingCancelled
|
||||
booking.Updatedat = time.Now()
|
||||
db.DB.Save(&booking)
|
||||
|
||||
if db.Js != nil {
|
||||
payload := map[string]interface{}{
|
||||
"booking_id": booking.Bookingid,
|
||||
"booking_no": booking.Bookingno,
|
||||
"customer_id": booking.Appcustomerid,
|
||||
"status": "Cancelled",
|
||||
"cancelled_at": time.Now().UnixMilli(),
|
||||
}
|
||||
if data, err := json.Marshal(payload); err == nil {
|
||||
if _, err := db.Js.Publish("api.v1.bookings.cancel", data); err != nil {
|
||||
utils.Warn("Failed to publish booking.cancel to NATS", "booking_id", booking.Bookingid, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return utils.OK(c, booking)
|
||||
}
|
||||
|
||||
func GetCustomerBookingQuote(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
||||
if err != nil {
|
||||
return utils.BadRequest(c, "invalid booking ID")
|
||||
}
|
||||
|
||||
// Ownership is checked here as it is on the other booking routes — without
|
||||
// it any signed-in customer could read the price quoted on anyone else's
|
||||
// booking just by walking the id.
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.Select("bookingid").
|
||||
Where("bookingid = ? AND appcustomerid = ?", bookingID, customerID).
|
||||
First(&booking).Error; err != nil {
|
||||
return utils.NotFound(c, "booking not found")
|
||||
}
|
||||
|
||||
var serviceOpt models.BookingServiceOption
|
||||
if err := db.DB.Where("bookingid = ?", bookingID).Order("createdat DESC").First(&serviceOpt).Error; err != nil {
|
||||
return utils.NotFound(c, "price quote not found for this booking")
|
||||
}
|
||||
|
||||
return utils.OK(c, serviceOpt)
|
||||
}
|
||||
|
||||
func TrackConsignment(c *fiber.Ctx) error {
|
||||
trackingNo := c.Params("trackingno")
|
||||
if trackingNo == "" {
|
||||
return utils.BadRequest(c, "tracking number is required")
|
||||
}
|
||||
|
||||
var consignment models.Consignment
|
||||
if err := db.DB.Where("trackingno = ?", trackingNo).First(&consignment).Error; err != nil {
|
||||
return utils.NotFound(c, "no shipment found for this tracking number")
|
||||
}
|
||||
|
||||
var history []models.ConsignmentHistory
|
||||
db.DB.Where("consignmentid = ?", consignment.Consignmentid).Order("createdat DESC").Find(&history)
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignment": consignment,
|
||||
"history": history,
|
||||
})
|
||||
}
|
||||
|
||||
func SaveCustomerDeviceToken(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
|
||||
var req struct {
|
||||
DeviceToken string `json:"device_token"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
if req.DeviceToken == "" {
|
||||
return utils.BadRequest(c, "device_token is required")
|
||||
}
|
||||
|
||||
if err := db.DB.Model(&models.AppCustomer{}).
|
||||
Where("appcustomerid = ?", customerID).
|
||||
Update("device_token", req.DeviceToken).Error; err != nil {
|
||||
return utils.Internal(c, "failed to save device token")
|
||||
}
|
||||
|
||||
return utils.Message(c, "device token saved")
|
||||
}
|
||||
|
||||
614
controllers/cxAuthController.go
Normal file
614
controllers/cxAuthController.go
Normal file
@@ -0,0 +1,614 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/internal/mail"
|
||||
"doormile/internal/sms"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Customer authentication — §4 of the contract.
|
||||
//
|
||||
// A 4-digit code to a phone or an email address, and no password anywhere. This
|
||||
// is deliberately NOT the miler's phone+PIN flow: /miler/verify-pin exists and
|
||||
// is not reused. A PIN is a stored secret a rider sets once and a console can
|
||||
// reset, which is appropriate for a fleet of known employees; a customer base is
|
||||
// not that, and the previous customer PIN flow shipped a reset endpoint that
|
||||
// took over any account from a phone number alone.
|
||||
//
|
||||
// Every response here goes in `data`, auth included. /miler/verify-pin returns
|
||||
// its payload outside the envelope and that inconsistency cost the miler client
|
||||
// a release to discover — it is not repeated.
|
||||
|
||||
const (
|
||||
cxOtpTTL = 5 * time.Minute
|
||||
cxOtpLength = 4
|
||||
cxResendWait = 30 * time.Second
|
||||
// cxOtpMaxVerify is attempts per issued code. Three, then the code dies —
|
||||
// a 4-digit code is 10,000 combinations and generous retries make it
|
||||
// walkable.
|
||||
cxOtpMaxVerify = 3
|
||||
// cxOtpMaxRequests is codes per identifier per hour, so an attacker cannot
|
||||
// mint fresh codes to reset the attempt counter, and a victim cannot be
|
||||
// flooded with texts.
|
||||
cxOtpMaxRequests = 5
|
||||
cxOtpRequestWin = time.Hour
|
||||
|
||||
cxAccessTTL = time.Hour
|
||||
cxRefreshTTL = 60 * 24 * time.Hour
|
||||
|
||||
// cxCustomerRoleID is role 9 across this codebase.
|
||||
cxCustomerRoleID = 9
|
||||
cxDefaultConfig = 1001
|
||||
)
|
||||
|
||||
// ── Identifier handling ──────────────────────────────────────────────────────
|
||||
|
||||
// normalizeIdentifier canonicalises what the customer typed into either an
|
||||
// E.164 phone number or a lowercased email address.
|
||||
//
|
||||
// The app currently sends "+91 98765 43210" with spaces and is being tightened
|
||||
// to send E.164; both are accepted, and both must land on the SAME stored
|
||||
// value, or a customer signing in from a newer build gets a second account.
|
||||
func normalizeIdentifier(raw string) (identifier, kind string, ok bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if strings.Contains(raw, "@") {
|
||||
email := strings.ToLower(raw)
|
||||
// Cheap structural check only. Deliverability is proven by the code
|
||||
// arriving, not by a regex.
|
||||
at := strings.Index(email, "@")
|
||||
if at < 1 || at == len(email)-1 || !strings.Contains(email[at:], ".") {
|
||||
return "", "", false
|
||||
}
|
||||
return email, "email", true
|
||||
}
|
||||
return normalizePhone(raw)
|
||||
}
|
||||
|
||||
// normalizePhone reduces any of the shapes the app and support staff use to
|
||||
// E.164 for India.
|
||||
func normalizePhone(raw string) (phone, kind string, ok bool) {
|
||||
var digits strings.Builder
|
||||
plus := strings.HasPrefix(strings.TrimSpace(raw), "+")
|
||||
for _, r := range raw {
|
||||
if r >= '0' && r <= '9' {
|
||||
digits.WriteRune(r)
|
||||
}
|
||||
}
|
||||
d := digits.String()
|
||||
|
||||
switch {
|
||||
case plus && len(d) >= 11 && len(d) <= 15:
|
||||
// Already international, spaces and dashes removed.
|
||||
return "+" + d, "phone", true
|
||||
case len(d) == 10:
|
||||
// Bare national number, the common case from the keypad.
|
||||
return "+91" + d, "phone", true
|
||||
case len(d) == 12 && strings.HasPrefix(d, "91"):
|
||||
return "+" + d, "phone", true
|
||||
case len(d) == 11 && strings.HasPrefix(d, "0"):
|
||||
return "+91" + d[1:], "phone", true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// splitName turns the single name field the app collects into the first/last
|
||||
// columns appcustomers already has. A one-word name keeps an empty last name
|
||||
// rather than being rejected — plenty of people have one.
|
||||
func splitName(full string) (first, last string) {
|
||||
full = strings.Join(strings.Fields(full), " ")
|
||||
if len([]rune(full)) < 2 {
|
||||
return "", ""
|
||||
}
|
||||
if i := strings.LastIndex(full, " "); i > 0 {
|
||||
return full[:i], full[i+1:]
|
||||
}
|
||||
return full, ""
|
||||
}
|
||||
|
||||
func fullName(c *models.AppCustomer) string {
|
||||
return strings.TrimSpace(c.Firstname + " " + c.Lastname)
|
||||
}
|
||||
|
||||
// renderCustomer is the one shape the customer object is returned in — from
|
||||
// verify, from refresh and from /auth/me — so a cold-start session restore
|
||||
// cannot disagree with what sign-in returned.
|
||||
//
|
||||
// email is never null. The client types it as a non-nullable String and a null
|
||||
// throws in the parser; an unknown address is the empty string.
|
||||
func renderCustomer(c *models.AppCustomer) fiber.Map {
|
||||
return fiber.Map{
|
||||
"id": fmt.Sprintf("cust_%d", c.Appcustomerid),
|
||||
"name": fullName(c),
|
||||
"phone": c.Phone,
|
||||
"email": c.Email,
|
||||
}
|
||||
}
|
||||
|
||||
// ── OTP storage ──────────────────────────────────────────────────────────────
|
||||
|
||||
func cxOtpKey(id string) string { return "cx:otp:" + id }
|
||||
func cxOtpTriesKey(id string) string { return "cx:otp:" + id + ":tries" }
|
||||
func cxOtpSentKey(id string) string { return "cx:otp:" + id + ":sent" }
|
||||
func cxOtpRateKey(id string) string { return "cx:otp:" + id + ":requests" }
|
||||
|
||||
func cxGenerateCode() string {
|
||||
max := big.NewInt(1)
|
||||
for i := 0; i < cxOtpLength; i++ {
|
||||
max.Mul(max, big.NewInt(10))
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%0*d", cxOtpLength, n.Int64())
|
||||
}
|
||||
|
||||
// issueCxOtp mints, stores and delivers a code, enforcing both the per-hour
|
||||
// request cap and the resend cooldown. Returns the seconds the client must wait
|
||||
// before it may ask again — the countdown is server-driven so it can be changed
|
||||
// without an app release.
|
||||
func issueCxOtp(cfg *config.Config, identifier, kind string) (resendAfter int, err error) {
|
||||
if db.Rdb == nil {
|
||||
return 0, fmt.Errorf("verification service unavailable")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Cooldown first: a customer hammering Resend should be told to wait, not
|
||||
// spend one of their five hourly codes on a request that sends nothing.
|
||||
if ttl, terr := db.Rdb.TTL(ctx, cxOtpSentKey(identifier)).Result(); terr == nil && ttl > 0 {
|
||||
return int(ttl.Seconds()) + 1, errCxResendTooSoon
|
||||
}
|
||||
|
||||
count, ierr := db.Rdb.Incr(ctx, cxOtpRateKey(identifier)).Result()
|
||||
if ierr == nil && count == 1 {
|
||||
db.Rdb.Expire(ctx, cxOtpRateKey(identifier), cxOtpRequestWin)
|
||||
}
|
||||
if count > cxOtpMaxRequests {
|
||||
return int(cxOtpRequestWin.Seconds()), errCxTooManyRequests
|
||||
}
|
||||
|
||||
code := sms.StagingCode()
|
||||
if code == "" {
|
||||
code = cxGenerateCode()
|
||||
}
|
||||
if code == "" {
|
||||
return 0, fmt.Errorf("could not generate a verification code")
|
||||
}
|
||||
|
||||
if serr := db.Rdb.Set(ctx, cxOtpKey(identifier), code, cxOtpTTL).Err(); serr != nil {
|
||||
return 0, serr
|
||||
}
|
||||
db.Rdb.Del(ctx, cxOtpTriesKey(identifier))
|
||||
db.Rdb.Set(ctx, cxOtpSentKey(identifier), "1", cxResendWait)
|
||||
|
||||
if kind == "email" {
|
||||
if merr := mail.SendOTPEmail(cfg, identifier, code); merr != nil {
|
||||
utils.Warn("cx auth: failed to send OTP email", "error", merr)
|
||||
return 0, merr
|
||||
}
|
||||
} else {
|
||||
if serr := sms.SendOTP(identifier, code); serr != nil {
|
||||
utils.Warn("cx auth: failed to send OTP sms", "error", serr)
|
||||
return 0, serr
|
||||
}
|
||||
}
|
||||
|
||||
return int(cxResendWait.Seconds()), nil
|
||||
}
|
||||
|
||||
var (
|
||||
errCxResendTooSoon = fmt.Errorf("resend too soon")
|
||||
errCxTooManyRequests = fmt.Errorf("too many requests")
|
||||
)
|
||||
|
||||
// consumeCxOtp checks a submitted code and burns it. A code is single-use, and
|
||||
// a wrong answer costs one of three attempts before the code is destroyed
|
||||
// outright — otherwise a 4-digit space is walkable.
|
||||
func consumeCxOtp(identifier, submitted string) bool {
|
||||
if db.Rdb == nil {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stored, err := db.Rdb.Get(ctx, cxOtpKey(identifier)).Result()
|
||||
if err == goredis.Nil || err != nil {
|
||||
return false
|
||||
}
|
||||
if stored != submitted {
|
||||
tries, _ := db.Rdb.Incr(ctx, cxOtpTriesKey(identifier)).Result()
|
||||
db.Rdb.Expire(ctx, cxOtpTriesKey(identifier), cxOtpTTL)
|
||||
if tries >= cxOtpMaxVerify {
|
||||
db.Rdb.Del(ctx, cxOtpKey(identifier), cxOtpTriesKey(identifier))
|
||||
}
|
||||
return false
|
||||
}
|
||||
db.Rdb.Del(ctx, cxOtpKey(identifier), cxOtpTriesKey(identifier))
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// CxRequestOtp sends a sign-in code to a phone or an email address.
|
||||
//
|
||||
// It answers the same way whether or not the identifier has an account. Telling
|
||||
// an anonymous caller "no account found" — which the old /customer/login did —
|
||||
// turns this endpoint into a directory of who is registered.
|
||||
func CxRequestOtp(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Identifier string `json:"identifier"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
|
||||
identifier, kind, ok := normalizeIdentifier(req.Identifier)
|
||||
if !ok {
|
||||
return utils.CxBadRequest(c, "Enter a valid phone number or email address")
|
||||
}
|
||||
|
||||
resendAfter, err := issueCxOtp(cfg, identifier, kind)
|
||||
switch {
|
||||
case err == errCxResendTooSoon:
|
||||
// Not an error to the customer — they simply have to wait, and the
|
||||
// screen already renders a countdown.
|
||||
return utils.CxOK(c, fiber.Map{
|
||||
"sent": false,
|
||||
"resendAfterSeconds": resendAfter,
|
||||
"codeLength": cxOtpLength,
|
||||
})
|
||||
case err == errCxTooManyRequests:
|
||||
c.Set("Retry-After", fmt.Sprintf("%d", resendAfter))
|
||||
return utils.CxFail(c, fiber.StatusTooManyRequests, utils.CxErrRateLimited,
|
||||
"Too many attempts. Try again in a minute")
|
||||
case err != nil:
|
||||
utils.Error("CxRequestOtp: could not issue code", "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
return utils.CxOK(c, fiber.Map{
|
||||
"sent": true,
|
||||
"resendAfterSeconds": resendAfter,
|
||||
"codeLength": cxOtpLength,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CxSignup creates the account and sends the code in one call.
|
||||
//
|
||||
// An existing phone number is NOT an error: it is treated as a sign-in and a
|
||||
// code is sent. The app has no "account already exists" screen, and inventing
|
||||
// one here would strand a returning customer who tapped Sign up out of habit.
|
||||
func CxSignup(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
|
||||
first, last := splitName(req.Name)
|
||||
if first == "" {
|
||||
return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name")
|
||||
}
|
||||
|
||||
phone, _, ok := normalizePhone(req.Phone)
|
||||
if !ok {
|
||||
return utils.CxBadRequest(c, "Enter a valid phone number")
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
|
||||
var existing models.AppCustomer
|
||||
err := db.DB.Where("phone = ?", phone).First(&existing).Error
|
||||
if err != nil {
|
||||
// New account. It is created unverified in the sense that nothing
|
||||
// is signed in yet — the token is only issued once the code comes
|
||||
// back, so an unfinished signup leaves a row and no session.
|
||||
customer := models.AppCustomer{
|
||||
Firstname: first,
|
||||
Lastname: last,
|
||||
Phone: phone,
|
||||
Email: email,
|
||||
Status: constants.CustomerStatusActive,
|
||||
Configid: cxDefaultConfig,
|
||||
}
|
||||
if cerr := db.DB.Create(&customer).Error; cerr != nil {
|
||||
utils.Error("CxSignup: could not create customer", "error", cerr)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
} else if existing.Status == constants.CustomerStatusBlocked {
|
||||
return utils.CxForbidden(c, "You do not have access to this")
|
||||
}
|
||||
|
||||
resendAfter, ierr := issueCxOtp(cfg, phone, "phone")
|
||||
switch {
|
||||
case ierr == errCxResendTooSoon:
|
||||
return utils.CxOK(c, fiber.Map{"sent": false, "resendAfterSeconds": resendAfter})
|
||||
case ierr == errCxTooManyRequests:
|
||||
c.Set("Retry-After", fmt.Sprintf("%d", resendAfter))
|
||||
return utils.CxFail(c, fiber.StatusTooManyRequests, utils.CxErrRateLimited,
|
||||
"Too many attempts. Try again in a minute")
|
||||
case ierr != nil:
|
||||
utils.Error("CxSignup: could not issue code", "error", ierr)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
return utils.CxOK(c, fiber.Map{"sent": true, "resendAfterSeconds": resendAfter})
|
||||
}
|
||||
}
|
||||
|
||||
// CxVerifyOtp exchanges a code for a session.
|
||||
func CxVerifyOtp(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Identifier string `json:"identifier"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
|
||||
identifier, kind, ok := normalizeIdentifier(req.Identifier)
|
||||
if !ok || strings.TrimSpace(req.Code) == "" {
|
||||
return utils.CxBadRequest(c, "Enter the code we sent you")
|
||||
}
|
||||
|
||||
if !consumeCxOtp(identifier, strings.TrimSpace(req.Code)) {
|
||||
return utils.CxFail(c, fiber.StatusUnauthorized, utils.CxErrInvalidOtp, "That code did not match")
|
||||
}
|
||||
|
||||
var customer models.AppCustomer
|
||||
column := "phone"
|
||||
if kind == "email" {
|
||||
column = "email"
|
||||
}
|
||||
lookupErr := db.DB.Where(column+" = ?", identifier).First(&customer).Error
|
||||
|
||||
if lookupErr != nil {
|
||||
// Verified an identifier with no account behind it. That is a
|
||||
// signup completing, and it needs a name — the account is worth
|
||||
// nothing without one and the app collects it on the same screen.
|
||||
if kind != "phone" {
|
||||
return utils.CxNotFound(c, "We could not find an account for that address")
|
||||
}
|
||||
first, last := splitName(req.Name)
|
||||
if first == "" {
|
||||
return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name")
|
||||
}
|
||||
customer = models.AppCustomer{
|
||||
Firstname: first,
|
||||
Lastname: last,
|
||||
Phone: identifier,
|
||||
Status: constants.CustomerStatusActive,
|
||||
Configid: cxDefaultConfig,
|
||||
}
|
||||
if cerr := db.DB.Create(&customer).Error; cerr != nil {
|
||||
utils.Error("CxVerifyOtp: could not create customer", "error", cerr)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
}
|
||||
|
||||
if customer.Status == constants.CustomerStatusBlocked {
|
||||
return utils.CxForbidden(c, "You do not have access to this")
|
||||
}
|
||||
|
||||
// A name supplied on a verify for an existing account that has none
|
||||
// (possible for a row created by ops or migrated in) is accepted; it is
|
||||
// never allowed to overwrite a name already on file from a request that
|
||||
// only proves possession of the phone.
|
||||
if first, last := splitName(req.Name); first != "" && customer.Firstname == "" {
|
||||
customer.Firstname, customer.Lastname = first, last
|
||||
}
|
||||
now := time.Now()
|
||||
customer.Lastloginat = &now
|
||||
if err := db.DB.Save(&customer).Error; err != nil {
|
||||
utils.Warn("CxVerifyOtp: could not stamp last login", "error", err)
|
||||
}
|
||||
|
||||
return issueCxSession(c, cfg, &customer)
|
||||
}
|
||||
}
|
||||
|
||||
// CxRefresh rotates a refresh token for a new pair.
|
||||
//
|
||||
// Rotation, not reuse: the presented token is revoked and a new one issued, so
|
||||
// a token captured from an old device stops working the moment the real device
|
||||
// refreshes. The chain is recorded via Replacedbyid, which is what makes a
|
||||
// replayed old token identifiable rather than merely rejected.
|
||||
func CxRefresh(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
presented := strings.TrimSpace(req.RefreshToken)
|
||||
if presented == "" {
|
||||
return utils.CxUnauthorized(c, "Please sign in again")
|
||||
}
|
||||
|
||||
var row models.CustomerRefreshToken
|
||||
if err := db.DB.Where("tokenhash = ?", hashToken(presented)).First(&row).Error; err != nil {
|
||||
return utils.CxUnauthorized(c, "Please sign in again")
|
||||
}
|
||||
if row.Revokedat != nil {
|
||||
// A revoked token coming back means either a stale client or a
|
||||
// stolen one, and there is no way to tell them apart. Killing the
|
||||
// whole chain costs the honest customer one sign-in and costs an
|
||||
// attacker the session.
|
||||
utils.Warn("cx auth: revoked refresh token replayed — revoking the customer's sessions",
|
||||
"customer_id", row.Appcustomerid)
|
||||
revokeCxSessions(row.Appcustomerid)
|
||||
return utils.CxUnauthorized(c, "Please sign in again")
|
||||
}
|
||||
if utils.IST(row.Expiresat).Before(time.Now()) {
|
||||
return utils.CxUnauthorized(c, "Please sign in again")
|
||||
}
|
||||
|
||||
var customer models.AppCustomer
|
||||
if err := db.DB.First(&customer, row.Appcustomerid).Error; err != nil {
|
||||
return utils.CxUnauthorized(c, "Please sign in again")
|
||||
}
|
||||
if customer.Status == constants.CustomerStatusBlocked {
|
||||
return utils.CxForbidden(c, "You do not have access to this")
|
||||
}
|
||||
|
||||
revoked := time.Now()
|
||||
row.Revokedat = &revoked
|
||||
if err := db.DB.Save(&row).Error; err != nil {
|
||||
utils.Error("CxRefresh: could not revoke the presented token", "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
return issueCxSession(c, cfg, &customer)
|
||||
}
|
||||
}
|
||||
|
||||
// CxLogout revokes the session and unregisters the device's push token, so a
|
||||
// signed-out phone stops receiving another person's parcel updates.
|
||||
func CxLogout(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
|
||||
var req struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
DeviceToken string `json:"deviceToken"`
|
||||
}
|
||||
_ = c.BodyParser(&req)
|
||||
|
||||
now := time.Now()
|
||||
if strings.TrimSpace(req.RefreshToken) != "" {
|
||||
db.DB.Model(&models.CustomerRefreshToken{}).
|
||||
Where("tokenhash = ? AND appcustomerid = ?", hashToken(req.RefreshToken), customerID).
|
||||
Update("revokedat", now)
|
||||
} else {
|
||||
// No token supplied — sign out everywhere rather than leave a session
|
||||
// the customer believes they ended.
|
||||
revokeCxSessions(customerID)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.DeviceToken) != "" {
|
||||
db.DB.Where("appcustomerid = ? AND token = ?", customerID, req.DeviceToken).
|
||||
Delete(&models.CustomerDevice{})
|
||||
}
|
||||
|
||||
return utils.CxOK(c, fiber.Map{"signedOut": true})
|
||||
}
|
||||
|
||||
// CxMe returns the signed-in customer, for cold-start session restore.
|
||||
func CxMe(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
|
||||
var customer models.AppCustomer
|
||||
if err := db.DB.First(&customer, customerID).Error; err != nil {
|
||||
return utils.CxUnauthorized(c, "Please sign in again")
|
||||
}
|
||||
if customer.Status == constants.CustomerStatusBlocked {
|
||||
return utils.CxForbidden(c, "You do not have access to this")
|
||||
}
|
||||
return utils.CxOK(c, renderCustomer(&customer))
|
||||
}
|
||||
|
||||
// ── Session issuing ──────────────────────────────────────────────────────────
|
||||
|
||||
// issueCxSession mints the access/refresh pair and answers in the shape verify
|
||||
// and refresh both promise.
|
||||
func issueCxSession(c *fiber.Ctx, cfg *config.Config, customer *models.AppCustomer) error {
|
||||
// The JWT carries tenantid like every other token in this system. B2C
|
||||
// bookings are not attributed to a tenant yet (that is an open business
|
||||
// decision, not something to guess), so it is 0 here — but the claim is
|
||||
// present, and every customer read is scoped by appcustomerid regardless.
|
||||
access, err := utils.GenerateTokenWithTTL(
|
||||
customer.Appcustomerid, customer.Phone, cxCustomerRoleID, 0,
|
||||
customer.Configid, cfg.JWTSecret, cxAccessTTL)
|
||||
if err != nil {
|
||||
utils.Error("issueCxSession: could not mint access token", "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
refresh, err := newRefreshToken()
|
||||
if err != nil {
|
||||
utils.Error("issueCxSession: could not mint refresh token", "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
row := models.CustomerRefreshToken{
|
||||
Appcustomerid: customer.Appcustomerid,
|
||||
Tokenhash: hashToken(refresh),
|
||||
Expiresat: utils.DBNow().Add(cxRefreshTTL),
|
||||
}
|
||||
if err := db.DB.Create(&row).Error; err != nil {
|
||||
utils.Error("issueCxSession: could not store refresh token", "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
return utils.CxOK(c, fiber.Map{
|
||||
"accessToken": access,
|
||||
"refreshToken": refresh,
|
||||
"expiresIn": int(cxAccessTTL.Seconds()),
|
||||
"customer": renderCustomer(customer),
|
||||
})
|
||||
}
|
||||
|
||||
// newRefreshToken returns 32 bytes of entropy, hex encoded. Long enough that
|
||||
// guessing is not a threat model.
|
||||
func newRefreshToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// hashToken is what actually lands in the database. A refresh token is a
|
||||
// bearer credential valid for sixty days; storing it in plaintext would make a
|
||||
// database read equivalent to sixty days of account access.
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func revokeCxSessions(customerID int) {
|
||||
if err := db.DB.Model(&models.CustomerRefreshToken{}).
|
||||
Where("appcustomerid = ? AND revokedat IS NULL", customerID).
|
||||
Update("revokedat", time.Now()).Error; err != nil {
|
||||
utils.Error("revokeCxSessions: failed", "customer_id", customerID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// joinNonEmpty builds a display line from the parts that actually exist, so a
|
||||
// missing landmark does not leave ", , " in the middle of an address.
|
||||
func joinNonEmpty(sep string, parts ...string) string {
|
||||
kept := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if s := strings.TrimSpace(p); s != "" {
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, sep)
|
||||
}
|
||||
904
controllers/cxBookingController.go
Normal file
904
controllers/cxBookingController.go
Normal file
@@ -0,0 +1,904 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/internal/assignment"
|
||||
"doormile/internal/cxstage"
|
||||
"doormile/middlewares"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Bookings — §9 of the contract.
|
||||
//
|
||||
// A customer books a PICKUP: one visit, 1..N destinations, and no tracking
|
||||
// number anywhere in this file. Tracking numbers are minted per destination
|
||||
// when the miler completes the pickup, because until the parcels are in
|
||||
// someone's hands there is no shipment to track — only an intention to collect
|
||||
// one.
|
||||
|
||||
const (
|
||||
cxDefaultPageSize = 20
|
||||
cxMaxPageSize = 50
|
||||
|
||||
// cxAbsoluteMaxDestinations is a hard ceiling checked BEFORE any database
|
||||
// work, independent of the configured per-city cap.
|
||||
//
|
||||
// The configured cap (customerbookinglimits.maxdestinations) is the real
|
||||
// policy and stays authoritative — but reading it costs two queries, and
|
||||
// the district lookup above it builds a `WHERE districtcode IN (...)` from
|
||||
// however many entries the caller sent. Without a ceiling, a request
|
||||
// carrying ten thousand destinations does all of that work before anything
|
||||
// says no.
|
||||
//
|
||||
// Set well above any plausible policy so it never masks the real cap: this
|
||||
// is a sanity guard on unbounded input, not a product limit.
|
||||
cxAbsoluteMaxDestinations = 25
|
||||
)
|
||||
|
||||
type cxDetailsInput struct {
|
||||
Street *string `json:"street"`
|
||||
Building *string `json:"building"`
|
||||
Landmark *string `json:"landmark"`
|
||||
RecipientName *string `json:"recipientName"`
|
||||
RecipientPhone *string `json:"recipientPhone"`
|
||||
Instructions *string `json:"instructions"`
|
||||
Pin *struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
} `json:"pin"`
|
||||
// CodAmount is money the customer wants collected at this door on their
|
||||
// behalf. Doormile is the carrier, not the seller.
|
||||
CodAmount *float64 `json:"codAmount"`
|
||||
}
|
||||
|
||||
type cxCreateBookingRequest struct {
|
||||
Pickup struct {
|
||||
Title string `json:"title"`
|
||||
Sub string `json:"sub"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
} `json:"pickup"`
|
||||
SlotID string `json:"slotId"`
|
||||
Destinations []struct {
|
||||
StateCode string `json:"stateCode"`
|
||||
DistrictCode string `json:"districtCode"`
|
||||
PackageCount int `json:"packageCount"`
|
||||
Details *cxDetailsInput `json:"details"`
|
||||
} `json:"destinations"`
|
||||
// Estimate is what the customer was shown on Review. Recorded for dispute
|
||||
// audit — when the settled price is questioned months later, the number on
|
||||
// the screen is the fact that matters, not a re-run of today's pricing.
|
||||
Estimate *struct {
|
||||
Min int `json:"min"`
|
||||
Max int `json:"max"`
|
||||
} `json:"estimate"`
|
||||
// Remarks is the free-text note the customer adds on Review ("Handle with
|
||||
// care"). It is top-level in the documented payload
|
||||
// (docs/customer-app-api-crisp.md) and lands in PickupBooking.Notes, which
|
||||
// the admin Orders table displays and searches. Without the field here
|
||||
// BodyParser drops it silently and every customer-app booking reaches the
|
||||
// console with an empty note.
|
||||
Remarks string `json:"remarks"`
|
||||
}
|
||||
|
||||
// CreateCxBooking creates the pickup.
|
||||
func CreateCxBooking(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
|
||||
var req cxCreateBookingRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
|
||||
// "No destinations at all" and "a destination is missing its state or
|
||||
// district" are different problems with different fixes, and the customer
|
||||
// is shown this message verbatim. Telling someone who added nothing that
|
||||
// "every destination needs a serviceable state and district" describes a
|
||||
// problem they do not have and hides the one they do — they need to add a
|
||||
// destination, not correct one. The estimate endpoint already says this
|
||||
// correctly; these two now agree.
|
||||
if len(req.Destinations) == 0 {
|
||||
return utils.CxBadRequest(c, "Add at least one destination")
|
||||
}
|
||||
if strings.TrimSpace(req.SlotID) == "" {
|
||||
return utils.CxBadRequest(c, "Pick a pickup slot")
|
||||
}
|
||||
// Cheapest validation first, before anything reaches the database. A slot id
|
||||
// carries its own date, so a stale one is provably stale without a query —
|
||||
// and this is the case an app left open across midnight actually hits.
|
||||
if CxSlotDateIsPast(req.SlotID) {
|
||||
return utils.CxBadRequest(c, "That pickup time has passed — pick a new slot")
|
||||
}
|
||||
// Unbounded input, bounded before it costs anything. The configured cap
|
||||
// below is the real policy; this only stops a caller making the server do
|
||||
// three queries and build an arbitrarily long IN clause to be told no.
|
||||
if len(req.Destinations) > cxAbsoluteMaxDestinations {
|
||||
return utils.CxBadRequest(c, "That is more destinations than one pickup can carry")
|
||||
}
|
||||
|
||||
// The pickup point has to be somewhere Doormile actually collects from.
|
||||
// CityGateMiddleware sniffs the body for a `pickuppincode`, which this
|
||||
// request shape does not have — its pickup is a title/sub/lat/lng from the
|
||||
// place search — so it waves every customer booking through. The check is
|
||||
// done here, against the pincode resolved from the coordinates.
|
||||
pickupPincode := pincodeForPoint(req.Pickup.Lat, req.Pickup.Lng)
|
||||
if _, served := middlewares.PincodeInOperatingCity(pickupPincode); !served {
|
||||
return utils.CxFail(c, fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable,
|
||||
"We are not collecting from that area yet")
|
||||
}
|
||||
|
||||
appLocationID := appLocationForPoint(req.Pickup.Lat, req.Pickup.Lng)
|
||||
maxPackages, maxDestinations := CxBookingLimits(appLocationID)
|
||||
if len(req.Destinations) > maxDestinations {
|
||||
return utils.CxBadRequest(c, "Up to "+strconv.Itoa(maxDestinations)+" destinations per pickup")
|
||||
}
|
||||
|
||||
// Resolve every district in one query, then validate. Names are copied onto
|
||||
// the destination rather than joined at read time — the client renders
|
||||
// "Chennai, Tamil Nadu" straight from the booking.
|
||||
codes := make([]string, 0, len(req.Destinations))
|
||||
for _, d := range req.Destinations {
|
||||
codes = append(codes, strings.ToUpper(strings.TrimSpace(d.DistrictCode)))
|
||||
}
|
||||
var districtRows []models.ServiceableDistrict
|
||||
if err := db.DB.Where("districtcode IN ?", codes).Find(&districtRows).Error; err != nil {
|
||||
utils.Error("CreateCxBooking: district lookup failed", "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
districts := make(map[string]models.ServiceableDistrict, len(districtRows))
|
||||
for _, d := range districtRows {
|
||||
districts[d.Districtcode] = d
|
||||
}
|
||||
|
||||
stateNames, err := cxStateNames(districtRows)
|
||||
if err != nil {
|
||||
utils.Error("CreateCxBooking: state lookup failed", "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
totalPackages := 0
|
||||
for _, d := range req.Destinations {
|
||||
code := strings.ToUpper(strings.TrimSpace(d.DistrictCode))
|
||||
district, ok := districts[code]
|
||||
if !ok || strings.TrimSpace(d.StateCode) == "" {
|
||||
return utils.CxBadRequest(c, "Every destination needs a serviceable state and district")
|
||||
}
|
||||
if !district.Available {
|
||||
// The district was open when the customer picked it and closed
|
||||
// before they confirmed. A distinct code, because the app has a
|
||||
// specific recovery for it: send them back to change that one
|
||||
// destination rather than to a generic retry.
|
||||
return utils.CxFail(c, fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable,
|
||||
"That district is no longer available")
|
||||
}
|
||||
packages := d.PackageCount
|
||||
if packages < 1 {
|
||||
packages = 1
|
||||
}
|
||||
totalPackages += packages
|
||||
}
|
||||
if totalPackages > maxPackages {
|
||||
return utils.CxBadRequest(c, "Up to "+strconv.Itoa(maxPackages)+" packages per pickup")
|
||||
}
|
||||
|
||||
slotFrom, slotTo, slotOK := ResolveCxSlot(req.SlotID)
|
||||
if !slotOK {
|
||||
return utils.CxBadRequest(c, "Pick a pickup slot")
|
||||
}
|
||||
|
||||
// An EXPIRED slot and a FULL slot are different failures and must not share
|
||||
// a message. A slot id encodes its own date, so an app left open across
|
||||
// midnight — or one that cached the slot list for a session — sends
|
||||
// yesterday's window in good faith. Telling that customer the window "just
|
||||
// filled up" is untrue and points them at the wrong recovery: they need to
|
||||
// re-fetch the slot list, not try again for a place in a queue.
|
||||
//
|
||||
// 400 rather than 409 for the same reason. The contract maps 400/invalid to
|
||||
// "Pick a pickup slot", which is exactly the action required, while 409 is
|
||||
// the capacity race below.
|
||||
if !slotFrom.After(utils.ISTNow()) {
|
||||
return utils.CxBadRequest(c, "That pickup time has passed — pick a new slot")
|
||||
}
|
||||
|
||||
if !CxSlotHasCapacity(req.SlotID, req.Pickup.Lat, req.Pickup.Lng) {
|
||||
return utils.CxConflict(c, "That pickup window just filled up")
|
||||
}
|
||||
|
||||
// Price the pickup as one visit. A failed estimate must never block a
|
||||
// booking, so a zero range is stored rather than an error returned — the
|
||||
// receipt settles on what the miler weighs regardless.
|
||||
estimateDestinations := make([]cxEstimateDestination, 0, len(req.Destinations))
|
||||
for _, d := range req.Destinations {
|
||||
estimateDestinations = append(estimateDestinations, cxEstimateDestination{
|
||||
StateCode: d.StateCode,
|
||||
DistrictCode: d.DistrictCode,
|
||||
PackageCount: d.PackageCount,
|
||||
})
|
||||
}
|
||||
quote := quoteCxPickup(req.Pickup.Lat, req.Pickup.Lng, estimateDestinations)
|
||||
estimateMin, estimateMax := quote.Min, quote.Max
|
||||
if req.Estimate != nil && req.Estimate.Max > 0 {
|
||||
// The customer's number wins. They agreed to what was on their screen,
|
||||
// and re-pricing at confirm time would quietly change the deal.
|
||||
estimateMin, estimateMax = req.Estimate.Min, req.Estimate.Max
|
||||
}
|
||||
|
||||
now := utils.DBNow()
|
||||
pickupFromDB := cxToDBTime(slotFrom)
|
||||
pickupToDB := cxToDBTime(slotTo)
|
||||
|
||||
first := req.Destinations[0]
|
||||
firstDistrict := districts[strings.ToUpper(strings.TrimSpace(first.DistrictCode))]
|
||||
|
||||
booking := models.PickupBooking{
|
||||
Bookingno: generateBookingNo(),
|
||||
Appcustomerid: customerID,
|
||||
Pickupaddress: joinNonEmpty(", ", req.Pickup.Title, req.Pickup.Sub),
|
||||
Pickuptitle: req.Pickup.Title,
|
||||
Pickupsub: req.Pickup.Sub,
|
||||
Pickuppincode: pickupPincode,
|
||||
|
||||
Pickuplatitude: req.Pickup.Lat,
|
||||
Pickuplongitude: req.Pickup.Lng,
|
||||
|
||||
// The flat delivery columns mirror destination 0. They are NOT the
|
||||
// destination list — that lives in bookingdestinations — but the miler
|
||||
// app, the hub console, the routing code and the hyperlocal check all
|
||||
// read them, and leaving them empty would make a customer-app booking
|
||||
// invisible to every one of those. Destination 0 is the one the rider
|
||||
// is told about first, so it is the one that mirrors.
|
||||
Deliveryaddress: cxDestinationAddress(first.Details, firstDistrict),
|
||||
Deliverypincode: firstDistrict.Pincodeprefix,
|
||||
Deliverylatitude: firstDistrict.Centrelatitude,
|
||||
Deliverylongitude: firstDistrict.Centrelongitude,
|
||||
Deliverycity: firstDistrict.Districtname,
|
||||
|
||||
Bookingsource: constants.BookingSourceCustomerApp,
|
||||
Pickupsourcetype: constants.PickupSourceCustomer,
|
||||
Status: constants.BookingPendingPickup,
|
||||
|
||||
Preferredpickupfrom: &pickupFromDB,
|
||||
Preferredpickupto: &pickupToDB,
|
||||
|
||||
Slotid: req.SlotID,
|
||||
Customerstage: constants.CxStageBooked,
|
||||
Customerstatus: constants.CxStatusActive,
|
||||
Estimateminrupees: estimateMin,
|
||||
Estimatemaxrupees: estimateMax,
|
||||
Routekm: quote.RouteKM,
|
||||
|
||||
Notes: req.Remarks,
|
||||
|
||||
Createdat: now,
|
||||
Updatedat: now,
|
||||
}
|
||||
|
||||
if pin := cxFirstPin(first.Details); pin != nil {
|
||||
booking.Deliverylatitude, booking.Deliverylongitude = pin[0], pin[1]
|
||||
}
|
||||
|
||||
tx := db.DB.Begin()
|
||||
if err := tx.Create(&booking).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CreateCxBooking: could not create booking", "customer_id", customerID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
for i, d := range req.Destinations {
|
||||
code := strings.ToUpper(strings.TrimSpace(d.DistrictCode))
|
||||
district := districts[code]
|
||||
packages := d.PackageCount
|
||||
if packages < 1 {
|
||||
packages = 1
|
||||
}
|
||||
|
||||
dest := models.BookingDestination{
|
||||
Bookingid: booking.Bookingid,
|
||||
Seq: i,
|
||||
Statecode: district.Statecode,
|
||||
Statename: stateNames[district.Statecode],
|
||||
Districtcode: district.Districtcode,
|
||||
Districtname: district.Districtname,
|
||||
Packagecount: packages,
|
||||
Pincode: district.Pincodeprefix,
|
||||
Createdat: now,
|
||||
Updatedat: now,
|
||||
}
|
||||
applyCxDetails(&dest, d.Details)
|
||||
|
||||
if err := tx.Create(&dest).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CreateCxBooking: could not create destination", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
// One parcel row per package, linked to its destination. The miler
|
||||
// weighs and photographs each package at the door, so each needs a row
|
||||
// to be weighed into — and each has to know which order it belongs to.
|
||||
// Weight is deliberately left at zero: it is never collected from the
|
||||
// customer, and a guess here would look like a measurement on the
|
||||
// receipt.
|
||||
for p := 0; p < packages; p++ {
|
||||
parcel := models.BookingParcel{
|
||||
Bookingid: booking.Bookingid,
|
||||
Bookingdestinationid: &dest.Bookingdestinationid,
|
||||
Itemcategory: "General",
|
||||
Createdat: now,
|
||||
Updatedat: now,
|
||||
}
|
||||
if err := tx.Create(&parcel).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CreateCxBooking: could not create parcel", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The service option is what the REST of the platform reads a booking's
|
||||
// value from, and it is not optional just because the customer surface
|
||||
// keeps its own estimate columns:
|
||||
//
|
||||
// * MilerDeliverConsignment and MilerInwardConsignmentAtHub copy
|
||||
// Estimatedprice onto BookingAssignment.ridercharges when a leg closes,
|
||||
// and GET /miler/earnings sums that column — so with no row here every
|
||||
// customer-app job a rider completes would show ₹0 on their Earnings
|
||||
// screen.
|
||||
// * The admin console's Orders list renders serviceoptions[0].estimatedprice
|
||||
// as the Price column, which would read "N/A" for every customer booking.
|
||||
//
|
||||
// The midpoint of the band the customer was shown is the honest figure
|
||||
// before the miler weighs anything, and it is what lookupDoormilePrice
|
||||
// returns everywhere else in this codebase.
|
||||
slaDue := cxToDBTime(slotTo.Add(48 * time.Hour))
|
||||
estimatedDelivery := cxToDBTime(slotTo.Add(24 * time.Hour))
|
||||
srvOption := models.BookingServiceOption{
|
||||
Bookingid: booking.Bookingid,
|
||||
Servicetype: "Normal",
|
||||
Estimatedprice: float64(estimateMin+estimateMax) / 2,
|
||||
Estimateddeliveryat: &estimatedDelivery,
|
||||
Sladueat: &slaDue,
|
||||
Createdat: now,
|
||||
}
|
||||
if err := tx.Create(&srvOption).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CreateCxBooking: could not create service option", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
if err := cxstage.Record(tx, cxstage.Event{
|
||||
BookingID: booking.Bookingid,
|
||||
Stage: constants.CxStageBooked,
|
||||
ActorType: constants.CxActorCustomer,
|
||||
ActorID: &customerID,
|
||||
Source: "POST /customer/bookings",
|
||||
At: now,
|
||||
}); err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CreateCxBooking: could not record booked stage", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
utils.Error("CreateCxBooking: commit failed", "customer_id", customerID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
// Fire-and-forget, after commit, exactly as the express path does.
|
||||
go assignment.AssignCustomerMiler(booking.Bookingid)
|
||||
publishCxBookingCreated(&booking, len(req.Destinations))
|
||||
|
||||
return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusCreated)
|
||||
}
|
||||
|
||||
// GetCxBookings backs the Orders tabs, Home's recent list and pull-to-refresh.
|
||||
func GetCxBookings(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
|
||||
limit := cxDefaultPageSize
|
||||
if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 {
|
||||
limit = v
|
||||
}
|
||||
if limit > cxMaxPageSize {
|
||||
limit = cxMaxPageSize
|
||||
}
|
||||
|
||||
// One filter, applied to both the page and the count, so `total` is the size
|
||||
// of the tab the customer is actually looking at. Counting every booking
|
||||
// they have ever made would put "48" above a Cancelled tab holding two rows.
|
||||
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
||||
scoped := func() *gorm.DB {
|
||||
q := db.DB.Model(&models.PickupBooking{}).Where("appcustomerid = ?", customerID)
|
||||
switch status {
|
||||
case constants.CxStatusActive:
|
||||
// Rows written before this surface existed carry no customerstatus.
|
||||
// Treating a null as active keeps a live booking visible rather
|
||||
// than hiding it from the customer who is waiting on it.
|
||||
// Parenthesised explicitly rather than relying on AND binding
|
||||
// tighter than OR — the two readings differ by "shows every
|
||||
// cancelled booking in the active tab", which is not a thing to
|
||||
// leave to operator precedence.
|
||||
q = q.Where("(customerstatus = ?) OR ((customerstatus IS NULL OR customerstatus = '') AND status <> ?)",
|
||||
constants.CxStatusActive, constants.BookingCancelled)
|
||||
case constants.CxStatusCompleted:
|
||||
q = q.Where("customerstatus = ?", constants.CxStatusCompleted)
|
||||
case constants.CxStatusCancelled:
|
||||
q = q.Where("customerstatus = ? OR status = ?", constants.CxStatusCancelled, constants.BookingCancelled)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
q := scoped()
|
||||
|
||||
// Keyset pagination on the primary key. Offsets drift when a new booking
|
||||
// lands mid-scroll, which shows the customer the same row twice.
|
||||
if cursor := strings.TrimSpace(c.Query("cursor")); cursor != "" {
|
||||
if after, err := strconv.Atoi(cursor); err == nil {
|
||||
q = q.Where("bookingid < ?", after)
|
||||
}
|
||||
}
|
||||
|
||||
var bookings []models.PickupBooking
|
||||
if err := q.Order("bookingid DESC").Limit(limit + 1).Find(&bookings).Error; err != nil {
|
||||
utils.Error("GetCxBookings: query failed", "customer_id", customerID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
var nextCursor *string
|
||||
if len(bookings) > limit {
|
||||
bookings = bookings[:limit]
|
||||
next := strconv.Itoa(bookings[len(bookings)-1].Bookingid)
|
||||
nextCursor = &next
|
||||
}
|
||||
|
||||
bundle := loadCxBundle(bookings)
|
||||
out := make([]fiber.Map, 0, len(bookings))
|
||||
for i := range bookings {
|
||||
out = append(out, renderCxBooking(&bookings[i], bundle))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := scoped().Count(&total).Error; err != nil {
|
||||
utils.Warn("GetCxBookings: count failed, reporting the page size", "customer_id", customerID, "error", err)
|
||||
total = int64(len(out))
|
||||
}
|
||||
|
||||
return utils.CxList(c, out, int(total), nextCursor)
|
||||
}
|
||||
|
||||
// GetCxBookingDetail is the canonical read — the tracking screen and the
|
||||
// receipt are both rendered from it, and it is polled while tracking is open.
|
||||
func GetCxBookingDetail(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
reference := strings.TrimSpace(c.Params("reference"))
|
||||
|
||||
booking, err := cxLoadBooking(customerID, reference)
|
||||
if err != nil {
|
||||
return utils.CxNotFound(c, "We could not find that pickup")
|
||||
}
|
||||
|
||||
bundle := loadCxBundle([]models.PickupBooking{*booking})
|
||||
payload := renderCxBooking(booking, bundle)
|
||||
|
||||
// Polled every few seconds while the tracking screen is open. A 304 turns
|
||||
// most of those polls into a header exchange instead of a full render.
|
||||
if served := serveIfNotModified(c, payload); served {
|
||||
return nil
|
||||
}
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
return utils.CxOK(c, payload)
|
||||
}
|
||||
|
||||
// GetCxOrder returns one order by tracking number, for push deep links.
|
||||
//
|
||||
// It answers with the whole booking object rather than a slimmer order shape —
|
||||
// the client already parses this one, and a second shape for the same data is
|
||||
// a second parser to keep in step.
|
||||
func GetCxOrder(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
trackingID := strings.TrimSpace(c.Params("trackingId"))
|
||||
if trackingID == "" {
|
||||
return utils.CxNotFound(c, "We could not find that order")
|
||||
}
|
||||
|
||||
var dest models.BookingDestination
|
||||
if err := db.DB.Where("trackingno = ?", trackingID).First(&dest).Error; err != nil {
|
||||
return utils.CxNotFound(c, "We could not find that order")
|
||||
}
|
||||
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.Where("bookingid = ? AND appcustomerid = ?", dest.Bookingid, customerID).
|
||||
First(&booking).Error; err != nil {
|
||||
// The tracking number exists but belongs to someone else. Answered as
|
||||
// not-found rather than forbidden: confirming that a tracking number is
|
||||
// real tells an enumerating caller something they should not learn.
|
||||
return utils.CxNotFound(c, "We could not find that order")
|
||||
}
|
||||
|
||||
bundle := loadCxBundle([]models.PickupBooking{booking})
|
||||
return utils.CxOK(c, renderCxBooking(&booking, bundle))
|
||||
}
|
||||
|
||||
// CancelCxBooking cancels the whole pickup.
|
||||
//
|
||||
// Allowed through arrived and refused from picked_up onward. `cancellable` on
|
||||
// the booking mirrors the same policy so the UI can hide the button, but this
|
||||
// re-checks — the button state is a hint the client renders from a response
|
||||
// that may be seconds old, never the authority.
|
||||
func CancelCxBooking(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
reference := strings.TrimSpace(c.Params("reference"))
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = c.BodyParser(&req)
|
||||
|
||||
booking, err := cxLoadBooking(customerID, reference)
|
||||
if err != nil {
|
||||
return utils.CxNotFound(c, "We could not find that pickup")
|
||||
}
|
||||
|
||||
if booking.Customerstatus == constants.CxStatusCancelled ||
|
||||
booking.Status == constants.BookingCancelled {
|
||||
// Already cancelled. Answered as success rather than as a conflict: the
|
||||
// customer asked for a state the booking is already in, and a retry
|
||||
// over a flaky network must not read as a failure.
|
||||
return utils.CxOK(c, fiber.Map{
|
||||
"reference": booking.Bookingno,
|
||||
"status": constants.CxStatusCancelled,
|
||||
"cancelReason": booking.Cancelreason,
|
||||
})
|
||||
}
|
||||
|
||||
stage := booking.Customerstage
|
||||
if stage == "" {
|
||||
stage = deriveStageFromStatus(booking)
|
||||
}
|
||||
if !constants.CxCancellable(stage) {
|
||||
return utils.CxConflict(c, "This pickup can no longer be cancelled")
|
||||
}
|
||||
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
|
||||
tx := db.DB.Begin()
|
||||
if err := cxstage.Cancel(tx, booking.Bookingid, reason,
|
||||
constants.CxActorCustomer, &customerID, "POST /customer/bookings/{reference}/cancel"); err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CancelCxBooking: cancel failed", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
// Release the rider. Without this the assignment stays open, the rider
|
||||
// keeps a stop they must not attempt, and MilerEndDuty refuses to let them
|
||||
// go off duty while any assignment is still Assigned or Accepted.
|
||||
if err := tx.Model(&models.BookingAssignment{}).
|
||||
Where("bookingid = ? AND assignmentstatus IN ?", booking.Bookingid,
|
||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||
Updates(map[string]interface{}{
|
||||
"assignmentstatus": constants.AssignmentCancelled,
|
||||
"remarks": "cancelled by customer",
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CancelCxBooking: could not release assignment", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
if booking.Assignedmileruserid != nil {
|
||||
if err := tx.Model(&models.MilerProfile{}).
|
||||
Where("userid = ? AND availabilitystatus IN ?", *booking.Assignedmileruserid,
|
||||
[]string{constants.MilerAssigned, constants.MilerOnPickup, constants.MilerAtCustomer}).
|
||||
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CancelCxBooking: could not free the rider", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
utils.Error("CancelCxBooking: commit failed", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
publishCxBookingCancelled(booking, reason)
|
||||
|
||||
return utils.CxOK(c, fiber.Map{
|
||||
"reference": booking.Bookingno,
|
||||
"status": constants.CxStatusCancelled,
|
||||
"cancelReason": reason,
|
||||
})
|
||||
}
|
||||
|
||||
// PatchCxDestination fills in the parts of an address the customer left out.
|
||||
//
|
||||
// Accepted until the parcels are collected. After that the shipment's addresses
|
||||
// are frozen on the consignment and an edit here would change what the customer
|
||||
// sees without changing where the parcel is going — which is worse than
|
||||
// refusing.
|
||||
//
|
||||
// Writes land on the same booking row the miler app reads addresses from, so a
|
||||
// correction made while the rider is on their way reaches them.
|
||||
func PatchCxDestination(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
reference := strings.TrimSpace(c.Params("reference"))
|
||||
|
||||
index, err := strconv.Atoi(c.Params("index"))
|
||||
if err != nil || index < 0 {
|
||||
return utils.CxNotFound(c, "We could not find that destination")
|
||||
}
|
||||
|
||||
booking, err := cxLoadBooking(customerID, reference)
|
||||
if err != nil {
|
||||
return utils.CxNotFound(c, "We could not find that pickup")
|
||||
}
|
||||
|
||||
stage := booking.Customerstage
|
||||
if stage == "" {
|
||||
stage = deriveStageFromStatus(booking)
|
||||
}
|
||||
if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStagePickedUp] {
|
||||
return utils.CxConflict(c, "Your packages have been collected — these details can no longer be changed")
|
||||
}
|
||||
if booking.Customerstatus == constants.CxStatusCancelled || booking.Status == constants.BookingCancelled {
|
||||
return utils.CxConflict(c, "This pickup was cancelled")
|
||||
}
|
||||
|
||||
var dest models.BookingDestination
|
||||
if err := db.DB.Where("bookingid = ? AND seq = ?", booking.Bookingid, index).
|
||||
First(&dest).Error; err != nil {
|
||||
return utils.CxNotFound(c, "We could not find that destination")
|
||||
}
|
||||
|
||||
var req cxDetailsInput
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
|
||||
applyCxDetails(&dest, &req)
|
||||
dest.Updatedat = utils.DBNow()
|
||||
|
||||
tx := db.DB.Begin()
|
||||
if err := tx.Save(&dest).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("PatchCxDestination: save failed", "destination_id", dest.Bookingdestinationid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
// Destination 0 mirrors onto the booking's flat delivery columns, which is
|
||||
// where the miler app and the routing code look. An edit that only landed
|
||||
// on bookingdestinations would be invisible to the rider standing at the
|
||||
// door, which is precisely who it was made for.
|
||||
if dest.Seq == 0 {
|
||||
updates := map[string]interface{}{
|
||||
"deliveryaddress": cxDestinationAddressFromRow(&dest),
|
||||
"updatedat": utils.DBNow(),
|
||||
}
|
||||
if dest.Pinlatitude != nil && dest.Pinlongitude != nil {
|
||||
updates["deliverylatitude"] = *dest.Pinlatitude
|
||||
updates["deliverylongitude"] = *dest.Pinlongitude
|
||||
}
|
||||
if err := tx.Model(&models.PickupBooking{}).
|
||||
Where("bookingid = ?", booking.Bookingid).Updates(updates).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("PatchCxDestination: could not mirror onto booking", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
utils.Error("PatchCxDestination: commit failed", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusOK)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// cxLoadBooking finds a booking by its customer-facing reference, scoped to the
|
||||
// caller. Ownership is part of the lookup, not a check afterwards — a query
|
||||
// that can return someone else's row is one forgotten `if` away from leaking it.
|
||||
func cxLoadBooking(customerID int, reference string) (*models.PickupBooking, error) {
|
||||
if reference == "" {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.Where("bookingno = ? AND appcustomerid = ?", reference, customerID).
|
||||
First(&booking).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &booking, nil
|
||||
}
|
||||
|
||||
// cxRespondWithBooking re-reads and renders, so a create or a patch answers in
|
||||
// exactly the shape a later GET will.
|
||||
func cxRespondWithBooking(c *fiber.Ctx, bookingID, status int) error {
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.First(&booking, bookingID).Error; err != nil {
|
||||
utils.Error("cxRespondWithBooking: reload failed", "booking_id", bookingID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
bundle := loadCxBundle([]models.PickupBooking{booking})
|
||||
payload := renderCxBooking(&booking, bundle)
|
||||
if status == fiber.StatusCreated {
|
||||
return utils.CxCreated(c, payload)
|
||||
}
|
||||
return utils.CxOK(c, payload)
|
||||
}
|
||||
|
||||
// applyCxDetails writes only the fields actually present in the request. An
|
||||
// omitted key leaves the stored value alone; an explicit null clears it, which
|
||||
// is how the customer removes a landmark they no longer want the rider to use.
|
||||
func applyCxDetails(dest *models.BookingDestination, in *cxDetailsInput) {
|
||||
if in == nil {
|
||||
return
|
||||
}
|
||||
if in.Street != nil {
|
||||
dest.Street = strings.TrimSpace(*in.Street)
|
||||
}
|
||||
if in.Building != nil {
|
||||
dest.Building = strings.TrimSpace(*in.Building)
|
||||
}
|
||||
if in.Landmark != nil {
|
||||
dest.Landmark = strings.TrimSpace(*in.Landmark)
|
||||
}
|
||||
if in.RecipientName != nil {
|
||||
dest.Recipientname = strings.TrimSpace(*in.RecipientName)
|
||||
}
|
||||
if in.RecipientPhone != nil {
|
||||
if phone, _, ok := normalizePhone(*in.RecipientPhone); ok {
|
||||
dest.Recipientphone = phone
|
||||
} else {
|
||||
dest.Recipientphone = strings.TrimSpace(*in.RecipientPhone)
|
||||
}
|
||||
}
|
||||
if in.Instructions != nil {
|
||||
dest.Instructions = strings.TrimSpace(*in.Instructions)
|
||||
}
|
||||
if in.Pin != nil {
|
||||
lat, lng := in.Pin.Lat, in.Pin.Lng
|
||||
if lat == 0 && lng == 0 {
|
||||
dest.Pinlatitude, dest.Pinlongitude = nil, nil
|
||||
} else {
|
||||
dest.Pinlatitude, dest.Pinlongitude = &lat, &lng
|
||||
}
|
||||
}
|
||||
if in.CodAmount != nil {
|
||||
dest.Codamount = *in.CodAmount
|
||||
}
|
||||
}
|
||||
|
||||
func cxFirstPin(in *cxDetailsInput) *[2]float64 {
|
||||
if in == nil || in.Pin == nil {
|
||||
return nil
|
||||
}
|
||||
if in.Pin.Lat == 0 && in.Pin.Lng == 0 {
|
||||
return nil
|
||||
}
|
||||
return &[2]float64{in.Pin.Lat, in.Pin.Lng}
|
||||
}
|
||||
|
||||
// cxDestinationAddress builds the flat address string the rest of the system
|
||||
// stores, from whatever the customer supplied. It always resolves to something
|
||||
// non-empty — pickupbookings.deliveryaddress is NOT NULL, and a booking with
|
||||
// only a state and a district is a legitimate booking.
|
||||
func cxDestinationAddress(in *cxDetailsInput, district models.ServiceableDistrict) string {
|
||||
parts := []string{}
|
||||
if in != nil {
|
||||
if in.Building != nil {
|
||||
parts = append(parts, *in.Building)
|
||||
}
|
||||
if in.Street != nil {
|
||||
parts = append(parts, *in.Street)
|
||||
}
|
||||
if in.Landmark != nil {
|
||||
parts = append(parts, *in.Landmark)
|
||||
}
|
||||
}
|
||||
parts = append(parts, district.Districtname)
|
||||
address := joinNonEmpty(", ", parts...)
|
||||
if address == "" {
|
||||
return district.Districtcode
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
func cxDestinationAddressFromRow(d *models.BookingDestination) string {
|
||||
address := joinNonEmpty(", ", d.Building, d.Street, d.Landmark, d.Districtname)
|
||||
if address == "" {
|
||||
return d.Districtcode
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
// cxStateNames resolves display names for the states a set of districts belong
|
||||
// to, in one query.
|
||||
func cxStateNames(districts []models.ServiceableDistrict) (map[string]string, error) {
|
||||
codes := make([]string, 0, len(districts))
|
||||
seen := map[string]bool{}
|
||||
for _, d := range districts {
|
||||
if d.Statecode != "" && !seen[d.Statecode] {
|
||||
seen[d.Statecode] = true
|
||||
codes = append(codes, d.Statecode)
|
||||
}
|
||||
}
|
||||
names := make(map[string]string, len(codes))
|
||||
if len(codes) == 0 {
|
||||
return names, nil
|
||||
}
|
||||
var states []models.ServiceableState
|
||||
if err := db.DB.Where("statecode IN ?", codes).Find(&states).Error; err != nil {
|
||||
return names, err
|
||||
}
|
||||
for _, s := range states {
|
||||
names[s.Statecode] = s.Statename
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// cxToDBTime converts an IST wall clock into the shape this database stores —
|
||||
// the same digits, tagged UTC so the driver writes them verbatim. See
|
||||
// utils.DBNow: comparing a container's UTC clock against IST-stamped rows is
|
||||
// what made date-range reports undercount.
|
||||
func cxToDBTime(t time.Time) time.Time {
|
||||
ist := t.In(utils.ISTLocation())
|
||||
return time.Date(ist.Year(), ist.Month(), ist.Day(), ist.Hour(), ist.Minute(), ist.Second(), 0, time.UTC)
|
||||
}
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// publishCxBookingCreated and publishCxBookingCancelled mirror the existing
|
||||
// booking events onto NATS. Best-effort and nil-checked, like every other
|
||||
// publish in this codebase: the event bus is never allowed to fail a booking.
|
||||
func publishCxBookingCreated(b *models.PickupBooking, destinationCount int) {
|
||||
if db.Js == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"booking_id": b.Bookingid,
|
||||
"booking_no": b.Bookingno,
|
||||
"customer_id": b.Appcustomerid,
|
||||
"pickup_address": b.Pickupaddress,
|
||||
"pickup_pincode": b.Pickuppincode,
|
||||
"destination_count": destinationCount,
|
||||
"slot_id": b.Slotid,
|
||||
"status": constants.BookingPendingPickup,
|
||||
"created_at": utils.EpochMillis(b.Createdat),
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := db.Js.Publish("api.v1.bookings.create", data); err != nil {
|
||||
utils.Warn("Failed to publish booking.create to NATS", "booking_id", b.Bookingid, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func publishCxBookingCancelled(b *models.PickupBooking, reason string) {
|
||||
if db.Js == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"booking_id": b.Bookingid,
|
||||
"booking_no": b.Bookingno,
|
||||
"customer_id": b.Appcustomerid,
|
||||
"status": "Cancelled",
|
||||
"reason": reason,
|
||||
"cancelled_at": time.Now().UnixMilli(),
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := db.Js.Publish("api.v1.bookings.cancel", data); err != nil {
|
||||
utils.Warn("Failed to publish booking.cancel to NATS", "booking_id", b.Bookingid, "error", err)
|
||||
}
|
||||
}
|
||||
760
controllers/cxBookingView.go
Normal file
760
controllers/cxBookingView.go
Normal file
@@ -0,0 +1,760 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/internal/storage"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// The canonical booking object — §9.3.
|
||||
//
|
||||
// The tracking screen and the receipt are both rendered from this one shape, so
|
||||
// it is built in exactly one place and every read that returns a booking goes
|
||||
// through it. A list row and a detail read differing in shape is how a client
|
||||
// ends up with two parsers for one object.
|
||||
//
|
||||
// Two rules the client's type declarations depend on, and which will throw in
|
||||
// its parser if broken:
|
||||
// - pickup and slotId are present on EVERY booking, cancelled ones included.
|
||||
// - destinations[].stateName / districtName are always populated; the client
|
||||
// renders "Chennai, Tamil Nadu" from them and never looks a code up.
|
||||
|
||||
// cxPhotoTTL is how long a parcel-photo link stays valid. Long enough to open
|
||||
// the receipt, read it and come back; short enough that a link forwarded on is
|
||||
// dead by the time it is opened.
|
||||
const cxPhotoTTL = 30 * time.Minute
|
||||
|
||||
// cxBookingBundle is everything one or more bookings need in order to render,
|
||||
// loaded in batch. Building it per booking would put six queries behind a
|
||||
// tracking poll that runs every few seconds.
|
||||
type cxBookingBundle struct {
|
||||
destinations map[int][]models.BookingDestination
|
||||
events map[int][]models.BookingStageEvent
|
||||
photos map[int][]models.BookingParcelPhoto // keyed by destination id
|
||||
milers map[int]cxAgent // keyed by miler user id
|
||||
assignedTo map[int]int // booking id -> miler user id
|
||||
deliveryAgent map[int]int // destination id -> agent user id
|
||||
payments map[int]float64 // booking id -> settled rupees
|
||||
districts map[string]models.ServiceableDistrict
|
||||
hubNames map[int]string
|
||||
// single marks a one-booking read (the tracking screen or the receipt), as
|
||||
// opposed to a page of them. A couple of fields are worth a live lookup for
|
||||
// one booking and are not worth twenty of them for a list.
|
||||
single bool
|
||||
}
|
||||
|
||||
type cxAgent struct {
|
||||
Name string
|
||||
Vehicle string
|
||||
Phone string
|
||||
Rating float64
|
||||
Trips int
|
||||
VehicleType string
|
||||
Lat, Lng float64
|
||||
}
|
||||
|
||||
// renderCxBooking projects one booking into the customer contract.
|
||||
func renderCxBooking(b *models.PickupBooking, bundle *cxBookingBundle) fiber.Map {
|
||||
destinations := bundle.destinations[b.Bookingid]
|
||||
|
||||
stage := b.Customerstage
|
||||
if stage == "" {
|
||||
// A booking written before this surface existed, or one created through
|
||||
// the console. Deriving a stage from the operational status is honest
|
||||
// about where the parcel is; inventing history for it is not, so its
|
||||
// timeline stays as short as the events actually recorded.
|
||||
stage = deriveStageFromStatus(b)
|
||||
}
|
||||
status := b.Customerstatus
|
||||
if status == "" {
|
||||
status = deriveStatus(b, stage)
|
||||
}
|
||||
// The OPERATIONAL status is the authority on cancellation, whatever the
|
||||
// stored customer status says.
|
||||
//
|
||||
// Three console paths cancel a booking by writing pickupbookings.status
|
||||
// directly — AdminCancelBooking, AdminBulkCancelBookings and
|
||||
// AdminUpdateBookingStatus (which accepts an arbitrary status string). None
|
||||
// of them knows this projection exists. Without this line a customer whose
|
||||
// pickup ops cancelled would keep seeing it as active and cancellable
|
||||
// forever, because customerstatus was written as "active" at booking time
|
||||
// and the empty-string fallback above never fires.
|
||||
//
|
||||
// Done here rather than only at those call sites so that a cancel path
|
||||
// added later cannot reintroduce the same divergence.
|
||||
if b.Status == constants.BookingCancelled {
|
||||
status = constants.CxStatusCancelled
|
||||
}
|
||||
|
||||
out := fiber.Map{
|
||||
"reference": b.Bookingno,
|
||||
"stage": stage,
|
||||
"status": status,
|
||||
"cancellable": status == constants.CxStatusActive && constants.CxCancellable(stage),
|
||||
"createdAt": utils.EpochMillis(b.Createdat),
|
||||
|
||||
"pickup": fiber.Map{
|
||||
"title": cxPickupTitle(b),
|
||||
"sub": cxPickupSub(b),
|
||||
"lat": b.Pickuplatitude,
|
||||
"lng": b.Pickuplongitude,
|
||||
},
|
||||
"slotId": b.Slotid,
|
||||
|
||||
"destinations": renderCxDestinations(destinations, bundle),
|
||||
|
||||
"miler": nil,
|
||||
"deliveryAgent": nil,
|
||||
|
||||
"milerDistanceKm": nil,
|
||||
"milerEtaMinutes": nil,
|
||||
"milersInZone": 0,
|
||||
|
||||
"routeKm": b.Routekm,
|
||||
"expectedDelivery": cxExpectedDelivery(destinations),
|
||||
|
||||
"fare": fiber.Map{
|
||||
"min": b.Estimateminrupees,
|
||||
"max": b.Estimatemaxrupees,
|
||||
"paymentMethod": "UPI · Cash at doorstep",
|
||||
"parcel": describeParcels(totalPackages(destinations)),
|
||||
},
|
||||
"amountPaid": nil,
|
||||
"deliveredAt": nil,
|
||||
"cancelReason": nil,
|
||||
|
||||
"history": renderCxHistory(bundle.events[b.Bookingid]),
|
||||
}
|
||||
|
||||
if b.Cancelreason != "" {
|
||||
out["cancelReason"] = b.Cancelreason
|
||||
}
|
||||
|
||||
// The assigned miler, from the stage they are assigned onward.
|
||||
if milerUserID, ok := bundle.assignedTo[b.Bookingid]; ok {
|
||||
if agent, found := bundle.milers[milerUserID]; found {
|
||||
out["miler"] = renderCxAgent(agent)
|
||||
|
||||
// Distance and ETA are live facts about a rider en route, so they
|
||||
// are computed from the rider's current position rather than
|
||||
// stored. Only meaningful while they are actually coming: after
|
||||
// pickup the number would describe a journey that already ended.
|
||||
if stage == constants.CxStageOnTheWay || stage == constants.CxStageArrived {
|
||||
km, eta := cxRiderApproach(agent, b, stage)
|
||||
out["milerDistanceKm"] = km
|
||||
out["milerEtaMinutes"] = eta
|
||||
}
|
||||
}
|
||||
} else if stage == constants.CxStageBooked && bundle.single {
|
||||
// Nobody assigned yet — the tracking screen shows how many riders are
|
||||
// in the zone instead, which is the only honest thing to say while
|
||||
// searching.
|
||||
//
|
||||
// Only on a single-booking read. This is a Redis GEOSEARCH per booking,
|
||||
// and running it across a 20-row Orders list would put twenty of them
|
||||
// behind one page load to fill a line the list does not render.
|
||||
out["milersInZone"] = milersWithin(b.Pickuplatitude, b.Pickuplongitude, cxMilersNearbyRadiusKM)
|
||||
}
|
||||
|
||||
// The delivering rider, once any order is out for delivery. Taken from the
|
||||
// first destination that has one, since the booking-level field describes
|
||||
// the leg the customer is currently watching.
|
||||
for _, d := range destinations {
|
||||
if agentUserID, ok := bundle.deliveryAgent[d.Bookingdestinationid]; ok {
|
||||
if agent, found := bundle.milers[agentUserID]; found {
|
||||
out["deliveryAgent"] = renderCxAgent(agent)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// amountPaid is present from picked_up. The fare block stays on the booking
|
||||
// forever alongside it — the receipt renders amountPaid − fare.min as the
|
||||
// weight adjustment, so losing the original estimate would lose the
|
||||
// explanation for the difference.
|
||||
if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStagePickedUp] {
|
||||
if paid, ok := bundle.payments[b.Bookingid]; ok {
|
||||
out["amountPaid"] = int(paid)
|
||||
}
|
||||
}
|
||||
|
||||
if delivered := cxAllDeliveredAt(destinations); delivered != nil {
|
||||
out["deliveredAt"] = utils.EpochMillis(*delivered)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func renderCxAgent(a cxAgent) fiber.Map {
|
||||
return fiber.Map{
|
||||
"name": a.Name,
|
||||
"vehicle": a.Vehicle,
|
||||
"phone": a.Phone,
|
||||
"rating": a.Rating,
|
||||
"trips": a.Trips,
|
||||
"vehicleType": a.VehicleType,
|
||||
}
|
||||
}
|
||||
|
||||
func renderCxDestinations(destinations []models.BookingDestination, bundle *cxBookingBundle) []fiber.Map {
|
||||
out := make([]fiber.Map, 0, len(destinations))
|
||||
for i := range destinations {
|
||||
d := destinations[i]
|
||||
|
||||
row := fiber.Map{
|
||||
"stateCode": d.Statecode,
|
||||
"stateName": d.Statename,
|
||||
"districtCode": d.Districtcode,
|
||||
"districtName": d.Districtname,
|
||||
"packageCount": d.Packagecount,
|
||||
"district": nil,
|
||||
"details": renderCxDetails(&d),
|
||||
"trackingId": nil,
|
||||
"stage": nil,
|
||||
"verification": nil,
|
||||
}
|
||||
|
||||
if district, ok := bundle.districts[d.Districtcode]; ok {
|
||||
card := fiber.Map{
|
||||
"code": district.Districtcode,
|
||||
"name": district.Districtname,
|
||||
"available": district.Available,
|
||||
}
|
||||
if district.Hubid != nil {
|
||||
if name, found := bundle.hubNames[*district.Hubid]; found {
|
||||
card["hub"] = name
|
||||
}
|
||||
}
|
||||
if district.Promise != "" {
|
||||
card["promise"] = district.Promise
|
||||
}
|
||||
row["district"] = card
|
||||
}
|
||||
|
||||
// Null until order_created — there is no order to track before the
|
||||
// parcels have actually been collected.
|
||||
if d.Trackingno != "" {
|
||||
row["trackingId"] = d.Trackingno
|
||||
}
|
||||
if d.Stage != "" {
|
||||
row["stage"] = d.Stage
|
||||
}
|
||||
|
||||
// Null until picked_up: the weight and the photographs are what the
|
||||
// miler recorded at the door and cannot exist before they were there.
|
||||
if d.Verifiedweightkg != nil && d.Verifiedat != nil {
|
||||
verification := fiber.Map{
|
||||
"weightKg": *d.Verifiedweightkg,
|
||||
"photos": cxPhotoURLs(bundle.photos[d.Bookingdestinationid]),
|
||||
"capturedAt": utils.EpochMillis(*d.Verifiedat),
|
||||
"capturedBy": "",
|
||||
}
|
||||
if d.Verifiedbyuserid != nil {
|
||||
if agent, ok := bundle.milers[*d.Verifiedbyuserid]; ok {
|
||||
verification["capturedBy"] = agent.Name
|
||||
}
|
||||
}
|
||||
row["verification"] = verification
|
||||
}
|
||||
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderCxDetails returns only the fields that were actually filled in. The UI
|
||||
// renders a missing one as "Not added — the Miler can confirm this at pickup",
|
||||
// which is a real state and not an error: a customer may legitimately book with
|
||||
// nothing but a state and a district.
|
||||
func renderCxDetails(d *models.BookingDestination) fiber.Map {
|
||||
details := fiber.Map{}
|
||||
if d.Street != "" {
|
||||
details["street"] = d.Street
|
||||
}
|
||||
if d.Building != "" {
|
||||
details["building"] = d.Building
|
||||
}
|
||||
if d.Landmark != "" {
|
||||
details["landmark"] = d.Landmark
|
||||
}
|
||||
if d.Recipientname != "" {
|
||||
details["recipientName"] = d.Recipientname
|
||||
}
|
||||
if d.Recipientphone != "" {
|
||||
details["recipientPhone"] = d.Recipientphone
|
||||
}
|
||||
if d.Instructions != "" {
|
||||
details["instructions"] = d.Instructions
|
||||
}
|
||||
if d.Pinlatitude != nil && d.Pinlongitude != nil {
|
||||
details["pin"] = fiber.Map{"lat": *d.Pinlatitude, "lng": *d.Pinlongitude}
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
// cxPhotoURLs signs each parcel photograph for the length of a receipt view.
|
||||
func cxPhotoURLs(photos []models.BookingParcelPhoto) []string {
|
||||
urls := make([]string, 0, len(photos))
|
||||
for _, p := range photos {
|
||||
url, err := storage.PresignGet(p.Objectkey, cxPhotoTTL)
|
||||
if err != nil {
|
||||
utils.Warn("cxPhotoURLs: could not sign parcel photo", "key", p.Objectkey, "error", err)
|
||||
continue
|
||||
}
|
||||
urls = append(urls, url)
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
// renderCxHistory turns the event log into the timeline. Ordered oldest-first
|
||||
// and carrying only real timestamps — every entry on the customer's timeline
|
||||
// comes from a row that a real write created.
|
||||
func renderCxHistory(events []models.BookingStageEvent) []fiber.Map {
|
||||
// One entry per stage. A multi-destination pickup emits a per-order stage
|
||||
// once per destination, so those have to collapse — and WHICH of them the
|
||||
// timeline shows is not arbitrary:
|
||||
//
|
||||
// * Booking-level stages (booked..order_created) happen once for the whole
|
||||
// pickup, so the first event is the only event.
|
||||
// * Per-order stages (in_transit..delivered) are reached by the booking
|
||||
// when its SLOWEST order gets there, matching how cxstage rolls the
|
||||
// booking up. Taking the first would timestamp "Delivered" at the moment
|
||||
// the earliest parcel landed while deliveredAt reports the last one —
|
||||
// the same screen contradicting itself.
|
||||
at := map[string]time.Time{}
|
||||
order := make([]string, 0, len(events))
|
||||
|
||||
for _, e := range events {
|
||||
// Cancellation and release rows are audit records rather than progress —
|
||||
// they carry a stage key only because the table needs one. Putting them
|
||||
// on the timeline would show the customer "Pickup booked" a second time
|
||||
// when a rider handed their pickup back.
|
||||
if strings.HasPrefix(e.Remarks, "cancelled:") || strings.HasPrefix(e.Remarks, "released:") {
|
||||
continue
|
||||
}
|
||||
|
||||
existing, seen := at[e.Stage]
|
||||
if !seen {
|
||||
at[e.Stage] = e.Occurredat
|
||||
order = append(order, e.Stage)
|
||||
continue
|
||||
}
|
||||
if constants.CxStageRank(e.Stage) >= constants.CxStageOrder[constants.CxStageInTransit] &&
|
||||
e.Occurredat.After(existing) {
|
||||
at[e.Stage] = e.Occurredat
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]fiber.Map, 0, len(order))
|
||||
for _, stage := range order {
|
||||
out = append(out, fiber.Map{
|
||||
"stage": stage,
|
||||
"at": utils.EpochMillis(at[stage]),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Derivations ──────────────────────────────────────────────────────────────
|
||||
|
||||
// deriveStageFromStatus gives a stage to a booking that has none: rows written
|
||||
// before this surface existed, and console-created express bookings that never
|
||||
// went through the customer flow.
|
||||
func deriveStageFromStatus(b *models.PickupBooking) string {
|
||||
switch b.Status {
|
||||
case constants.BookingCancelled:
|
||||
return constants.CxStageBooked
|
||||
case constants.BookingPickedUp:
|
||||
return constants.CxStagePickedUp
|
||||
case constants.BookingConvertedConsignment:
|
||||
return constants.CxStageOrderCreated
|
||||
case constants.BookingMilerAssigned, constants.BookingPickupScheduled:
|
||||
// reachedat is the only durable record that the rider actually got
|
||||
// there — the operational flow records arrival as a fact rather than a
|
||||
// status, so this is the one place it can be read from.
|
||||
if b.Arrivedat != nil {
|
||||
return constants.CxStageArrived
|
||||
}
|
||||
return constants.CxStageAssigned
|
||||
default:
|
||||
return constants.CxStageBooked
|
||||
}
|
||||
}
|
||||
|
||||
func deriveStatus(b *models.PickupBooking, stage string) string {
|
||||
if b.Status == constants.BookingCancelled {
|
||||
return constants.CxStatusCancelled
|
||||
}
|
||||
if stage == constants.CxStageDelivered {
|
||||
return constants.CxStatusCompleted
|
||||
}
|
||||
return constants.CxStatusActive
|
||||
}
|
||||
|
||||
// cxPickupTitle / cxPickupSub give the pickup block its two lines. The stored
|
||||
// title/sub pair is used when the booking came through the customer app; a
|
||||
// console-created booking only has one flat address string, so it is split
|
||||
// rather than left half-empty — the client types both as non-nullable.
|
||||
func cxPickupTitle(b *models.PickupBooking) string {
|
||||
if b.Pickuptitle != "" {
|
||||
return b.Pickuptitle
|
||||
}
|
||||
address := strings.TrimSpace(b.Pickupaddress)
|
||||
if address == "" {
|
||||
return "Pickup address"
|
||||
}
|
||||
if i := strings.Index(address, ","); i > 0 {
|
||||
return strings.TrimSpace(address[:i])
|
||||
}
|
||||
if len([]rune(address)) > 32 {
|
||||
return string([]rune(address)[:32])
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
func cxPickupSub(b *models.PickupBooking) string {
|
||||
if b.Pickupsub != "" {
|
||||
return b.Pickupsub
|
||||
}
|
||||
sub := joinNonEmpty(", ", strings.TrimSpace(b.Pickupaddress), b.Pickuppincode)
|
||||
if sub == "" {
|
||||
return "Address not recorded"
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
// cxExpectedDelivery is a display string, formatted server-side in IST, so the
|
||||
// client never has to know the operating timezone. The latest promise across
|
||||
// the destinations is the one shown: a booking is not fully delivered until its
|
||||
// last parcel is.
|
||||
func cxExpectedDelivery(destinations []models.BookingDestination) string {
|
||||
var latest *time.Time
|
||||
for i := range destinations {
|
||||
d := destinations[i]
|
||||
if d.Expecteddeliveryat == nil {
|
||||
continue
|
||||
}
|
||||
if latest == nil || d.Expecteddeliveryat.After(*latest) {
|
||||
latest = d.Expecteddeliveryat
|
||||
}
|
||||
}
|
||||
if latest == nil {
|
||||
return ""
|
||||
}
|
||||
return utils.FormatISTDate(*latest)
|
||||
}
|
||||
|
||||
// cxAllDeliveredAt returns when the LAST parcel landed, or nil while any is
|
||||
// still moving.
|
||||
func cxAllDeliveredAt(destinations []models.BookingDestination) *time.Time {
|
||||
if len(destinations) == 0 {
|
||||
return nil
|
||||
}
|
||||
var latest *time.Time
|
||||
for i := range destinations {
|
||||
d := destinations[i]
|
||||
if d.Deliveredat == nil {
|
||||
return nil
|
||||
}
|
||||
if latest == nil || d.Deliveredat.After(*latest) {
|
||||
latest = d.Deliveredat
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func totalPackages(destinations []models.BookingDestination) int {
|
||||
n := 0
|
||||
for _, d := range destinations {
|
||||
n += d.Packagecount
|
||||
}
|
||||
if n == 0 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// cxRiderApproach reports how far the rider still is and roughly how long that
|
||||
// takes. At the door both are zero — a rider standing at the address is not
|
||||
// "0.4 km away", and the screen says Arrived.
|
||||
func cxRiderApproach(agent cxAgent, b *models.PickupBooking, stage string) (float64, int) {
|
||||
if stage == constants.CxStageArrived {
|
||||
return 0, 0
|
||||
}
|
||||
lat, lng := agent.Lat, agent.Lng
|
||||
if lat == 0 && lng == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
km := calculateDistance(lat, lng, b.Pickuplatitude, b.Pickuplongitude)
|
||||
km = float64(int(km*10+0.5)) / 10
|
||||
|
||||
// 18 km/h is a two-wheeler in Indian city traffic, plus a two-minute floor
|
||||
// for parking and finding the door. Deliberately a rough number: the app
|
||||
// shows it as an approximation and a precise-looking ETA that slips reads
|
||||
// worse than an honest one.
|
||||
const avgSpeedKMH = 18.0
|
||||
eta := int(km/avgSpeedKMH*60) + 2
|
||||
return km, eta
|
||||
}
|
||||
|
||||
// ── Bundle loading ───────────────────────────────────────────────────────────
|
||||
|
||||
// loadCxBundle fetches everything a page of bookings needs, in a fixed number
|
||||
// of queries regardless of how many bookings or destinations are involved.
|
||||
func loadCxBundle(bookings []models.PickupBooking) *cxBookingBundle {
|
||||
bundle := &cxBookingBundle{
|
||||
destinations: map[int][]models.BookingDestination{},
|
||||
events: map[int][]models.BookingStageEvent{},
|
||||
photos: map[int][]models.BookingParcelPhoto{},
|
||||
milers: map[int]cxAgent{},
|
||||
assignedTo: map[int]int{},
|
||||
deliveryAgent: map[int]int{},
|
||||
payments: map[int]float64{},
|
||||
districts: map[string]models.ServiceableDistrict{},
|
||||
hubNames: map[int]string{},
|
||||
}
|
||||
bundle.single = len(bookings) == 1
|
||||
|
||||
if len(bookings) == 0 {
|
||||
return bundle
|
||||
}
|
||||
|
||||
bookingIDs := make([]int, 0, len(bookings))
|
||||
for _, b := range bookings {
|
||||
bookingIDs = append(bookingIDs, b.Bookingid)
|
||||
}
|
||||
|
||||
var destinations []models.BookingDestination
|
||||
if err := db.DB.Where("bookingid IN ?", bookingIDs).
|
||||
Order("bookingid ASC, seq ASC").Find(&destinations).Error; err != nil {
|
||||
utils.Error("loadCxBundle: destinations query failed", "error", err)
|
||||
}
|
||||
destIDs := make([]int, 0, len(destinations))
|
||||
districtCodes := map[string]bool{}
|
||||
for _, d := range destinations {
|
||||
bundle.destinations[d.Bookingid] = append(bundle.destinations[d.Bookingid], d)
|
||||
destIDs = append(destIDs, d.Bookingdestinationid)
|
||||
if d.Districtcode != "" {
|
||||
districtCodes[d.Districtcode] = true
|
||||
}
|
||||
}
|
||||
|
||||
var events []models.BookingStageEvent
|
||||
if err := db.DB.Where("bookingid IN ?", bookingIDs).
|
||||
Order("occurredat ASC, stageeventid ASC").Find(&events).Error; err != nil {
|
||||
utils.Error("loadCxBundle: stage events query failed", "error", err)
|
||||
}
|
||||
for _, e := range events {
|
||||
bundle.events[e.Bookingid] = append(bundle.events[e.Bookingid], e)
|
||||
}
|
||||
|
||||
if len(destIDs) > 0 {
|
||||
var photos []models.BookingParcelPhoto
|
||||
if err := db.DB.Where("bookingdestinationid IN ?", destIDs).
|
||||
Order("capturedat ASC").Find(&photos).Error; err != nil {
|
||||
utils.Warn("loadCxBundle: parcel photos query failed", "error", err)
|
||||
}
|
||||
for _, p := range photos {
|
||||
if p.Bookingdestinationid != nil {
|
||||
bundle.photos[*p.Bookingdestinationid] = append(bundle.photos[*p.Bookingdestinationid], p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
milerIDs := map[int]bool{}
|
||||
|
||||
// The live assignment, if any. Rejected and cancelled assignments are not
|
||||
// the current rider and must not be shown as one.
|
||||
var assignments []models.BookingAssignment
|
||||
if err := db.DB.Where("bookingid IN ? AND assignmentstatus IN ?", bookingIDs,
|
||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted, constants.AssignmentCompleted}).
|
||||
Order("assignedat ASC").Find(&assignments).Error; err != nil {
|
||||
utils.Warn("loadCxBundle: assignments query failed", "error", err)
|
||||
}
|
||||
for _, a := range assignments {
|
||||
bundle.assignedTo[a.Bookingid] = a.Mileruserid
|
||||
milerIDs[a.Mileruserid] = true
|
||||
}
|
||||
|
||||
// Who is delivering each order — the rider who recorded the out-for-delivery
|
||||
// event on that consignment.
|
||||
consignmentToDest := map[int]int{}
|
||||
consignmentIDs := make([]int, 0, len(destinations))
|
||||
for _, d := range destinations {
|
||||
if d.Consignmentid != nil {
|
||||
consignmentToDest[*d.Consignmentid] = d.Bookingdestinationid
|
||||
consignmentIDs = append(consignmentIDs, *d.Consignmentid)
|
||||
}
|
||||
}
|
||||
if len(consignmentIDs) > 0 {
|
||||
var history []models.ConsignmentHistory
|
||||
if err := db.DB.Where("consignmentid IN ? AND eventstatus = ?",
|
||||
consignmentIDs, constants.ConsignmentOutForDelivery).
|
||||
Order("createdat ASC").Find(&history).Error; err != nil {
|
||||
utils.Warn("loadCxBundle: consignment history query failed", "error", err)
|
||||
}
|
||||
for _, h := range history {
|
||||
if h.Userid == nil {
|
||||
continue
|
||||
}
|
||||
if destID, ok := consignmentToDest[h.Consignmentid]; ok {
|
||||
bundle.deliveryAgent[destID] = *h.Userid
|
||||
milerIDs[*h.Userid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, d := range destinations {
|
||||
if d.Verifiedbyuserid != nil {
|
||||
milerIDs[*d.Verifiedbyuserid] = true
|
||||
}
|
||||
}
|
||||
bundle.milers = loadCxAgents(milerIDs)
|
||||
|
||||
// What the customer actually paid. Summed from the payment rows rather than
|
||||
// stored on the booking, so money has one home and a second collection
|
||||
// cannot silently disagree with a cached total.
|
||||
type paidRow struct {
|
||||
Bookingid int
|
||||
Total float64
|
||||
}
|
||||
var paid []paidRow
|
||||
if err := db.DB.Model(&models.BookingPayment{}).
|
||||
Select("bookingid, sum(amount) as total").
|
||||
Where("bookingid IN ? AND paymentstatus = ?", bookingIDs, constants.PaymentStatusPaid).
|
||||
Group("bookingid").Scan(&paid).Error; err != nil {
|
||||
utils.Warn("loadCxBundle: payment totals query failed", "error", err)
|
||||
}
|
||||
for _, p := range paid {
|
||||
bundle.payments[p.Bookingid] = p.Total
|
||||
}
|
||||
|
||||
if len(districtCodes) > 0 {
|
||||
codes := make([]string, 0, len(districtCodes))
|
||||
for code := range districtCodes {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
var districts []models.ServiceableDistrict
|
||||
if err := db.DB.Where("districtcode IN ?", codes).Find(&districts).Error; err != nil {
|
||||
utils.Warn("loadCxBundle: district query failed", "error", err)
|
||||
}
|
||||
for _, d := range districts {
|
||||
bundle.districts[d.Districtcode] = d
|
||||
}
|
||||
bundle.hubNames = hubNamesFor(districts)
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
// loadCxAgents resolves rider display data — and their live position, which
|
||||
// comes from Redis because it changes every few seconds and has no business in
|
||||
// Postgres.
|
||||
func loadCxAgents(ids map[int]bool) map[int]cxAgent {
|
||||
out := map[int]cxAgent{}
|
||||
if len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
list := make([]int, 0, len(ids))
|
||||
for id := range ids {
|
||||
list = append(list, id)
|
||||
}
|
||||
|
||||
var profiles []models.MilerProfile
|
||||
if err := db.DB.Where("userid IN ?", list).Find(&profiles).Error; err != nil {
|
||||
utils.Warn("loadCxAgents: profile query failed", "error", err)
|
||||
return out
|
||||
}
|
||||
|
||||
vehicleIDs := make([]int, 0, len(profiles))
|
||||
for _, p := range profiles {
|
||||
if p.Vehicleid != nil {
|
||||
vehicleIDs = append(vehicleIDs, *p.Vehicleid)
|
||||
}
|
||||
}
|
||||
vehicles := map[int]models.Vehicle{}
|
||||
if len(vehicleIDs) > 0 {
|
||||
var rows []models.Vehicle
|
||||
if err := db.DB.Where("vehicleid IN ?", vehicleIDs).Find(&rows).Error; err == nil {
|
||||
for _, v := range rows {
|
||||
vehicles[v.Vehicleid] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range profiles {
|
||||
agent := cxAgent{
|
||||
Name: p.Displayname,
|
||||
Phone: cxMilerContact(p.Phone),
|
||||
Rating: p.Rating,
|
||||
Trips: p.Totalcompletedpickups,
|
||||
VehicleType: p.Defaultvehicletype,
|
||||
Lat: p.Currentlatitude,
|
||||
Lng: p.Currentlongitude,
|
||||
}
|
||||
if p.Vehicleid != nil {
|
||||
if v, ok := vehicles[*p.Vehicleid]; ok {
|
||||
agent.Vehicle = v.Vehicleno
|
||||
if agent.VehicleType == "" {
|
||||
agent.VehicleType = v.Vehicletype
|
||||
}
|
||||
}
|
||||
}
|
||||
if lat, lng, ok := cxLiveRiderPosition(p.Userid); ok {
|
||||
agent.Lat, agent.Lng = lat, lng
|
||||
}
|
||||
out[p.Userid] = agent
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cxLiveRiderPosition reads the rider's current position from the same Redis
|
||||
// GEO index the assignment engine searches, so the distance the customer sees
|
||||
// and the distance the dispatcher used are the same number. Falls back to the
|
||||
// profile's last-known coordinates when Redis has nothing.
|
||||
func cxLiveRiderPosition(milerUserID int) (lat, lng float64, ok bool) {
|
||||
if db.Rdb == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
positions, err := db.Rdb.GeoPos(ctx, "milers:locations", cxRiderGeoMember(milerUserID)).Result()
|
||||
if err != nil || len(positions) == 0 || positions[0] == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return positions[0].Latitude, positions[0].Longitude, true
|
||||
}
|
||||
|
||||
// cxRiderGeoMember is the member name riders are stored under in the GEO index
|
||||
// (see UpdateMilerLocation, which GEOADDs under the rider's user id).
|
||||
func cxRiderGeoMember(milerUserID int) string {
|
||||
return strconv.Itoa(milerUserID)
|
||||
}
|
||||
|
||||
// cxMilerContact decides what phone number the customer is given for their
|
||||
// rider.
|
||||
//
|
||||
// A masked-calling proxy is preferred, and MILER_CALL_PROXY configures one:
|
||||
// handing a customer a rider's personal mobile makes that number permanently
|
||||
// theirs, and riders on comparable platforms have been contacted long after the
|
||||
// delivery on numbers given out this way. With no proxy configured the real
|
||||
// number is returned, because a Call Miler button that dials nothing is worse
|
||||
// than one that dials a rider — but this is a setting to close before launch,
|
||||
// and §13 of the contract asks product to confirm which it is.
|
||||
func cxMilerContact(phone string) string {
|
||||
if proxy := strings.TrimSpace(os.Getenv("MILER_CALL_PROXY")); proxy != "" {
|
||||
return proxy
|
||||
}
|
||||
return phone
|
||||
}
|
||||
563
controllers/cxCatalogueController.go
Normal file
563
controllers/cxCatalogueController.go
Normal file
@@ -0,0 +1,563 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Catalogue and configuration — §5 of the customer contract.
|
||||
//
|
||||
// These four reads drive the whole booking form; nothing else in the app works
|
||||
// without them. Every one of them degrades to something the app can still
|
||||
// render rather than to an error, because a customer staring at a retry button
|
||||
// on the state picker cannot book at all.
|
||||
|
||||
// ── Serviceability ───────────────────────────────────────────────────────────
|
||||
|
||||
// GetCxStates lists the states a destination may be sent to.
|
||||
//
|
||||
// districtCount counts AVAILABLE districts only, and the client hides any state
|
||||
// showing 0 — so a state that is listed but has nothing open still returns,
|
||||
// carrying the "Opening soon" transit tag. An empty list is a legitimate
|
||||
// answer: the app has a designed no-service state for it.
|
||||
func GetCxStates(c *fiber.Ctx) error {
|
||||
var states []models.ServiceableState
|
||||
if err := db.DB.Where("status = ?", "Active").
|
||||
Order("displayorder ASC, statename ASC").Find(&states).Error; err != nil {
|
||||
utils.Error("GetCxStates: query failed", "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
// One grouped count instead of a query per state.
|
||||
type stateCount struct {
|
||||
Statecode string
|
||||
N int
|
||||
}
|
||||
var counts []stateCount
|
||||
if err := db.DB.Model(&models.ServiceableDistrict{}).
|
||||
Select("statecode, count(*) as n").
|
||||
Where("available = ?", true).
|
||||
Group("statecode").Scan(&counts).Error; err != nil {
|
||||
utils.Warn("GetCxStates: district count failed, reporting zero", "error", err)
|
||||
}
|
||||
byState := make(map[string]int, len(counts))
|
||||
for _, sc := range counts {
|
||||
byState[sc.Statecode] = sc.N
|
||||
}
|
||||
|
||||
out := make([]fiber.Map, 0, len(states))
|
||||
for _, s := range states {
|
||||
out = append(out, fiber.Map{
|
||||
"code": s.Statecode,
|
||||
"name": s.Statename,
|
||||
"districtCount": byState[s.Statecode],
|
||||
"transitTag": s.Transittag,
|
||||
})
|
||||
}
|
||||
|
||||
if served := serveIfNotModified(c, out); served {
|
||||
return nil
|
||||
}
|
||||
return utils.CxList(c, out, len(out), nil)
|
||||
}
|
||||
|
||||
// GetCxDistricts lists every district in a state, unavailable ones included.
|
||||
//
|
||||
// The picker filters unavailable districts out, but their names still appear in
|
||||
// a quiet "coming soon" line — dropping them here would delete real copy from
|
||||
// the screen. `note` says why one is closed, so the app never has to invent a
|
||||
// reason.
|
||||
func GetCxDistricts(c *fiber.Ctx) error {
|
||||
stateCode := strings.ToUpper(strings.TrimSpace(c.Params("stateCode")))
|
||||
if stateCode == "" {
|
||||
return utils.CxBadRequest(c, "Pick a state first")
|
||||
}
|
||||
|
||||
var state models.ServiceableState
|
||||
if err := db.DB.Where("statecode = ? AND status = ?", stateCode, "Active").
|
||||
First(&state).Error; err != nil {
|
||||
return utils.CxNotFound(c, "That state is no longer serviceable")
|
||||
}
|
||||
|
||||
var districts []models.ServiceableDistrict
|
||||
if err := db.DB.Where("statecode = ?", stateCode).
|
||||
Order("available DESC, displayorder ASC, districtname ASC").
|
||||
Find(&districts).Error; err != nil {
|
||||
utils.Error("GetCxDistricts: query failed", "state", stateCode, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
hubNames := hubNamesFor(districts)
|
||||
|
||||
out := make([]fiber.Map, 0, len(districts))
|
||||
for _, d := range districts {
|
||||
row := fiber.Map{
|
||||
"code": d.Districtcode,
|
||||
"name": d.Districtname,
|
||||
"available": d.Available,
|
||||
}
|
||||
if d.Note != "" {
|
||||
row["note"] = d.Note
|
||||
}
|
||||
if d.Hubid != nil {
|
||||
if name, ok := hubNames[*d.Hubid]; ok {
|
||||
row["hub"] = name
|
||||
}
|
||||
}
|
||||
if d.Promise != "" {
|
||||
row["promise"] = d.Promise
|
||||
}
|
||||
// The district's centre. Only state and district are required at booking
|
||||
// time, so for most destinations this is the ONLY geography the parcel
|
||||
// has until the miler corrects it at the door — it is what places the
|
||||
// destination on a map and what the fare estimate is priced against.
|
||||
// Omitted rather than sent as 0,0 when unknown: null island is a real
|
||||
// coordinate and would render as a pin off the coast of Africa.
|
||||
if d.Centrelatitude != 0 || d.Centrelongitude != 0 {
|
||||
row["lat"] = d.Centrelatitude
|
||||
row["lng"] = d.Centrelongitude
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
|
||||
if served := serveIfNotModified(c, out); served {
|
||||
return nil
|
||||
}
|
||||
return utils.CxList(c, out, len(out), nil)
|
||||
}
|
||||
|
||||
// hubNamesFor resolves the serving-hub names for a page of districts in one
|
||||
// query rather than one per row.
|
||||
func hubNamesFor(districts []models.ServiceableDistrict) map[int]string {
|
||||
ids := make([]int, 0, len(districts))
|
||||
seen := map[int]bool{}
|
||||
for _, d := range districts {
|
||||
if d.Hubid != nil && !seen[*d.Hubid] {
|
||||
seen[*d.Hubid] = true
|
||||
ids = append(ids, *d.Hubid)
|
||||
}
|
||||
}
|
||||
names := make(map[int]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return names
|
||||
}
|
||||
var hubs []models.Hub
|
||||
if err := db.DB.Select("hubid, hubname").Where("hubid IN ?", ids).Find(&hubs).Error; err != nil {
|
||||
utils.Warn("hubNamesFor: hub lookup failed, omitting hub names", "error", err)
|
||||
return names
|
||||
}
|
||||
for _, h := range hubs {
|
||||
names[h.Hubid] = h.Hubname
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// serveIfNotModified implements ETag/If-None-Match for the serviceability
|
||||
// reads. Both change perhaps weekly and are fetched on every cold start of the
|
||||
// booking form, so a 304 is the difference between a full round trip and a
|
||||
// header exchange. Returns true when it has already answered.
|
||||
func serveIfNotModified(c *fiber.Ctx, payload interface{}) bool {
|
||||
body, err := c.App().Config().JSONEncoder(payload)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
etag := `"` + hex.EncodeToString(sum[:16]) + `"`
|
||||
c.Set("ETag", etag)
|
||||
c.Set("Cache-Control", "max-age=300")
|
||||
|
||||
// A client may legitimately send several etags, or the weak form.
|
||||
for _, candidate := range strings.Split(c.Get("If-None-Match"), ",") {
|
||||
candidate = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(candidate), "W/"))
|
||||
if candidate == etag || candidate == "*" {
|
||||
c.Status(fiber.StatusNotModified)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Pickup slots ─────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// cxSlotLeadMinutes is how far ahead of a window's start the app may still
|
||||
// offer it. A window that starts in four minutes cannot be staffed, and
|
||||
// offering it produces a booking nobody can reach on time.
|
||||
cxSlotLeadMinutes = 45
|
||||
// cxSlotDays is how far ahead slots are offered: today and tomorrow, which
|
||||
// is what the design lays out.
|
||||
cxSlotDays = 2
|
||||
// cxSlotZoneRadiusKM bounds "the customer's zone" when counting how full a
|
||||
// window already is. Capacity is a property of an area's riders, not of the
|
||||
// whole city.
|
||||
cxSlotZoneRadiusKM = 12.0
|
||||
// cxMilersNearbyRadiusKM is the radius for the reassuring "4 milers nearby"
|
||||
// line — deliberately tighter than the capacity radius, because it is a
|
||||
// statement about who could arrive shortly.
|
||||
cxMilersNearbyRadiusKM = 6.0
|
||||
)
|
||||
|
||||
// GetCxPickupSlots returns the pickup windows offered at a location.
|
||||
//
|
||||
// Slots are capacity- and location-aware: the id resolves to a real
|
||||
// preferredpickupfrom/to on the booking, which is what the assignment engine
|
||||
// consumes, so a slot the customer can pick is a slot ops can staff. Windows
|
||||
// already past, or too close to start, are not returned at all rather than
|
||||
// returned as unavailable — a greyed-out 8am slot at 6pm is noise.
|
||||
func GetCxPickupSlots(c *fiber.Ctx) error {
|
||||
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
|
||||
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
|
||||
|
||||
appLocationID := appLocationForPoint(lat, lng)
|
||||
|
||||
var templates []models.PickupSlotTemplate
|
||||
q := db.DB.Where("status = ?", "Active")
|
||||
if appLocationID != nil {
|
||||
q = q.Where("applocationid IS NULL OR applocationid = ?", *appLocationID)
|
||||
} else {
|
||||
q = q.Where("applocationid IS NULL")
|
||||
}
|
||||
if err := q.Order("displayorder ASC, starthour ASC").Find(&templates).Error; err != nil {
|
||||
utils.Error("GetCxPickupSlots: template query failed", "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
now := utils.ISTNow()
|
||||
cutoff := now.Add(cxSlotLeadMinutes * time.Minute)
|
||||
|
||||
candidates := make([]cxSlotCandidate, 0, len(templates)*cxSlotDays)
|
||||
for day := 0; day < cxSlotDays; day++ {
|
||||
d := now.AddDate(0, 0, day)
|
||||
for _, tpl := range templates {
|
||||
from := time.Date(d.Year(), d.Month(), d.Day(), tpl.Starthour, tpl.Startminute, 0, 0, utils.ISTLocation())
|
||||
to := time.Date(d.Year(), d.Month(), d.Day(), tpl.Endhour, tpl.Endminute, 0, 0, utils.ISTLocation())
|
||||
if !from.After(cutoff) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, cxSlotCandidate{
|
||||
id: cxSlotID(from, tpl.Code),
|
||||
from: from,
|
||||
to: to,
|
||||
tpl: tpl,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
booked := slotLoad(candidates, lat, lng)
|
||||
milersNearby := milersWithin(lat, lng, cxMilersNearbyRadiusKM)
|
||||
|
||||
out := make([]fiber.Map, 0, len(candidates))
|
||||
taggedOne := false
|
||||
for _, cand := range candidates {
|
||||
remaining := cand.tpl.Capacity - booked[cand.id]
|
||||
available := remaining > 0
|
||||
|
||||
row := fiber.Map{
|
||||
"id": cand.id,
|
||||
"day": utils.FormatISTDay(cand.from),
|
||||
"window": utils.FormatISTWindow(cand.from, cand.to),
|
||||
"available": available,
|
||||
}
|
||||
if !available {
|
||||
row["note"] = "Fully booked"
|
||||
}
|
||||
// At most one slot carries the tag, and only if it can actually be
|
||||
// booked — labelling a full window "Fastest pickup" is worse than
|
||||
// labelling nothing.
|
||||
if available && !taggedOne && cand.tpl.Tag != "" {
|
||||
row["tag"] = cand.tpl.Tag
|
||||
taggedOne = true
|
||||
}
|
||||
if milersNearby > 0 {
|
||||
row["milersNearby"] = milersNearby
|
||||
}
|
||||
if available && cand.tpl.Caption != "" {
|
||||
row["caption"] = cand.tpl.Caption
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
|
||||
// Slots are volatile; a stale slot list is a booking that 409s on confirm.
|
||||
c.Set("Cache-Control", "max-age=30")
|
||||
return utils.CxList(c, out, len(out), nil)
|
||||
}
|
||||
|
||||
// cxSlotID mints the opaque slot id the client sends back. It encodes the date
|
||||
// and the template code so the server can resolve it to a real window without
|
||||
// keeping per-request state — and so a slot id from yesterday's cached list
|
||||
// resolves to yesterday and is rejected, rather than silently booking today.
|
||||
func cxSlotID(from time.Time, code string) string {
|
||||
return fmt.Sprintf("slot_%s_%s", from.Format("20060102"), code)
|
||||
}
|
||||
|
||||
// ResolveCxSlot turns a slot id back into the window it names, checking the
|
||||
// template still exists and is active. Returns ok=false for an unknown,
|
||||
// malformed or retired slot.
|
||||
func ResolveCxSlot(slotID string) (from, to time.Time, ok bool) {
|
||||
parts := strings.SplitN(slotID, "_", 3)
|
||||
if len(parts) != 3 || parts[0] != "slot" {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
day, err := time.ParseInLocation("20060102", parts[1], utils.ISTLocation())
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
|
||||
var tpl models.PickupSlotTemplate
|
||||
if err := db.DB.Where("code = ? AND status = ?", parts[2], "Active").
|
||||
First(&tpl).Error; err != nil {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
|
||||
from = time.Date(day.Year(), day.Month(), day.Day(), tpl.Starthour, tpl.Startminute, 0, 0, utils.ISTLocation())
|
||||
to = time.Date(day.Year(), day.Month(), day.Day(), tpl.Endhour, tpl.Endminute, 0, 0, utils.ISTLocation())
|
||||
return from, to, true
|
||||
}
|
||||
|
||||
// CxSlotDateIsPast reports whether a slot id names a day that is already over.
|
||||
//
|
||||
// Pure: it reads the date out of the id and compares it to today, with no
|
||||
// template lookup and no database. That matters because it is the cheapest
|
||||
// validation in the booking path and it catches the most likely stale-slot
|
||||
// case — an app left open across midnight, or one that cached the slot list for
|
||||
// a whole session, sending yesterday's window in good faith.
|
||||
//
|
||||
// Only a whole day in the past is decided here. Whether one of TODAY's windows
|
||||
// has already started needs the template's hours, which ResolveCxSlot loads.
|
||||
func CxSlotDateIsPast(slotID string) bool {
|
||||
parts := strings.SplitN(slotID, "_", 3)
|
||||
if len(parts) != 3 || parts[0] != "slot" {
|
||||
return false
|
||||
}
|
||||
day, err := time.ParseInLocation("20060102", parts[1], utils.ISTLocation())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
now := utils.ISTNow()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, utils.ISTLocation())
|
||||
return day.Before(today)
|
||||
}
|
||||
|
||||
// CxSlotHasCapacity re-checks a window at confirm time. The list read is
|
||||
// advisory and up to 30 seconds stale; this is the authority, and it is what
|
||||
// turns a race into a clean 409 rather than an overbooked window.
|
||||
func CxSlotHasCapacity(slotID string, lat, lng float64) bool {
|
||||
from, to, ok := ResolveCxSlot(slotID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
var tpl models.PickupSlotTemplate
|
||||
parts := strings.SplitN(slotID, "_", 3)
|
||||
if err := db.DB.Where("code = ?", parts[2]).First(&tpl).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return countBookingsInWindow(from, to, lat, lng) < tpl.Capacity
|
||||
}
|
||||
|
||||
// cxSlotCandidate is one concrete window on one concrete day, before capacity
|
||||
// is applied — a template plus the date it was expanded onto.
|
||||
type cxSlotCandidate struct {
|
||||
id string
|
||||
from, to time.Time
|
||||
tpl models.PickupSlotTemplate
|
||||
}
|
||||
|
||||
// slotLoad counts how many live bookings already sit in each candidate window
|
||||
// near this point. Done per window rather than in one grouped query because the
|
||||
// windows overlap across days and the zone filter is geometric, not indexable
|
||||
// here; the list is at most a dozen rows.
|
||||
func slotLoad(candidates []cxSlotCandidate, lat, lng float64) map[string]int {
|
||||
load := make(map[string]int, len(candidates))
|
||||
for _, cand := range candidates {
|
||||
load[cand.id] = countBookingsInWindow(cand.from, cand.to, lat, lng)
|
||||
}
|
||||
return load
|
||||
}
|
||||
|
||||
// countBookingsInWindow counts pickups already committed to a window inside the
|
||||
// customer's zone. Cancelled and completed bookings do not consume capacity —
|
||||
// only work still to be done does.
|
||||
func countBookingsInWindow(from, to time.Time, lat, lng float64) int {
|
||||
// Stored timestamps are IST wall clock (see utils.DBNow), so the bounds are
|
||||
// sent as those digits rather than as a UTC instant. Comparing a UTC clock
|
||||
// against IST-stamped rows is what made date-range reports undercount.
|
||||
fromDB := time.Date(from.Year(), from.Month(), from.Day(), from.Hour(), from.Minute(), 0, 0, time.UTC)
|
||||
toDB := time.Date(to.Year(), to.Month(), to.Day(), to.Hour(), to.Minute(), 0, 0, time.UTC)
|
||||
|
||||
type row struct {
|
||||
Pickuplatitude float64
|
||||
Pickuplongitude float64
|
||||
}
|
||||
var rows []row
|
||||
err := db.DB.Model(&models.PickupBooking{}).
|
||||
Select("pickuplatitude, pickuplongitude").
|
||||
Where("preferredpickupfrom >= ? AND preferredpickupfrom < ?", fromDB, toDB).
|
||||
Where("status NOT IN ?", []string{constants.BookingCancelled, constants.BookingConvertedConsignment}).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
// Failing open keeps the form usable. An over-filled window is an ops
|
||||
// problem; a booking form that cannot offer any slot is a dead app.
|
||||
utils.Warn("countBookingsInWindow: query failed, treating window as open", "error", err)
|
||||
return 0
|
||||
}
|
||||
|
||||
if lat == 0 && lng == 0 {
|
||||
return len(rows)
|
||||
}
|
||||
n := 0
|
||||
for _, r := range rows {
|
||||
if r.Pickuplatitude == 0 && r.Pickuplongitude == 0 {
|
||||
continue
|
||||
}
|
||||
if calculateDistance(lat, lng, r.Pickuplatitude, r.Pickuplongitude) <= cxSlotZoneRadiusKM {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// milersWithin counts riders currently reporting a position inside a radius.
|
||||
// Reads the same Redis GEO index the assignment engine searches, so the number
|
||||
// the customer is shown is the pool the dispatcher would actually draw from.
|
||||
// Returns 0 when Redis is unavailable, and the client hides the line on 0.
|
||||
func milersWithin(lat, lng, radiusKM float64) int {
|
||||
if db.Rdb == nil || (lat == 0 && lng == 0) {
|
||||
return 0
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
locs, err := db.Rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{
|
||||
GeoSearchQuery: redis.GeoSearchQuery{
|
||||
Longitude: lng,
|
||||
Latitude: lat,
|
||||
Radius: radiusKM,
|
||||
RadiusUnit: "km",
|
||||
Sort: "ASC",
|
||||
Count: 50,
|
||||
},
|
||||
}).Result()
|
||||
if err != nil {
|
||||
utils.Warn("milersWithin: geo search failed", "error", err)
|
||||
return 0
|
||||
}
|
||||
return len(locs)
|
||||
}
|
||||
|
||||
// appLocationForPoint resolves which city a coordinate belongs to, via the
|
||||
// nearest active hub. Nil when nothing is close enough to claim it, in which
|
||||
// case only city-agnostic configuration applies.
|
||||
func appLocationForPoint(lat, lng float64) *int {
|
||||
if lat == 0 && lng == 0 {
|
||||
return nil
|
||||
}
|
||||
var hubs []models.Hub
|
||||
if err := db.DB.Select("hubid, applocationid, latitude, longitude").
|
||||
Where("status = ? AND deletedat IS NULL", "Active").Find(&hubs).Error; err != nil {
|
||||
utils.Warn("appLocationForPoint: hub query failed", "error", err)
|
||||
return nil
|
||||
}
|
||||
best := -1.0
|
||||
var bestID *int
|
||||
for i := range hubs {
|
||||
h := hubs[i]
|
||||
if h.Latitude == 0 && h.Longitude == 0 {
|
||||
continue
|
||||
}
|
||||
d := calculateDistance(lat, lng, h.Latitude, h.Longitude)
|
||||
if best < 0 || d < best {
|
||||
best = d
|
||||
id := h.Applocationid
|
||||
bestID = &id
|
||||
}
|
||||
}
|
||||
// A hub 200km away says nothing about which city this is.
|
||||
if bestID == nil || best > 60 {
|
||||
return nil
|
||||
}
|
||||
return bestID
|
||||
}
|
||||
|
||||
// ── Booking limits ───────────────────────────────────────────────────────────
|
||||
|
||||
// cxDefaultMaxPackages / cxDefaultMaxDestinations are the last-resort values,
|
||||
// used only when no configuration row exists at all. They can never be 0 —
|
||||
// a zero cap would reject every booking on the platform.
|
||||
//
|
||||
// cxDefaultMaxDestinations is deliberately **1**, not the 5 the design allows.
|
||||
//
|
||||
// This is the fail-safe half of the multi-destination gate. The gate itself is a
|
||||
// database value (customerbookinglimits.maxdestinations), and a gate that opens
|
||||
// when its configuration is missing is not a gate: a migration that ran without
|
||||
// the seed, a wiped table, or a fresh environment would silently permit
|
||||
// multi-destination bookings that the deployed rider app cannot complete,
|
||||
// stranding parcels with no stop and no way to close them.
|
||||
//
|
||||
// So "no configuration" resolves to the safest behaviour, not the most
|
||||
// permissive. Ops raises it to 5 by inserting the row — an explicit act — once
|
||||
// a rider build keying on consignmentid is live. The client's own 20/5 fallback
|
||||
// is UI guidance only; this is the authority.
|
||||
const (
|
||||
cxDefaultMaxPackages = 20
|
||||
cxDefaultMaxDestinations = 1
|
||||
)
|
||||
|
||||
// GetCxBookingLimits returns the caps on a single pickup.
|
||||
//
|
||||
// Nothing in the UI hardcodes these; they live here so ops can vary them by
|
||||
// city without an app release. Keyed off the pickup location when one is
|
||||
// supplied, so the client can re-fetch when the pickup point moves.
|
||||
func GetCxBookingLimits(c *fiber.Ctx) error {
|
||||
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
|
||||
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
|
||||
|
||||
maxPackages, maxDestinations := CxBookingLimits(appLocationForPoint(lat, lng))
|
||||
return utils.CxOK(c, fiber.Map{
|
||||
"maxPackages": maxPackages,
|
||||
"maxDestinations": maxDestinations,
|
||||
})
|
||||
}
|
||||
|
||||
// CxBookingLimits resolves the caps for a city, falling back to the global row
|
||||
// and then to the built-in defaults. Never returns 0 for either: a zero cap
|
||||
// rejects every booking, and a configuration mistake must not be able to take
|
||||
// the product offline.
|
||||
func CxBookingLimits(appLocationID *int) (maxPackages, maxDestinations int) {
|
||||
maxPackages, maxDestinations = cxDefaultMaxPackages, cxDefaultMaxDestinations
|
||||
|
||||
var limit models.CustomerBookingLimit
|
||||
found := false
|
||||
if appLocationID != nil {
|
||||
if err := db.DB.Where("applocationid = ?", *appLocationID).First(&limit).Error; err == nil {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if err := db.DB.Where("applocationid IS NULL").First(&limit).Error; err == nil {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
if limit.Maxpackages > 0 {
|
||||
maxPackages = limit.Maxpackages
|
||||
}
|
||||
if limit.Maxdestinations > 0 {
|
||||
maxDestinations = limit.Maxdestinations
|
||||
}
|
||||
return
|
||||
}
|
||||
100
controllers/cxConsignmentHooks.go
Normal file
100
controllers/cxConsignmentHooks.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"doormile/constants"
|
||||
"doormile/internal/cxstage"
|
||||
"doormile/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Per-order stages — the second half of §8.3.
|
||||
//
|
||||
// Stages 6-8 belong to each order rather than to the booking, and may differ
|
||||
// between destinations of the same pickup: one parcel out for delivery in
|
||||
// Chennai while another is still at a hub in Kerala. Each of the operational
|
||||
// writes that moves a consignment records its customer stage against the
|
||||
// destination that consignment belongs to, and the booking's own stage falls
|
||||
// back to the least-advanced of them.
|
||||
|
||||
// cxStageForConsignmentStatus maps an operational consignment status onto the
|
||||
// customer stage it means. Not every status has one: a parcel sitting on a
|
||||
// tripsheet, or one flagged missing, is still "in transit" as far as the
|
||||
// customer's seven milestones go, and inventing a stage for it would put a key
|
||||
// on the wire the client silently falls back to `booked` for.
|
||||
func cxStageForConsignmentStatus(status string) (string, bool) {
|
||||
switch status {
|
||||
case constants.ConsignmentInwardedAtHub,
|
||||
constants.ConsignmentTripsheetLoaded,
|
||||
constants.ConsignmentInTransit:
|
||||
return constants.CxStageInTransit, true
|
||||
case constants.ConsignmentOutForDelivery:
|
||||
return constants.CxStageOutForDelivery, true
|
||||
case constants.ConsignmentDelivered:
|
||||
return constants.CxStageDelivered, true
|
||||
default:
|
||||
// Created and Collected_By_Miler both mean "the order exists and is in
|
||||
// the rider's hands", which is order_created — already recorded at
|
||||
// pickup-complete, so there is nothing new to say.
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// recordCxConsignmentStage records the customer stage for one consignment,
|
||||
// inside the caller's transaction, and returns the notification to fire once
|
||||
// that transaction commits.
|
||||
//
|
||||
// The consignment is resolved to its destination through bookingdestinations,
|
||||
// not through pickupbookings.consignmentid. That column names only the FIRST
|
||||
// order of a multi-destination pickup, so a lookup through it finds nothing for
|
||||
// destinations 2..N — which would mean no stage advance and no notification on
|
||||
// every order after the first.
|
||||
//
|
||||
// A consignment that belongs to no customer booking at all — a console-created
|
||||
// express shipment — is a no-op, not an error. Those have no customer app
|
||||
// watching them.
|
||||
func recordCxConsignmentStage(tx *gorm.DB, consignmentID int, status string, actorType string, actorID *int, source string) (afterCommit func(), err error) {
|
||||
noop := func() {}
|
||||
|
||||
stage, ok := cxStageForConsignmentStatus(status)
|
||||
if !ok {
|
||||
return noop, nil
|
||||
}
|
||||
|
||||
dest, booking, found := cxDestinationForConsignment(consignmentID)
|
||||
if !found || booking == nil {
|
||||
return noop, nil
|
||||
}
|
||||
if booking.Bookingsource != constants.BookingSourceCustomerApp {
|
||||
return noop, nil
|
||||
}
|
||||
|
||||
destinationID := cxDestinationIDFor(dest)
|
||||
if err := cxstage.Record(tx, cxstage.Event{
|
||||
BookingID: booking.Bookingid,
|
||||
DestinationID: destinationID,
|
||||
Stage: stage,
|
||||
ActorType: actorType,
|
||||
ActorID: actorID,
|
||||
Source: source,
|
||||
At: utils.DBNow(),
|
||||
}); err != nil {
|
||||
return noop, err
|
||||
}
|
||||
|
||||
bookingID := booking.Bookingid
|
||||
return func() { go cxstage.Notify(bookingID, destinationID, stage) }, nil
|
||||
}
|
||||
|
||||
// cancelCxBookingFromOps stands a pickup down on behalf of a rider or an ops
|
||||
// user, recording who did it and why.
|
||||
//
|
||||
// A customer whose pickup disappears with no explanation has no way to tell a
|
||||
// cancellation from a bug, and support has no way to answer them — so the
|
||||
// actor and the reason are part of the write, not an afterthought.
|
||||
func cancelCxBookingFromOps(tx *gorm.DB, bookingID int, reason, actorType string, actorID *int, source string) error {
|
||||
if reason == "" {
|
||||
reason = "Cancelled by Doormile"
|
||||
}
|
||||
return cxstage.Cancel(tx, bookingID, reason, actorType, actorID, source)
|
||||
}
|
||||
856
controllers/cxCustomerApp_test.go
Normal file
856
controllers/cxCustomerApp_test.go
Normal file
@@ -0,0 +1,856 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
)
|
||||
|
||||
// The app currently sends "+91 98765 43210" with spaces and is being tightened
|
||||
// to send E.164. Both shapes must land on the SAME stored value: if they do
|
||||
// not, a customer signing in from the newer build gets a second account and
|
||||
// loses every booking they have made.
|
||||
func TestNormalizePhoneCollapsesEveryShapeToOne(t *testing.T) {
|
||||
want := "+919876543210"
|
||||
|
||||
inputs := []string{
|
||||
"+91 98765 43210", // what the app sends today
|
||||
"+919876543210", // what it is being tightened to send
|
||||
"9876543210", // bare keypad entry
|
||||
"09876543210", // with the national trunk prefix
|
||||
"919876543210", // country code, no plus
|
||||
"+91-98765-43210", // typed with dashes by support
|
||||
" 9876543210 ", // pasted with whitespace
|
||||
}
|
||||
|
||||
for _, in := range inputs {
|
||||
got, kind, ok := normalizePhone(in)
|
||||
if !ok {
|
||||
t.Errorf("normalizePhone(%q) rejected a valid number", in)
|
||||
continue
|
||||
}
|
||||
if kind != "phone" {
|
||||
t.Errorf("normalizePhone(%q) kind = %q, want \"phone\"", in, kind)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("normalizePhone(%q) = %q, want %q — two spellings of one number must not make two accounts",
|
||||
in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePhoneRejectsNonsense(t *testing.T) {
|
||||
for _, in := range []string{"", " ", "12345", "abcdef", "+1"} {
|
||||
if got, _, ok := normalizePhone(in); ok {
|
||||
t.Errorf("normalizePhone(%q) = %q, ok — want rejected", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIdentifierSplitsPhoneFromEmail(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
wantKind string
|
||||
wantOK bool
|
||||
}{
|
||||
{"Joe@Example.COM", "joe@example.com", "email", true},
|
||||
{"+91 98765 43210", "+919876543210", "phone", true},
|
||||
{"joe@example", "", "", false}, // no dot in the domain
|
||||
{"@example.com", "", "", false}, // no local part
|
||||
{"joe@", "", "", false}, // no domain
|
||||
{"", "", "", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got, kind, ok := normalizeIdentifier(tc.in)
|
||||
if ok != tc.wantOK || got != tc.want || kind != tc.wantKind {
|
||||
t.Errorf("normalizeIdentifier(%q) = (%q, %q, %v), want (%q, %q, %v)",
|
||||
tc.in, got, kind, ok, tc.want, tc.wantKind, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitNameKeepsOneWordNames(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
first, last string
|
||||
}{
|
||||
{"Joe Oommen", "Joe", "Oommen"},
|
||||
{"Meera", "Meera", ""}, // plenty of people have one name
|
||||
{" Arun Kumar ", "Arun", "Kumar"}, // collapses whitespace
|
||||
{"Vijay Raghav Menon", "Vijay Raghav", "Menon"}, // last token is the surname
|
||||
{"J", "", ""}, // under two characters is invalid_name
|
||||
{"", "", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
first, last := splitName(tc.in)
|
||||
if first != tc.first || last != tc.last {
|
||||
t.Errorf("splitName(%q) = (%q, %q), want (%q, %q)", tc.in, first, last, tc.first, tc.last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The slot id is opaque to the client but has to round-trip: it encodes the
|
||||
// date so a slot id from yesterday's cached list resolves to yesterday and gets
|
||||
// rejected, rather than silently booking today's window.
|
||||
func TestSlotIDCarriesItsDate(t *testing.T) {
|
||||
from := time.Date(2026, 9, 5, 14, 0, 0, 0, time.UTC)
|
||||
if got, want := cxSlotID(from, "t1"), "slot_20260905_t1"; got != want {
|
||||
t.Errorf("cxSlotID = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Two days must never produce the same id for the same template.
|
||||
other := time.Date(2026, 9, 6, 14, 0, 0, 0, time.UTC)
|
||||
if cxSlotID(from, "t1") == cxSlotID(other, "t1") {
|
||||
t.Error("cxSlotID collides across days — a stale slot would book today's window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeParcelsMatchesTheReceiptCopy(t *testing.T) {
|
||||
cases := []struct {
|
||||
packages int
|
||||
want string
|
||||
}{
|
||||
{0, "Standard box (up to 3 kg)"},
|
||||
{1, "Standard box (up to 3 kg)"},
|
||||
{3, "3 boxes (up to 3 kg each)"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := describeParcels(tc.packages); got != tc.want {
|
||||
t.Errorf("describeParcels(%d) = %q, want %q", tc.packages, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pickup.title and pickup.sub are typed non-nullable by the client and will
|
||||
// throw in its parser on a null. A console-created booking has only one flat
|
||||
// address string, so both have to be derivable from it.
|
||||
func TestPickupTitleAndSubAreNeverEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
booking models.PickupBooking
|
||||
}{
|
||||
{"customer-app booking", models.PickupBooking{
|
||||
Pickuptitle: "12 Nehru Street", Pickupsub: "Gandhipuram, Coimbatore 641012"}},
|
||||
{"console booking with a flat address", models.PickupBooking{
|
||||
Pickupaddress: "12 Nehru Street, Gandhipuram, Coimbatore", Pickuppincode: "641012"}},
|
||||
{"address with no comma", models.PickupBooking{
|
||||
Pickupaddress: "Brookefields", Pickuppincode: "641001"}},
|
||||
{"nothing recorded at all", models.PickupBooking{}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
b := tc.booking
|
||||
if title := cxPickupTitle(&b); title == "" {
|
||||
t.Error("cxPickupTitle returned empty — the client types it non-nullable")
|
||||
}
|
||||
if sub := cxPickupSub(&b); sub == "" {
|
||||
t.Error("cxPickupSub returned empty — the client types it non-nullable")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickupTitleIsCappedForTheDesign(t *testing.T) {
|
||||
b := models.PickupBooking{
|
||||
Pickupaddress: "A very long single-line address with no commas at all in it anywhere",
|
||||
}
|
||||
if got := cxPickupTitle(&b); len([]rune(got)) > 32 {
|
||||
t.Errorf("cxPickupTitle = %q (%d runes), want at most 32", got, len([]rune(got)))
|
||||
}
|
||||
}
|
||||
|
||||
// deliveredAt is the moment the LAST parcel landed. Reporting the first would
|
||||
// tell a customer their whole pickup completed while parcels were still moving.
|
||||
func TestAllDeliveredAtWaitsForTheLastParcel(t *testing.T) {
|
||||
early := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
|
||||
late := time.Date(2026, 9, 6, 16, 0, 0, 0, time.UTC)
|
||||
|
||||
partly := []models.BookingDestination{
|
||||
{Deliveredat: &early},
|
||||
{Deliveredat: nil},
|
||||
}
|
||||
if got := cxAllDeliveredAt(partly); got != nil {
|
||||
t.Errorf("cxAllDeliveredAt with one parcel still moving = %v, want nil", got)
|
||||
}
|
||||
|
||||
all := []models.BookingDestination{
|
||||
{Deliveredat: &early},
|
||||
{Deliveredat: &late},
|
||||
}
|
||||
got := cxAllDeliveredAt(all)
|
||||
if got == nil || !got.Equal(late) {
|
||||
t.Errorf("cxAllDeliveredAt = %v, want the last delivery %v", got, late)
|
||||
}
|
||||
|
||||
if got := cxAllDeliveredAt(nil); got != nil {
|
||||
t.Errorf("cxAllDeliveredAt(no destinations) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A booking written before this surface existed carries no stored stage, and a
|
||||
// console-created one never will. Both still have to render, so the stage is
|
||||
// derived from the operational status rather than left blank.
|
||||
func TestStageDerivationForBookingsWithNoStoredStage(t *testing.T) {
|
||||
arrived := time.Date(2026, 9, 5, 14, 0, 0, 0, time.UTC)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
booking models.PickupBooking
|
||||
want string
|
||||
}{
|
||||
{"pending pickup", models.PickupBooking{Status: constants.BookingPendingPickup}, constants.CxStageBooked},
|
||||
{"assigned, not yet arrived", models.PickupBooking{Status: constants.BookingMilerAssigned}, constants.CxStageAssigned},
|
||||
{"scheduled and arrived", models.PickupBooking{Status: constants.BookingPickupScheduled, Arrivedat: &arrived}, constants.CxStageArrived},
|
||||
{"picked up", models.PickupBooking{Status: constants.BookingPickedUp}, constants.CxStagePickedUp},
|
||||
{"converted", models.PickupBooking{Status: constants.BookingConvertedConsignment}, constants.CxStageOrderCreated},
|
||||
{"cancelled", models.PickupBooking{Status: constants.BookingCancelled}, constants.CxStageBooked},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
b := tc.booking
|
||||
if got := deriveStageFromStatus(&b); got != tc.want {
|
||||
t.Errorf("deriveStageFromStatus = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The timeline is built from the event log. Cancellation and release rows carry
|
||||
// a stage key only because the table needs one — putting them on the timeline
|
||||
// would show the customer "Pickup booked" a second time when a rider handed
|
||||
// their pickup back.
|
||||
func TestHistoryExcludesAuditRowsAndDeduplicates(t *testing.T) {
|
||||
at := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
events := []models.BookingStageEvent{
|
||||
{Stage: constants.CxStageBooked, Occurredat: at},
|
||||
{Stage: constants.CxStageAssigned, Occurredat: at.Add(time.Minute)},
|
||||
{Stage: constants.CxStageBooked, Remarks: "released: rider unavailable", Occurredat: at.Add(2 * time.Minute)},
|
||||
{Stage: constants.CxStageAssigned, Occurredat: at.Add(3 * time.Minute)}, // second rider, same stage
|
||||
{Stage: constants.CxStageBooked, Remarks: "cancelled: Package not ready", Occurredat: at.Add(4 * time.Minute)},
|
||||
}
|
||||
|
||||
history := renderCxHistory(events)
|
||||
if len(history) != 2 {
|
||||
t.Fatalf("renderCxHistory returned %d entries, want 2 (booked, assigned)", len(history))
|
||||
}
|
||||
if history[0]["stage"] != constants.CxStageBooked || history[1]["stage"] != constants.CxStageAssigned {
|
||||
t.Errorf("renderCxHistory = %v, want booked then assigned in order", history)
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-destination pickup emits each per-order stage once per destination.
|
||||
// The booking reaches that stage when its SLOWEST order does, so the timeline
|
||||
// must carry the LAST of them — otherwise "Delivered" is timestamped at the
|
||||
// moment the earliest parcel landed while deliveredAt reports the last one, and
|
||||
// the same screen contradicts itself.
|
||||
func TestHistoryTimestampsPerOrderStagesAtTheSlowestOrder(t *testing.T) {
|
||||
base := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
|
||||
firstParcel := base.Add(2 * time.Hour)
|
||||
lastParcel := base.Add(30 * time.Hour)
|
||||
|
||||
events := []models.BookingStageEvent{
|
||||
{Stage: constants.CxStageBooked, Occurredat: base},
|
||||
{Stage: constants.CxStageDelivered, Occurredat: firstParcel},
|
||||
{Stage: constants.CxStageDelivered, Occurredat: lastParcel},
|
||||
}
|
||||
|
||||
history := renderCxHistory(events)
|
||||
if len(history) != 2 {
|
||||
t.Fatalf("renderCxHistory returned %d entries, want 2", len(history))
|
||||
}
|
||||
if got, want := history[1]["at"], utils.EpochMillis(lastParcel); got != want {
|
||||
t.Errorf("delivered timestamped at %v, want the last parcel %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Booking-level stages happen once for the whole pickup, so a duplicate is a
|
||||
// re-assertion (Record dedupes, but a release can bring one back) and the
|
||||
// original moment is the true one.
|
||||
func TestHistoryKeepsTheFirstBookingLevelTimestamp(t *testing.T) {
|
||||
first := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
|
||||
later := first.Add(3 * time.Hour)
|
||||
|
||||
events := []models.BookingStageEvent{
|
||||
{Stage: constants.CxStageAssigned, Occurredat: first},
|
||||
{Stage: constants.CxStageAssigned, Occurredat: later},
|
||||
}
|
||||
|
||||
history := renderCxHistory(events)
|
||||
if len(history) != 1 {
|
||||
t.Fatalf("renderCxHistory returned %d entries, want 1", len(history))
|
||||
}
|
||||
if got, want := history[0]["at"], utils.EpochMillis(first); got != want {
|
||||
t.Errorf("assigned timestamped at %v, want the original %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Every stage the timeline can carry has to be one the client parses. An
|
||||
// unknown key is silently rendered as `booked`, so a mapping that invents one
|
||||
// makes a moving parcel look un-started.
|
||||
func TestConsignmentStatusMapsOnlyToRealStages(t *testing.T) {
|
||||
mapped := map[string]string{
|
||||
constants.ConsignmentInwardedAtHub: constants.CxStageInTransit,
|
||||
constants.ConsignmentTripsheetLoaded: constants.CxStageInTransit,
|
||||
constants.ConsignmentInTransit: constants.CxStageInTransit,
|
||||
constants.ConsignmentOutForDelivery: constants.CxStageOutForDelivery,
|
||||
constants.ConsignmentDelivered: constants.CxStageDelivered,
|
||||
}
|
||||
for status, want := range mapped {
|
||||
got, ok := cxStageForConsignmentStatus(status)
|
||||
if !ok || got != want {
|
||||
t.Errorf("cxStageForConsignmentStatus(%q) = (%q, %v), want (%q, true)", status, got, ok, want)
|
||||
}
|
||||
if constants.CxStageRank(got) < 0 {
|
||||
t.Errorf("cxStageForConsignmentStatus(%q) produced %q, which is not one of the nine stage keys", status, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Statuses that mean "the order exists and is in the rider's hands" are
|
||||
// already covered by order_created and must not emit a second stage.
|
||||
for _, status := range []string{
|
||||
constants.ConsignmentCreated,
|
||||
constants.ConsignmentCollectedByMiler,
|
||||
constants.ConsignmentMissing,
|
||||
"Cancelled",
|
||||
} {
|
||||
if got, ok := cxStageForConsignmentStatus(status); ok {
|
||||
t.Errorf("cxStageForConsignmentStatus(%q) = %q, want no stage", status, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cxLegWeights is what the price settles on. A leg whose packages were never
|
||||
// weighed must still produce a chargeable consignment, or an unweighed pickup
|
||||
// bills at zero.
|
||||
func TestLegWeightsFallBackWhenNothingWasWeighed(t *testing.T) {
|
||||
empty := cxPickupLeg{}
|
||||
dead, chargeable, _, _, _ := cxLegWeights(empty)
|
||||
if dead != 0.5 || chargeable != 0.5 {
|
||||
t.Errorf("cxLegWeights(no parcels) = (%v, %v), want the 0.5 kg placeholder", dead, chargeable)
|
||||
}
|
||||
|
||||
// Volumetric weight wins when the box is bulky and light — that is the
|
||||
// whole reason the column exists.
|
||||
bulky := cxPickupLeg{Parcels: []models.BookingParcel{
|
||||
{Weight: 1.0, Length: 40, Width: 40, Height: 40}, // volumetric = 64000/5000 = 12.8
|
||||
}}
|
||||
_, chargeable, maxL, _, _ := cxLegWeights(bulky)
|
||||
if chargeable != 12.8 {
|
||||
t.Errorf("cxLegWeights chargeable = %v, want the volumetric 12.8", chargeable)
|
||||
}
|
||||
if maxL != 40 {
|
||||
t.Errorf("cxLegWeights maxL = %v, want 40", maxL)
|
||||
}
|
||||
}
|
||||
|
||||
// How many stops a booking is worth to the rider decides whether a parcel is
|
||||
// deliverable at all. The queue used to emit one row per booking keyed on
|
||||
// pickupbookings.consignmentid — a column that names only the FIRST order — so
|
||||
// on a three-destination pickup two parcels sat in the rider's bag with no
|
||||
// stop, no deliver button and no way to close them.
|
||||
func TestMilerStopsBeforeCollectionAreOneVisit(t *testing.T) {
|
||||
booking := models.PickupBooking{
|
||||
Bookingid: 7,
|
||||
Deliveryaddress: "12th Main, Chennai",
|
||||
Deliverylatitude: 13.08,
|
||||
Deliverylongitude: 80.27,
|
||||
}
|
||||
// Booked, not yet collected: no destination carries a consignment.
|
||||
destinations := []models.BookingDestination{
|
||||
{Bookingdestinationid: 1, Bookingid: 7, Seq: 0, Districtname: "Chennai"},
|
||||
{Bookingdestinationid: 2, Bookingid: 7, Seq: 1, Districtname: "Ernakulam"},
|
||||
{Bookingdestinationid: 3, Bookingid: 7, Seq: 2, Districtname: "Bengaluru Urban"},
|
||||
}
|
||||
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
if len(stops) != 1 {
|
||||
t.Fatalf("got %d stops before collection, want 1 — the rider makes ONE visit to the door", len(stops))
|
||||
}
|
||||
if stops[0].deliveryLat != 13.08 {
|
||||
t.Errorf("pre-pickup stop lost the booking's mirrored coordinates: %v", stops[0].deliveryLat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMilerStopsAfterCollectionAreOnePerOrder(t *testing.T) {
|
||||
c1, c2, c3 := 101, 102, 103
|
||||
pinLat, pinLng := 9.98, 76.29
|
||||
|
||||
booking := models.PickupBooking{
|
||||
Bookingid: 7,
|
||||
Consignmentid: &c1,
|
||||
Deliverylatitude: 13.08,
|
||||
Deliverylongitude: 80.27,
|
||||
Parcels: []models.BookingParcel{
|
||||
{Bookingparcelid: 1, Bookingdestinationid: intPtr(1)},
|
||||
{Bookingparcelid: 2, Bookingdestinationid: intPtr(1)},
|
||||
{Bookingparcelid: 3, Bookingdestinationid: intPtr(2)},
|
||||
{Bookingparcelid: 4, Bookingdestinationid: intPtr(3)},
|
||||
},
|
||||
}
|
||||
destinations := []models.BookingDestination{
|
||||
{Bookingdestinationid: 1, Bookingid: 7, Seq: 0, Districtname: "Chennai",
|
||||
Statename: "Tamil Nadu", Consignmentid: &c1, Trackingno: "DMX10000001"},
|
||||
{Bookingdestinationid: 2, Bookingid: 7, Seq: 1, Districtname: "Ernakulam",
|
||||
Statename: "Kerala", Consignmentid: &c2, Trackingno: "DMX10000002",
|
||||
Pinlatitude: &pinLat, Pinlongitude: &pinLng},
|
||||
{Bookingdestinationid: 3, Bookingid: 7, Seq: 2, Districtname: "Bengaluru Urban",
|
||||
Statename: "Karnataka", Consignmentid: &c3, Trackingno: "DMX10000003"},
|
||||
}
|
||||
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
if len(stops) != 3 {
|
||||
t.Fatalf("got %d stops after collection, want 3 — one per order, or the other parcels are undeliverable", len(stops))
|
||||
}
|
||||
|
||||
// Each stop names its own order. Sharing one consignment id would have the
|
||||
// rider close the same parcel three times.
|
||||
seen := map[int]bool{}
|
||||
for i, s := range stops {
|
||||
if s.consignmentID == nil {
|
||||
t.Fatalf("stop %d has no consignment id", i)
|
||||
}
|
||||
if seen[*s.consignmentID] {
|
||||
t.Errorf("stop %d repeats consignment %d", i, *s.consignmentID)
|
||||
}
|
||||
seen[*s.consignmentID] = true
|
||||
if s.trackingNo == "" {
|
||||
t.Errorf("stop %d has no tracking number", i)
|
||||
}
|
||||
if s.deliveryAddress == "" {
|
||||
t.Errorf("stop %d has no delivery address — the rider has nowhere to go", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Parcels follow their own destination, so each order settles and is handed
|
||||
// over with the packages that actually belong to it.
|
||||
if len(stops[0].parcels) != 2 || len(stops[1].parcels) != 1 || len(stops[2].parcels) != 1 {
|
||||
t.Errorf("parcels split as %d/%d/%d, want 2/1/1",
|
||||
len(stops[0].parcels), len(stops[1].parcels), len(stops[2].parcels))
|
||||
}
|
||||
|
||||
// A destination with its own pin uses it; destination 0 falls back to the
|
||||
// coordinates mirrored onto the booking, which the rider may have corrected.
|
||||
if stops[1].deliveryLat != pinLat {
|
||||
t.Errorf("stop 1 lat = %v, want the customer's pin %v", stops[1].deliveryLat, pinLat)
|
||||
}
|
||||
if stops[0].deliveryLat != 13.08 {
|
||||
t.Errorf("stop 0 lat = %v, want the booking's mirrored 13.08", stops[0].deliveryLat)
|
||||
}
|
||||
}
|
||||
|
||||
// A console-created express booking has no destination rows at all and must
|
||||
// keep producing exactly the one stop it always did.
|
||||
func TestMilerStopsForConsoleBookingAreUnchanged(t *testing.T) {
|
||||
cid := 55
|
||||
booking := models.PickupBooking{
|
||||
Bookingid: 9,
|
||||
Consignmentid: &cid,
|
||||
Deliveryaddress: "Kitchen 4, Nagercoil",
|
||||
Deliverylatitude: 8.17,
|
||||
Deliverylongitude: 77.43,
|
||||
Parcels: []models.BookingParcel{{Bookingparcelid: 1}},
|
||||
}
|
||||
|
||||
stops := milerStopsForBooking(&booking, nil)
|
||||
if len(stops) != 1 {
|
||||
t.Fatalf("got %d stops, want 1", len(stops))
|
||||
}
|
||||
if stops[0].consignmentID == nil || *stops[0].consignmentID != cid {
|
||||
t.Errorf("console stop lost its consignment id: %v", stops[0].consignmentID)
|
||||
}
|
||||
if stops[0].deliveryAddress != "Kitchen 4, Nagercoil" || len(stops[0].parcels) != 1 {
|
||||
t.Errorf("console stop changed shape: %+v", stops[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Three console paths cancel a booking by writing pickupbookings.status
|
||||
// directly and none of them knows the customer projection exists. The customer
|
||||
// must never keep seeing a cancelled pickup as active and cancellable, so the
|
||||
// operational status is the authority here regardless of what customerstatus
|
||||
// holds.
|
||||
func TestOpsCancellationAlwaysReachesTheCustomer(t *testing.T) {
|
||||
bundle := loadCxBundleForTest()
|
||||
|
||||
booking := models.PickupBooking{
|
||||
Bookingid: 1,
|
||||
Bookingno: "DM-482913",
|
||||
// Written as active at booking time — this is the value that used to
|
||||
// win and keep a cancelled pickup looking live.
|
||||
Customerstatus: constants.CxStatusActive,
|
||||
Customerstage: constants.CxStageAssigned,
|
||||
Status: constants.BookingCancelled,
|
||||
Createdat: time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
out := renderCxBooking(&booking, bundle)
|
||||
|
||||
if out["status"] != constants.CxStatusCancelled {
|
||||
t.Errorf("status = %v, want %q — an ops cancel must reach the customer",
|
||||
out["status"], constants.CxStatusCancelled)
|
||||
}
|
||||
if out["cancellable"] != false {
|
||||
t.Errorf("cancellable = %v on a cancelled pickup, want false", out["cancellable"])
|
||||
}
|
||||
}
|
||||
|
||||
// A booking that is genuinely still live must not be swept up by that rule.
|
||||
func TestActiveBookingStaysActiveAndCancellable(t *testing.T) {
|
||||
bundle := loadCxBundleForTest()
|
||||
|
||||
booking := models.PickupBooking{
|
||||
Bookingid: 2,
|
||||
Bookingno: "DM-482914",
|
||||
Customerstatus: constants.CxStatusActive,
|
||||
Customerstage: constants.CxStageArrived,
|
||||
Status: constants.BookingPickupScheduled,
|
||||
Createdat: time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
out := renderCxBooking(&booking, bundle)
|
||||
|
||||
if out["status"] != constants.CxStatusActive {
|
||||
t.Errorf("status = %v, want %q", out["status"], constants.CxStatusActive)
|
||||
}
|
||||
// arrived is the last cancellable stage.
|
||||
if out["cancellable"] != true {
|
||||
t.Errorf("cancellable = %v at arrived, want true", out["cancellable"])
|
||||
}
|
||||
}
|
||||
|
||||
// loadCxBundleForTest builds an empty bundle, so the projection can be
|
||||
// exercised without a database.
|
||||
func loadCxBundleForTest() *cxBookingBundle {
|
||||
return &cxBookingBundle{
|
||||
destinations: map[int][]models.BookingDestination{},
|
||||
events: map[int][]models.BookingStageEvent{},
|
||||
photos: map[int][]models.BookingParcelPhoto{},
|
||||
milers: map[int]cxAgent{},
|
||||
assignedTo: map[int]int{},
|
||||
deliveryAgent: map[int]int{},
|
||||
payments: map[int]float64{},
|
||||
districts: map[string]models.ServiceableDistrict{},
|
||||
hubNames: map[int]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fan-out row-level proof ─────────────────────────────────────────────────
|
||||
//
|
||||
// One pickup, three genuinely different destinations. Every assertion below
|
||||
// exists because the failure it guards against is silent: the rows would still
|
||||
// render, the rider would still see stops, and the parcels would go to the
|
||||
// wrong doors. Booking-level destination-0 data leaking into rows 2 and 3 is
|
||||
// the specific defect this locks down.
|
||||
|
||||
// cxThreeDestinationFixture builds a collected pickup bound for Chennai,
|
||||
// Ernakulam and Bengaluru — different addresses, different coordinates,
|
||||
// different recipients, different tracking numbers, different COD.
|
||||
func cxThreeDestinationFixture() (models.PickupBooking, []models.BookingDestination) {
|
||||
c1, c2, c3 := 901, 902, 903
|
||||
chennaiLat, chennaiLng := 13.082680, 80.270718
|
||||
kochiLat, kochiLng := 9.981636, 76.299881
|
||||
blrLat, blrLng := 12.971599, 77.594566
|
||||
|
||||
booking := models.PickupBooking{
|
||||
Bookingid: 70,
|
||||
Bookingno: "DM-482913",
|
||||
Consignmentid: &c1,
|
||||
// Booking-level delivery data mirrors destination 0 ONLY. If any of it
|
||||
// leaks onto stops 1 or 2, the assertions below catch it.
|
||||
Deliveryaddress: "3B, 12th Main, Chennai, Tamil Nadu",
|
||||
Deliverylatitude: chennaiLat,
|
||||
Deliverylongitude: chennaiLng,
|
||||
Parcels: []models.BookingParcel{
|
||||
{Bookingparcelid: 1, Bookingdestinationid: intPtr(11)},
|
||||
{Bookingparcelid: 2, Bookingdestinationid: intPtr(11)},
|
||||
{Bookingparcelid: 3, Bookingdestinationid: intPtr(12)},
|
||||
{Bookingparcelid: 4, Bookingdestinationid: intPtr(13)},
|
||||
},
|
||||
}
|
||||
|
||||
destinations := []models.BookingDestination{
|
||||
{
|
||||
Bookingdestinationid: 11, Bookingid: 70, Seq: 0,
|
||||
Statecode: "TN", Statename: "Tamil Nadu",
|
||||
Districtcode: "TN-MAA", Districtname: "Chennai",
|
||||
Building: "3B", Street: "12th Main",
|
||||
Recipientname: "Meera S", Recipientphone: "+919884412210",
|
||||
Packagecount: 2, Codamount: 1200,
|
||||
Consignmentid: &c1, Trackingno: "DMX10482913",
|
||||
Pinlatitude: &chennaiLat, Pinlongitude: &chennaiLng,
|
||||
},
|
||||
{
|
||||
Bookingdestinationid: 12, Bookingid: 70, Seq: 1,
|
||||
Statecode: "KL", Statename: "Kerala",
|
||||
Districtcode: "KL-EKM", Districtname: "Ernakulam",
|
||||
Street: "Marine Drive", Landmark: "Near the ferry",
|
||||
Recipientname: "Joe Oommen", Recipientphone: "+919847011223",
|
||||
Packagecount: 1, Codamount: 0,
|
||||
Consignmentid: &c2, Trackingno: "DMX10559120",
|
||||
Pinlatitude: &kochiLat, Pinlongitude: &kochiLng,
|
||||
},
|
||||
{
|
||||
Bookingdestinationid: 13, Bookingid: 70, Seq: 2,
|
||||
Statecode: "KA", Statename: "Karnataka",
|
||||
Districtcode: "KA-BLR", Districtname: "Bengaluru Urban",
|
||||
Street: "Brigade Road",
|
||||
Recipientname: "Arun Kumar", Recipientphone: "+919000011223",
|
||||
Packagecount: 1, Codamount: 450,
|
||||
Consignmentid: &c3, Trackingno: "DMX10662004",
|
||||
Pinlatitude: &blrLat, Pinlongitude: &blrLng,
|
||||
},
|
||||
}
|
||||
return booking, destinations
|
||||
}
|
||||
|
||||
// Each generated stop carries a DIFFERENT, correct address — none of them
|
||||
// inherits the booking-level destination-0 string.
|
||||
func TestFanoutStopsHaveDistinctAddresses(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
|
||||
if len(stops) != 3 {
|
||||
t.Fatalf("got %d stops, want 3", len(stops))
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for i, s := range stops {
|
||||
if s.deliveryAddress == "" {
|
||||
t.Fatalf("stop %d has no delivery address — the rider has nowhere to go", i)
|
||||
}
|
||||
if seen[s.deliveryAddress] {
|
||||
t.Errorf("stop %d repeats an address already used: %q", i, s.deliveryAddress)
|
||||
}
|
||||
seen[s.deliveryAddress] = true
|
||||
}
|
||||
|
||||
wantDistrict := []string{"Chennai", "Ernakulam", "Bengaluru Urban"}
|
||||
for i, want := range wantDistrict {
|
||||
if !strings.Contains(stops[i].deliveryAddress, want) {
|
||||
t.Errorf("stop %d address %q does not name %q", i, stops[i].deliveryAddress, want)
|
||||
}
|
||||
}
|
||||
// The specific leak this guards: destination 0's address on a later stop.
|
||||
for i := 1; i < 3; i++ {
|
||||
if strings.Contains(stops[i].deliveryAddress, "Chennai") {
|
||||
t.Errorf("stop %d inherited destination 0's address: %q", i, stops[i].deliveryAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Coordinates are distinct and correct per stop. A shared coordinate sends
|
||||
// every parcel to one map pin, which is how a rider drives to the wrong city.
|
||||
func TestFanoutStopsHaveDistinctCoordinates(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
|
||||
type point struct{ lat, lng float64 }
|
||||
want := []point{{13.082680, 80.270718}, {9.981636, 76.299881}, {12.971599, 77.594566}}
|
||||
|
||||
seen := map[point]bool{}
|
||||
for i, s := range stops {
|
||||
got := point{s.deliveryLat, s.deliveryLng}
|
||||
if got.lat == 0 && got.lng == 0 {
|
||||
t.Errorf("stop %d sits at 0,0 — route sequencing skips those", i)
|
||||
}
|
||||
if seen[got] {
|
||||
t.Errorf("stop %d repeats coordinates %v", i, got)
|
||||
}
|
||||
seen[got] = true
|
||||
if got != want[i] {
|
||||
t.Errorf("stop %d coordinates = %v, want %v", i, got, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recipient details stay attached to their own stop. Delivering Meera's parcel
|
||||
// while showing Joe's phone number is a handover to the wrong person.
|
||||
func TestFanoutRecipientsStayWithTheirStop(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
|
||||
want := []struct{ name, phone string }{
|
||||
{"Meera S", "+919884412210"},
|
||||
{"Joe Oommen", "+919847011223"},
|
||||
{"Arun Kumar", "+919000011223"},
|
||||
}
|
||||
for i, w := range want {
|
||||
if stops[i].recipientName != w.name {
|
||||
t.Errorf("stop %d recipientName = %q, want %q", i, stops[i].recipientName, w.name)
|
||||
}
|
||||
if stops[i].recipientPhone != w.phone {
|
||||
t.Errorf("stop %d recipientPhone = %q, want %q", i, stops[i].recipientPhone, w.phone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tracking and consignment ids stay attached to their own stop. A crossed id
|
||||
// means the rider closes the wrong order and the customer is told the wrong
|
||||
// parcel arrived.
|
||||
func TestFanoutTrackingAndConsignmentIdsStayWithTheirStop(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
|
||||
wantTracking := []string{"DMX10482913", "DMX10559120", "DMX10662004"}
|
||||
wantConsignment := []int{901, 902, 903}
|
||||
|
||||
seenTracking := map[string]bool{}
|
||||
seenConsignment := map[int]bool{}
|
||||
|
||||
for i := range stops {
|
||||
if stops[i].trackingNo != wantTracking[i] {
|
||||
t.Errorf("stop %d trackingNo = %q, want %q", i, stops[i].trackingNo, wantTracking[i])
|
||||
}
|
||||
if stops[i].consignmentID == nil {
|
||||
t.Fatalf("stop %d has no consignment id", i)
|
||||
}
|
||||
if *stops[i].consignmentID != wantConsignment[i] {
|
||||
t.Errorf("stop %d consignmentID = %d, want %d", i, *stops[i].consignmentID, wantConsignment[i])
|
||||
}
|
||||
if seenTracking[stops[i].trackingNo] || seenConsignment[*stops[i].consignmentID] {
|
||||
t.Errorf("stop %d repeats an identifier already used by another stop", i)
|
||||
}
|
||||
seenTracking[stops[i].trackingNo] = true
|
||||
seenConsignment[*stops[i].consignmentID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// COD is never copied across destinations. Money is the one field where a leak
|
||||
// is not a display bug: a rider shown 1200 at a door that owes nothing collects
|
||||
// it, and a door owing 450 shown 0 goes uncollected.
|
||||
func TestFanoutCodIsNotCopiedAcrossDestinations(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
|
||||
want := []float64{1200, 0, 450}
|
||||
for i, w := range want {
|
||||
if stops[i].codAmount != w {
|
||||
t.Errorf("stop %d codAmount = %v, want %v", i, stops[i].codAmount, w)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < len(stops); i++ {
|
||||
if stops[i].codAmount == 1200 {
|
||||
t.Errorf("stop %d carries destination 0's COD of 1200", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parcels follow their own destination, so each order settles and is handed
|
||||
// over with the packages that actually belong to it.
|
||||
func TestFanoutParcelsFollowTheirDestination(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
|
||||
want := []int{2, 1, 1}
|
||||
for i, w := range want {
|
||||
if len(stops[i].parcels) != w {
|
||||
t.Errorf("stop %d has %d parcels, want %d", i, len(stops[i].parcels), w)
|
||||
}
|
||||
}
|
||||
seen := map[int]int{}
|
||||
for i, s := range stops {
|
||||
for _, p := range s.parcels {
|
||||
if prev, dup := seen[p.Bookingparcelid]; dup {
|
||||
t.Errorf("parcel %d appears on stops %d and %d", p.Bookingparcelid, prev, i)
|
||||
}
|
||||
seen[p.Bookingparcelid] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Route order follows destinationseq. The stops are emitted in seq order so an
|
||||
// app that renders them as received shows "Stop 1, 2, 3" without sorting.
|
||||
func TestFanoutRouteOrderFollowsDestinationSeq(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
|
||||
// Deliberately handed over out of order — the ordering must come from seq,
|
||||
// not from however the rows happened to arrive.
|
||||
shuffled := []models.BookingDestination{destinations[2], destinations[0], destinations[1]}
|
||||
stops := milerStopsForBooking(&booking, shuffled)
|
||||
|
||||
if len(stops) != 3 {
|
||||
t.Fatalf("got %d stops, want 3", len(stops))
|
||||
}
|
||||
for i := 1; i < len(stops); i++ {
|
||||
if stops[i].seq <= stops[i-1].seq {
|
||||
t.Errorf("stop %d has seq %d, which does not follow stop %d's seq %d",
|
||||
i, stops[i].seq, i-1, stops[i-1].seq)
|
||||
}
|
||||
}
|
||||
// And seq must still name the right destination after ordering.
|
||||
if stops[0].trackingNo != "DMX10482913" || stops[2].trackingNo != "DMX10662004" {
|
||||
t.Errorf("ordering by seq detached a stop from its order: %q ... %q",
|
||||
stops[0].trackingNo, stops[2].trackingNo)
|
||||
}
|
||||
}
|
||||
|
||||
// Every one of the ten row-level fields the rider app reads is present and
|
||||
// destination-specific. This is the field-by-field audit, asserted rather than
|
||||
// described: consignmentid, trackingno, deliveryaddress, deliverylatitude,
|
||||
// deliverylongitude, recipientname, recipientphone, collectionamt (codAmount),
|
||||
// destinationseq (seq) and destinationcount (len).
|
||||
func TestFanoutEveryRowLevelFieldIsDestinationSpecific(t *testing.T) {
|
||||
booking, destinations := cxThreeDestinationFixture()
|
||||
stops := milerStopsForBooking(&booking, destinations)
|
||||
|
||||
if len(stops) != 3 {
|
||||
t.Fatalf("got %d stops, want 3", len(stops))
|
||||
}
|
||||
|
||||
for i, s := range stops {
|
||||
if s.consignmentID == nil {
|
||||
t.Errorf("stop %d: consignmentid missing", i)
|
||||
}
|
||||
if s.trackingNo == "" {
|
||||
t.Errorf("stop %d: trackingno missing", i)
|
||||
}
|
||||
if s.deliveryAddress == "" {
|
||||
t.Errorf("stop %d: deliveryaddress missing", i)
|
||||
}
|
||||
if s.deliveryLat == 0 && s.deliveryLng == 0 {
|
||||
t.Errorf("stop %d: delivery coordinates missing", i)
|
||||
}
|
||||
if s.recipientName == "" {
|
||||
t.Errorf("stop %d: recipientname missing", i)
|
||||
}
|
||||
if s.recipientPhone == "" {
|
||||
t.Errorf("stop %d: recipientphone missing", i)
|
||||
}
|
||||
if s.seq != i {
|
||||
t.Errorf("stop %d: destinationseq = %d, want %d", i, s.seq, i)
|
||||
}
|
||||
}
|
||||
|
||||
// codAmount is exempt from the non-empty check — 0 is a legitimate value
|
||||
// (destination 1 owes nothing) and is asserted exactly in the COD test.
|
||||
}
|
||||
|
||||
// maxDestinations is enforced HERE, not in the app. The Flutter limit is UI
|
||||
// guidance that a modified client, a stale build, or a failed
|
||||
// /config/booking-limits fetch can all bypass; this is the authority.
|
||||
//
|
||||
// The default matters as much as the check: with no configuration row at all
|
||||
// the cap must resolve to the SAFEST value, not the most permissive. A gate
|
||||
// that opens when its config is missing is not a gate — a migration that ran
|
||||
// without the seed would silently admit multi-destination bookings the rider
|
||||
// app cannot complete.
|
||||
func TestMaxDestinationsDefaultsToTheSafestValue(t *testing.T) {
|
||||
if cxDefaultMaxDestinations != 1 {
|
||||
t.Errorf("cxDefaultMaxDestinations = %d, want 1 — a missing config row must not open the gate",
|
||||
cxDefaultMaxDestinations)
|
||||
}
|
||||
if cxDefaultMaxPackages <= 0 {
|
||||
t.Errorf("cxDefaultMaxPackages = %d — a zero cap rejects every booking on the platform",
|
||||
cxDefaultMaxPackages)
|
||||
}
|
||||
}
|
||||
85
controllers/cxDeviceController.go
Normal file
85
controllers/cxDeviceController.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Device registration — §10 of the contract.
|
||||
//
|
||||
// One row per device token rather than one column on the customer. A customer
|
||||
// with a phone and a tablet has to get the delivery notification on both, and
|
||||
// appcustomers.device_token could only ever hold whichever registered last —
|
||||
// so the older device silently stopped receiving anything.
|
||||
|
||||
// RegisterCxDevice records a push token for the signed-in customer.
|
||||
func RegisterCxDevice(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
Platform string `json:"platform"`
|
||||
AppVersion string `json:"appVersion"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
token := strings.TrimSpace(req.Token)
|
||||
if token == "" {
|
||||
return utils.CxBadRequest(c, "A device token is required")
|
||||
}
|
||||
|
||||
platform := strings.ToLower(strings.TrimSpace(req.Platform))
|
||||
if platform != "android" && platform != "ios" {
|
||||
platform = ""
|
||||
}
|
||||
|
||||
device := models.CustomerDevice{
|
||||
Appcustomerid: customerID,
|
||||
Token: token,
|
||||
Platform: platform,
|
||||
Appversion: strings.TrimSpace(req.AppVersion),
|
||||
Lastseenat: utils.DBNow(),
|
||||
}
|
||||
|
||||
// A token can migrate between accounts — a shared handset, or a customer
|
||||
// signing out and a second one signing in. Upserting on the token (rather
|
||||
// than inserting) reassigns it, which is the only outcome that does not
|
||||
// send one person's parcel updates to another person's phone.
|
||||
if err := db.DB.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "token"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"appcustomerid", "platform", "appversion", "lastseenat",
|
||||
}),
|
||||
}).Create(&device).Error; err != nil {
|
||||
utils.Error("RegisterCxDevice: upsert failed", "customer_id", customerID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
return utils.CxOK(c, fiber.Map{"registered": true})
|
||||
}
|
||||
|
||||
// UnregisterCxDevice drops a push token. Called on sign-out, so a signed-out
|
||||
// phone stops receiving updates about a pickup it can no longer open.
|
||||
func UnregisterCxDevice(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
token := strings.TrimSpace(c.Params("token"))
|
||||
if token == "" {
|
||||
return utils.CxBadRequest(c, "A device token is required")
|
||||
}
|
||||
|
||||
if err := db.DB.Where("appcustomerid = ? AND token = ?", customerID, token).
|
||||
Delete(&models.CustomerDevice{}).Error; err != nil && err != gorm.ErrRecordNotFound {
|
||||
utils.Error("UnregisterCxDevice: delete failed", "customer_id", customerID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
return utils.CxOK(c, fiber.Map{"registered": false})
|
||||
}
|
||||
266
controllers/cxFareController.go
Normal file
266
controllers/cxFareController.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// Fare estimate — §7 of the contract.
|
||||
//
|
||||
// The number here is an ESTIMATE RANGE and never a final price. Weight is never
|
||||
// collected from the customer: the miler weighs and photographs each package at
|
||||
// the door, and that is when the price settles. Everything shown before that is
|
||||
// a promise about a band, which is why this returns min/max rather than a
|
||||
// figure.
|
||||
//
|
||||
// Called on every route and package-count change, so it stays cheap: the
|
||||
// pricing slab comes out of the same Redis-warmed cache the public price check
|
||||
// uses, and the distance is straight-line rather than a routing call.
|
||||
|
||||
// cxAssumedKgPerPackage is the weight band the estimate is priced against
|
||||
// before anything has been weighed. It is the ceiling of the standard box the
|
||||
// copy promises ("Standard box (up to 3 kg)"), so the customer is quoted the
|
||||
// top of the band they were shown rather than an optimistic guess that the
|
||||
// settled price then exceeds.
|
||||
const cxAssumedKgPerPackage = 3.0
|
||||
|
||||
// cxMultiStopUpliftPct is charged per additional destination on one pickup.
|
||||
// One visit collecting for three places is one visit, so the uplift is well
|
||||
// under three times the price — but the parcels still travel three separate
|
||||
// journeys after the hub, and pricing them as one would undercharge the part
|
||||
// that actually costs money.
|
||||
const cxMultiStopUpliftPct = 0.35
|
||||
|
||||
// cxFallbackBase / cxFallbackPerKm / cxFallbackPerKg reproduce the estimate the
|
||||
// booking path already falls back to when no pricing rule matches, so an
|
||||
// unpriced lane quotes the same number it charges.
|
||||
const (
|
||||
cxFallbackBase = 50.0
|
||||
cxFallbackPerKm = 5.0
|
||||
cxFallbackPerKg = 10.0
|
||||
)
|
||||
|
||||
type cxEstimateDestination struct {
|
||||
StateCode string `json:"stateCode"`
|
||||
DistrictCode string `json:"districtCode"`
|
||||
PackageCount int `json:"packageCount"`
|
||||
}
|
||||
|
||||
type cxEstimateRequest struct {
|
||||
Pickup struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
} `json:"pickup"`
|
||||
Destinations []cxEstimateDestination `json:"destinations"`
|
||||
}
|
||||
|
||||
// cxQuote is what both the estimate endpoint and the booking create path work
|
||||
// from, so the number quoted on Review is the number stored on the booking.
|
||||
type cxQuote struct {
|
||||
Min int
|
||||
Max int
|
||||
PaymentMethod string
|
||||
Parcel string
|
||||
RouteKM float64
|
||||
}
|
||||
|
||||
// EstimateCxFare prices a whole pickup — one visit, every destination.
|
||||
func EstimateCxFare(c *fiber.Ctx) error {
|
||||
var req cxEstimateRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
if len(req.Destinations) == 0 {
|
||||
return utils.CxBadRequest(c, "Add at least one destination")
|
||||
}
|
||||
|
||||
quote := quoteCxPickup(req.Pickup.Lat, req.Pickup.Lng, req.Destinations)
|
||||
|
||||
// Volatile enough to be worth a short cache, stable enough that the same
|
||||
// route typed twice should not re-price.
|
||||
c.Set("Cache-Control", "max-age=60")
|
||||
|
||||
return utils.CxOK(c, fiber.Map{
|
||||
"min": quote.Min,
|
||||
"max": quote.Max,
|
||||
"paymentMethod": quote.PaymentMethod,
|
||||
"parcel": quote.Parcel,
|
||||
"routeKm": quote.RouteKM,
|
||||
})
|
||||
}
|
||||
|
||||
// quoteCxPickup prices one pickup visit across all of its destinations.
|
||||
//
|
||||
// The farthest destination sets the route distance shown on the outline and the
|
||||
// receipt; every destination contributes its own leg price, and the additional
|
||||
// stops carry an uplift rather than a full second visit.
|
||||
func quoteCxPickup(pickupLat, pickupLng float64, destinations []cxEstimateDestination) cxQuote {
|
||||
districts := loadDistricts(destinations)
|
||||
|
||||
var totalMin, totalMax, maxRouteKM float64
|
||||
totalPackages := 0
|
||||
|
||||
for i, d := range destinations {
|
||||
packages := d.PackageCount
|
||||
if packages < 1 {
|
||||
packages = 1
|
||||
}
|
||||
totalPackages += packages
|
||||
|
||||
district, known := districts[strings.ToUpper(strings.TrimSpace(d.DistrictCode))]
|
||||
|
||||
routeKM := 0.0
|
||||
if known && (district.Centrelatitude != 0 || district.Centrelongitude != 0) &&
|
||||
(pickupLat != 0 || pickupLng != 0) {
|
||||
routeKM = calculateDistance(pickupLat, pickupLng, district.Centrelatitude, district.Centrelongitude)
|
||||
}
|
||||
if routeKM > maxRouteKM {
|
||||
maxRouteKM = routeKM
|
||||
}
|
||||
|
||||
legMin, legMax := priceLeg(pickupLat, pickupLng, district, known, packages, routeKM)
|
||||
|
||||
// The first destination is the visit; every one after it is an extra
|
||||
// leg on the same visit.
|
||||
if i > 0 {
|
||||
legMin *= cxMultiStopUpliftPct + 1
|
||||
legMax *= cxMultiStopUpliftPct + 1
|
||||
}
|
||||
totalMin += legMin
|
||||
totalMax += legMax
|
||||
}
|
||||
|
||||
// Whole rupees, not paise — "min: 49" renders as ₹49. Rounded outward so
|
||||
// the band the customer is shown always contains the price that settles
|
||||
// inside it.
|
||||
minR := int(math.Floor(totalMin))
|
||||
maxR := int(math.Ceil(totalMax))
|
||||
if maxR < minR {
|
||||
maxR = minR
|
||||
}
|
||||
|
||||
return cxQuote{
|
||||
Min: minR,
|
||||
Max: maxR,
|
||||
PaymentMethod: "UPI · Cash at doorstep",
|
||||
Parcel: describeParcels(totalPackages),
|
||||
RouteKM: math.Round(maxRouteKM*10) / 10,
|
||||
}
|
||||
}
|
||||
|
||||
// priceLeg prices one destination's journey. Falls back to the same
|
||||
// distance-and-weight formula the booking path uses when no pricing rule
|
||||
// covers the lane, so an unpriced route still quotes rather than failing —
|
||||
// a failed estimate must never block a booking.
|
||||
func priceLeg(pickupLat, pickupLng float64, district models.ServiceableDistrict, known bool, packages int, routeKM float64) (min, max float64) {
|
||||
weight := float64(packages) * cxAssumedKgPerPackage
|
||||
|
||||
zone := "National"
|
||||
if known && district.Pincodeprefix != "" {
|
||||
// Zone is resolved from postal prefixes, the same rule the rest of the
|
||||
// pricing engine uses, so an estimate and a settlement agree on which
|
||||
// slab applies.
|
||||
zone = resolveZone(pincodeForPoint(pickupLat, pickupLng), district.Pincodeprefix)
|
||||
}
|
||||
|
||||
if rules := matchedPricingRules(zone, "Normal", weight); len(rules) > 0 {
|
||||
lo, hi := rules[0].Minprice, rules[0].Maxprice
|
||||
for _, r := range rules[1:] {
|
||||
if r.Minprice < lo {
|
||||
lo = r.Minprice
|
||||
}
|
||||
if r.Maxprice > hi {
|
||||
hi = r.Maxprice
|
||||
}
|
||||
}
|
||||
return lo, hi
|
||||
}
|
||||
|
||||
base := cxFallbackBase + routeKM*cxFallbackPerKm + weight*cxFallbackPerKg
|
||||
// A ±20% band around the fallback, so the customer still sees a range and
|
||||
// not a false precision the miler's scale is about to contradict.
|
||||
return base * 0.8, base * 1.2
|
||||
}
|
||||
|
||||
// matchedPricingRules returns the active rules covering a weight in a zone,
|
||||
// reusing the Redis-warmed slab the public price check reads.
|
||||
func matchedPricingRules(zone, serviceType string, weight float64) []models.DoormilePricing {
|
||||
rules, err := loadFromPostgres(zone, serviceType)
|
||||
if err != nil || len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
matched := applyFilters(rules, weight, "General")
|
||||
if len(matched) == 0 {
|
||||
matched = applyFilters(rules, weight, "")
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// loadDistricts fetches every district named in one estimate in a single query.
|
||||
func loadDistricts(destinations []cxEstimateDestination) map[string]models.ServiceableDistrict {
|
||||
codes := make([]string, 0, len(destinations))
|
||||
seen := map[string]bool{}
|
||||
for _, d := range destinations {
|
||||
code := strings.ToUpper(strings.TrimSpace(d.DistrictCode))
|
||||
if code != "" && !seen[code] {
|
||||
seen[code] = true
|
||||
codes = append(codes, code)
|
||||
}
|
||||
}
|
||||
out := make(map[string]models.ServiceableDistrict, len(codes))
|
||||
if len(codes) == 0 {
|
||||
return out
|
||||
}
|
||||
var districts []models.ServiceableDistrict
|
||||
if err := db.DB.Where("districtcode IN ?", codes).Find(&districts).Error; err != nil {
|
||||
utils.Warn("loadDistricts: query failed, pricing on the fallback formula", "error", err)
|
||||
return out
|
||||
}
|
||||
for _, d := range districts {
|
||||
out[d.Districtcode] = d
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pincodeForPoint gives the zone resolver something to work with when the
|
||||
// pickup is a map pin rather than a typed address. Empty is a valid answer —
|
||||
// resolveZone treats an unknown prefix as a different state, which prices the
|
||||
// long way round rather than under-quoting.
|
||||
func pincodeForPoint(lat, lng float64) string {
|
||||
if lat == 0 && lng == 0 {
|
||||
return ""
|
||||
}
|
||||
var hubs []models.Hub
|
||||
if err := db.DB.Select("hubid, pincode, latitude, longitude").
|
||||
Where("status = ? AND deletedat IS NULL", "Active").Find(&hubs).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
best := -1.0
|
||||
pincode := ""
|
||||
for i := range hubs {
|
||||
h := hubs[i]
|
||||
if h.Pincode == "" || (h.Latitude == 0 && h.Longitude == 0) {
|
||||
continue
|
||||
}
|
||||
d := calculateDistance(lat, lng, h.Latitude, h.Longitude)
|
||||
if best < 0 || d < best {
|
||||
best, pincode = d, h.Pincode
|
||||
}
|
||||
}
|
||||
return pincode
|
||||
}
|
||||
|
||||
// describeParcels is the "what is being priced" line on Review and the receipt.
|
||||
func describeParcels(packages int) string {
|
||||
if packages <= 1 {
|
||||
return fmt.Sprintf("Standard box (up to %.0f kg)", cxAssumedKgPerPackage)
|
||||
}
|
||||
return fmt.Sprintf("%d boxes (up to %.0f kg each)", packages, cxAssumedKgPerPackage)
|
||||
}
|
||||
713
controllers/cxHttp_test.go
Normal file
713
controllers/cxHttp_test.go
Normal file
@@ -0,0 +1,713 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// HTTP-level contract tests for the customer surface.
|
||||
//
|
||||
// These exercise the real handlers through a real Fiber router and assert the
|
||||
// STATUS CODE and the ENVELOPE the client will actually receive. They cover
|
||||
// every path that can be reached without a database — which is every validation
|
||||
// and gate in the surface, and is precisely where a wrong status code would
|
||||
// reach production unnoticed.
|
||||
//
|
||||
// What they deliberately do NOT cover: the happy paths, which need Postgres,
|
||||
// Redis and NATS. A 200 from CreateCxBooking cannot be asserted here, and
|
||||
// pretending otherwise with a mock would test the mock. Those need the
|
||||
// integration pass against staging (see docs/customer-app-api.md §7).
|
||||
//
|
||||
// The rule every test below enforces: a validation failure must be a 4xx with a
|
||||
// machine-readable error.code and customer-safe English. It must NEVER be a 500
|
||||
// ("Something went wrong" on a request the server understood perfectly well)
|
||||
// and never a bare 404 from the router (which would mean the route is missing).
|
||||
|
||||
// cxFutureSlotID is a slot id whose date cannot go stale. Used by the cases
|
||||
// where the SLOT is not what is under test — a dated id like slot_20260905_t1
|
||||
// silently becomes an expired-slot test the day after it was written, and
|
||||
// would then assert the wrong failure.
|
||||
const cxFutureSlotID = "slot_20991231_t1"
|
||||
|
||||
// cxTestApp builds a router with the customer routes mounted and a stub auth
|
||||
// middleware, so handler behaviour is tested rather than JWT parsing.
|
||||
func cxTestApp(t *testing.T, authenticated bool) *fiber.App {
|
||||
t.Helper()
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
// Without this a panic becomes a dropped connection instead of a 500,
|
||||
// and a test would report a confusing transport error rather than the
|
||||
// real fault.
|
||||
DisableStartupMessage: true,
|
||||
})
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
if authenticated {
|
||||
c.Locals("userid", 4242)
|
||||
c.Locals("roleid", 9)
|
||||
c.Locals("tenantid", 0)
|
||||
}
|
||||
return c.Next()
|
||||
})
|
||||
|
||||
cfg := &config.Config{JWTSecret: "test-secret"}
|
||||
|
||||
customer := app.Group("/customer")
|
||||
customer.Post("/auth/otp/request", CxRequestOtp(cfg))
|
||||
customer.Post("/auth/signup", CxSignup(cfg))
|
||||
customer.Post("/auth/otp/verify", CxVerifyOtp(cfg))
|
||||
customer.Post("/auth/refresh", CxRefresh(cfg))
|
||||
customer.Post("/fare/estimate", EstimateCxFare)
|
||||
customer.Post("/bookings", CreateCxBooking)
|
||||
customer.Patch("/bookings/:reference/destinations/:index", PatchCxDestination)
|
||||
customer.Get("/orders/:trackingId", GetCxOrder)
|
||||
customer.Post("/devices", RegisterCxDevice)
|
||||
customer.Get("/places/reverse-geocode", ReverseGeocodeCx(cfg))
|
||||
customer.Post("/ops/bookings/:reference/stage", ForceCxStage)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
type cxResponse struct {
|
||||
status int
|
||||
body map[string]interface{}
|
||||
raw string
|
||||
}
|
||||
|
||||
func cxDo(t *testing.T, app *fiber.App, method, path string, body interface{}) cxResponse {
|
||||
t.Helper()
|
||||
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("could not encode request body: %v", err)
|
||||
}
|
||||
reader = bytes.NewReader(encoded)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := app.Test(req, 5000)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: transport error: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
out := cxResponse{status: resp.StatusCode, raw: string(raw)}
|
||||
_ = json.Unmarshal(raw, &out.body)
|
||||
return out
|
||||
}
|
||||
|
||||
// assertCxError checks the full error contract in one place: the status code,
|
||||
// the envelope shape, the machine code, and that the message is fit to show a
|
||||
// customer.
|
||||
func assertCxError(t *testing.T, got cxResponse, wantStatus int, wantCode string) {
|
||||
t.Helper()
|
||||
|
||||
if got.status != wantStatus {
|
||||
t.Fatalf("status = %d, want %d (body: %s)", got.status, wantStatus, got.raw)
|
||||
}
|
||||
if success, _ := got.body["success"].(bool); success {
|
||||
t.Errorf("success = true on an error response (body: %s)", got.raw)
|
||||
}
|
||||
|
||||
errObj, ok := got.body["error"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("no error object in the envelope — the client reads error.code (body: %s)", got.raw)
|
||||
}
|
||||
if code, _ := errObj["code"].(string); code != wantCode {
|
||||
t.Errorf("error.code = %q, want %q", errObj["code"], wantCode)
|
||||
}
|
||||
|
||||
message, _ := got.body["message"].(string)
|
||||
if message == "" {
|
||||
t.Error("message is empty — the app renders it verbatim in its one error state")
|
||||
}
|
||||
assertCustomerSafe(t, message)
|
||||
}
|
||||
|
||||
// assertCustomerSafe rejects anything that reads like an internal artefact
|
||||
// rather than something a customer should be shown. The contract is explicit
|
||||
// that `message` is displayed verbatim and must never be an enum key, a stack
|
||||
// trace or a driver error.
|
||||
func assertCustomerSafe(t *testing.T, message string) {
|
||||
t.Helper()
|
||||
|
||||
leaks := []string{
|
||||
"gorm", "sql:", "pq:", "panic", "nil pointer", "goroutine",
|
||||
"doormile/", ".go:", "SELECT ", "INSERT ", "record not found",
|
||||
}
|
||||
for _, leak := range leaks {
|
||||
if bytes.Contains([]byte(message), []byte(leak)) {
|
||||
t.Errorf("message %q leaks an internal detail (%q) to the customer", message, leak)
|
||||
}
|
||||
}
|
||||
// An enum key rather than a sentence — "INVALID_INPUT", "not_found".
|
||||
if message == "" {
|
||||
return
|
||||
}
|
||||
upperOnly := true
|
||||
for _, r := range message {
|
||||
if r >= 'a' && r <= 'z' {
|
||||
upperOnly = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if upperOnly {
|
||||
t.Errorf("message %q looks like an enum key, not customer-safe English", message)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth (§4) ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCxAuthValidationStatusCodes(t *testing.T) {
|
||||
app := cxTestApp(t, false)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
name: "otp request with no identifier",
|
||||
method: http.MethodPost, path: "/customer/auth/otp/request",
|
||||
body: map[string]interface{}{"identifier": ""},
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
{
|
||||
name: "otp request with a malformed phone",
|
||||
method: http.MethodPost, path: "/customer/auth/otp/request",
|
||||
body: map[string]interface{}{"identifier": "12345"},
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
{
|
||||
name: "otp request with a malformed email",
|
||||
method: http.MethodPost, path: "/customer/auth/otp/request",
|
||||
body: map[string]interface{}{"identifier": "joe@example"},
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
{
|
||||
// The contract pins this to its own code so the app can highlight
|
||||
// the name field rather than showing a generic error.
|
||||
name: "signup with a one-character name",
|
||||
method: http.MethodPost, path: "/customer/auth/signup",
|
||||
body: map[string]interface{}{"name": "J", "phone": "+919876543210"},
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalidName,
|
||||
},
|
||||
{
|
||||
name: "signup with no name at all",
|
||||
method: http.MethodPost, path: "/customer/auth/signup",
|
||||
body: map[string]interface{}{"phone": "+919876543210"},
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalidName,
|
||||
},
|
||||
{
|
||||
name: "signup with a valid name but an unusable phone",
|
||||
method: http.MethodPost, path: "/customer/auth/signup",
|
||||
body: map[string]interface{}{"name": "Joe Oommen", "phone": "123"},
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
{
|
||||
name: "verify with no code",
|
||||
method: http.MethodPost, path: "/customer/auth/otp/verify",
|
||||
body: map[string]interface{}{"identifier": "+919876543210", "code": ""},
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
{
|
||||
name: "verify with an unusable identifier",
|
||||
method: http.MethodPost, path: "/customer/auth/otp/verify",
|
||||
body: map[string]interface{}{"identifier": "nope", "code": "4821"},
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
{
|
||||
// 401 rather than 400: the client's recovery is "sign in again",
|
||||
// which it branches on the status for.
|
||||
name: "refresh with no token",
|
||||
method: http.MethodPost, path: "/customer/auth/refresh",
|
||||
body: map[string]interface{}{"refreshToken": ""},
|
||||
wantStatus: fiber.StatusUnauthorized, wantCode: utils.CxErrUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "refresh with a whitespace token",
|
||||
method: http.MethodPost, path: "/customer/auth/refresh",
|
||||
body: map[string]interface{}{"refreshToken": " "},
|
||||
wantStatus: fiber.StatusUnauthorized, wantCode: utils.CxErrUnauthorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := cxDo(t, app, tc.method, tc.path, tc.body)
|
||||
// Logged so `go test -v` shows the exact bytes the app receives,
|
||||
// not just a pass mark. These tests are about the wire contract, so
|
||||
// the wire response is the evidence.
|
||||
t.Logf("%s %s -> %d %s", tc.method, tc.path, got.status, got.raw)
|
||||
assertCxError(t, got, tc.wantStatus, tc.wantCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bookings, estimate, devices, places ──────────────────────────────────────
|
||||
|
||||
func TestCxRequestValidationStatusCodes(t *testing.T) {
|
||||
app := cxTestApp(t, true)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
name: "booking with no destinations",
|
||||
method: http.MethodPost, path: "/customer/bookings",
|
||||
body: map[string]interface{}{"slotId": cxFutureSlotID},
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
{
|
||||
name: "booking with an empty destinations array",
|
||||
method: http.MethodPost, path: "/customer/bookings",
|
||||
body: map[string]interface{}{
|
||||
"slotId": cxFutureSlotID,
|
||||
"destinations": []interface{}{},
|
||||
},
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
{
|
||||
name: "booking with destinations but no slot",
|
||||
method: http.MethodPost, path: "/customer/bookings",
|
||||
body: map[string]interface{}{
|
||||
"destinations": []map[string]interface{}{
|
||||
{"stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1},
|
||||
},
|
||||
},
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
{
|
||||
name: "estimate with no destinations",
|
||||
method: http.MethodPost, path: "/customer/fare/estimate",
|
||||
body: map[string]interface{}{"pickup": map[string]float64{"lat": 11.0, "lng": 76.9}},
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
{
|
||||
name: "device registration with no token",
|
||||
method: http.MethodPost, path: "/customer/devices",
|
||||
body: map[string]interface{}{"platform": "android"},
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
{
|
||||
name: "device registration with a whitespace token",
|
||||
method: http.MethodPost, path: "/customer/devices",
|
||||
body: map[string]interface{}{"token": " ", "platform": "ios"},
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
{
|
||||
name: "destination patch with a non-numeric index",
|
||||
method: http.MethodPatch, path: "/customer/bookings/DM-482913/destinations/abc",
|
||||
body: map[string]interface{}{"street": "12th Main"},
|
||||
wantStatus: fiber.StatusNotFound, wantCode: utils.CxErrNotFound,
|
||||
},
|
||||
{
|
||||
name: "destination patch with a negative index",
|
||||
method: http.MethodPatch, path: "/customer/bookings/DM-482913/destinations/-1",
|
||||
body: map[string]interface{}{"street": "12th Main"},
|
||||
wantStatus: fiber.StatusNotFound, wantCode: utils.CxErrNotFound,
|
||||
},
|
||||
{
|
||||
name: "reverse geocode with no coordinates",
|
||||
method: http.MethodGet, path: "/customer/places/reverse-geocode",
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
{
|
||||
name: "reverse geocode with unparseable coordinates",
|
||||
method: http.MethodGet, path: "/customer/places/reverse-geocode?lat=abc&lng=def",
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
{
|
||||
name: "reverse geocode at the null island",
|
||||
method: http.MethodGet, path: "/customer/places/reverse-geocode?lat=0&lng=0",
|
||||
wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := cxDo(t, app, tc.method, tc.path, tc.body)
|
||||
t.Logf("%s %s -> %d %s", tc.method, tc.path, got.status, got.raw)
|
||||
assertCxError(t, got, tc.wantStatus, tc.wantCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A body the parser cannot read is the customer's problem to fix, not a server
|
||||
// fault. Answering 500 here would put a "Something went wrong" retry loop in
|
||||
// front of a request that will never succeed.
|
||||
func TestCxMalformedJsonIsFourHundredNotFiveHundred(t *testing.T) {
|
||||
app := cxTestApp(t, true)
|
||||
|
||||
for _, path := range []string{
|
||||
"/customer/bookings",
|
||||
"/customer/fare/estimate",
|
||||
"/customer/devices",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte("{not json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req, 5000)
|
||||
if err != nil {
|
||||
t.Fatalf("transport error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d on malformed JSON, want 4xx (body: %s)", resp.StatusCode, raw)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── The QA stage override (§11) ──────────────────────────────────────────────
|
||||
|
||||
// The override is double-gated. Both switches off must be indistinguishable
|
||||
// from the route not existing — advertising a disabled admin capability tells
|
||||
// an attacker exactly what to go looking for.
|
||||
func TestForceStageIsInvisibleUnlessBothGatesAreOpen(t *testing.T) {
|
||||
app := cxTestApp(t, true)
|
||||
|
||||
restore := func(key, value string) func() {
|
||||
previous, had := os.LookupEnv(key)
|
||||
_ = os.Setenv(key, value)
|
||||
return func() {
|
||||
if had {
|
||||
_ = os.Setenv(key, previous)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
env string
|
||||
override string
|
||||
}{
|
||||
{"both gates closed", "development", ""},
|
||||
{"override off in development", "development", "false"},
|
||||
{"override on but production", "production", "true"},
|
||||
{"override on but PRODUCTION in caps", "PRODUCTION", "true"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer restore("ENV", tc.env)()
|
||||
defer restore("CX_ALLOW_STAGE_OVERRIDE", tc.override)()
|
||||
|
||||
got := cxDo(t, app, http.MethodPost,
|
||||
"/customer/ops/bookings/DM-482913/stage",
|
||||
map[string]interface{}{"stage": "delivered"})
|
||||
|
||||
if got.status != fiber.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 — a disabled override must not announce itself (body: %s)",
|
||||
got.status, got.raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// With both gates open the route is reachable, and an unknown stage is a
|
||||
// validation failure rather than a server fault.
|
||||
func TestForceStageRejectsAnUnknownStage(t *testing.T) {
|
||||
app := cxTestApp(t, true)
|
||||
|
||||
previousEnv, hadEnv := os.LookupEnv("ENV")
|
||||
previousOverride, hadOverride := os.LookupEnv("CX_ALLOW_STAGE_OVERRIDE")
|
||||
_ = os.Setenv("ENV", "development")
|
||||
_ = os.Setenv("CX_ALLOW_STAGE_OVERRIDE", "true")
|
||||
defer func() {
|
||||
if hadEnv {
|
||||
_ = os.Setenv("ENV", previousEnv)
|
||||
} else {
|
||||
_ = os.Unsetenv("ENV")
|
||||
}
|
||||
if hadOverride {
|
||||
_ = os.Setenv("CX_ALLOW_STAGE_OVERRIDE", previousOverride)
|
||||
} else {
|
||||
_ = os.Unsetenv("CX_ALLOW_STAGE_OVERRIDE")
|
||||
}
|
||||
}()
|
||||
|
||||
got := cxDo(t, app, http.MethodPost,
|
||||
"/customer/ops/bookings/DM-482913/stage",
|
||||
map[string]interface{}{"stage": "teleported"})
|
||||
|
||||
assertCxError(t, got, fiber.StatusBadRequest, utils.CxErrInvalid)
|
||||
}
|
||||
|
||||
// ── Envelope shape (§3.1, §3.4) ──────────────────────────────────────────────
|
||||
|
||||
// Every success response carries `message` as a present-but-empty string. The
|
||||
// contract states it explicitly, and a client that reads message.length on a
|
||||
// missing key throws.
|
||||
func TestSuccessEnvelopeAlwaysCarriesAnEmptyMessage(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Get("/ok", func(c *fiber.Ctx) error {
|
||||
return utils.CxOK(c, fiber.Map{"value": 1})
|
||||
})
|
||||
app.Get("/created", func(c *fiber.Ctx) error {
|
||||
return utils.CxCreated(c, fiber.Map{"value": 1})
|
||||
})
|
||||
app.Get("/list", func(c *fiber.Ctx) error {
|
||||
return utils.CxList(c, []int{1, 2}, 2, nil)
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
wantStatus int
|
||||
}{
|
||||
{"/ok", fiber.StatusOK},
|
||||
{"/created", fiber.StatusCreated},
|
||||
{"/list", fiber.StatusOK},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
got := cxDo(t, app, http.MethodGet, tc.path, nil)
|
||||
if got.status != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", got.status, tc.wantStatus)
|
||||
}
|
||||
if success, _ := got.body["success"].(bool); !success {
|
||||
t.Error("success != true on a success response")
|
||||
}
|
||||
if _, present := got.body["message"]; !present {
|
||||
t.Error("message key missing — the contract says always present, empty on success")
|
||||
}
|
||||
if message, _ := got.body["message"].(string); message != "" {
|
||||
t.Errorf("message = %q on a success response, want empty", message)
|
||||
}
|
||||
if _, present := got.body["data"]; !present {
|
||||
t.Error("data key missing — every payload lives in data, auth included")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A list envelope always carries an ARRAY and an explicit nextCursor, even when
|
||||
// empty. The client types data as a list and nextCursor as nullable; a missing
|
||||
// key or a null data throws in its parser.
|
||||
func TestListEnvelopeIsAlwaysAnArrayWithACursorKey(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Get("/empty", func(c *fiber.Ctx) error {
|
||||
return utils.CxList(c, []string{}, 0, nil)
|
||||
})
|
||||
cursor := "1042"
|
||||
app.Get("/paged", func(c *fiber.Ctx) error {
|
||||
return utils.CxList(c, []string{"a"}, 9, &cursor)
|
||||
})
|
||||
|
||||
empty := cxDo(t, app, http.MethodGet, "/empty", nil)
|
||||
if empty.status != fiber.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", empty.status)
|
||||
}
|
||||
if _, ok := empty.body["data"].([]interface{}); !ok {
|
||||
t.Errorf("data is not an array on an empty list (body: %s)", empty.raw)
|
||||
}
|
||||
if _, present := empty.body["nextCursor"]; !present {
|
||||
t.Error("nextCursor key missing — it must be present and null on the last page")
|
||||
}
|
||||
if empty.body["nextCursor"] != nil {
|
||||
t.Errorf("nextCursor = %v on the last page, want null", empty.body["nextCursor"])
|
||||
}
|
||||
if total, _ := empty.body["total"].(float64); total != 0 {
|
||||
t.Errorf("total = %v, want 0", empty.body["total"])
|
||||
}
|
||||
|
||||
paged := cxDo(t, app, http.MethodGet, "/paged", nil)
|
||||
if got, _ := paged.body["nextCursor"].(string); got != cursor {
|
||||
t.Errorf("nextCursor = %v, want %q", paged.body["nextCursor"], cursor)
|
||||
}
|
||||
if total, _ := paged.body["total"].(float64); total != 9 {
|
||||
t.Errorf("total = %v, want 9 (the size of the filtered set, not the page)", paged.body["total"])
|
||||
}
|
||||
}
|
||||
|
||||
// Every code in the contract maps to the status the client branches on, and
|
||||
// none of them produce a 5xx.
|
||||
func TestErrorEnvelopeStatusCodeMapping(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
code string
|
||||
message string
|
||||
}{
|
||||
{"invalid", fiber.StatusBadRequest, utils.CxErrInvalid, "Every destination needs a serviceable state and district"},
|
||||
{"invalid_name", fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name"},
|
||||
{"invalid_otp", fiber.StatusUnauthorized, utils.CxErrInvalidOtp, "That code did not match"},
|
||||
{"unauthorized", fiber.StatusUnauthorized, utils.CxErrUnauthorized, "Please sign in again"},
|
||||
{"forbidden", fiber.StatusForbidden, utils.CxErrForbidden, "You do not have access to this"},
|
||||
{"not_found", fiber.StatusNotFound, utils.CxErrNotFound, "We could not find that pickup"},
|
||||
{"conflict", fiber.StatusConflict, utils.CxErrConflict, "This pickup can no longer be cancelled"},
|
||||
{"unserviceable", fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable, "That district is no longer available"},
|
||||
{"rate_limited", fiber.StatusTooManyRequests, utils.CxErrRateLimited, "Too many attempts. Try again in a minute"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
app.Get("/"+tc.name, func(c *fiber.Ctx) error {
|
||||
return utils.CxFail(c, tc.status, tc.code, tc.message)
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := cxDo(t, app, http.MethodGet, "/"+tc.name, nil)
|
||||
assertCxError(t, got, tc.status, tc.code)
|
||||
if got.status >= 500 {
|
||||
t.Errorf("a documented client error answered %d", got.status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CxInternal is the only 5xx the surface produces, and it must never carry the
|
||||
// underlying error outward.
|
||||
func TestInternalErrorNeverLeaksTheCause(t *testing.T) {
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Get("/boom", func(c *fiber.Ctx) error {
|
||||
return utils.CxInternal(c)
|
||||
})
|
||||
|
||||
got := cxDo(t, app, http.MethodGet, "/boom", nil)
|
||||
if got.status != fiber.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", got.status)
|
||||
}
|
||||
if message, _ := got.body["message"].(string); message != "Something went wrong" {
|
||||
t.Errorf("message = %q, want the fixed customer-safe string", message)
|
||||
}
|
||||
assertCustomerSafe(t, got.body["message"].(string))
|
||||
|
||||
errObj, ok := got.body["error"].(map[string]interface{})
|
||||
if !ok || errObj["code"] != utils.CxErrServer {
|
||||
t.Errorf("error.code = %v, want %q", got.body["error"], utils.CxErrServer)
|
||||
}
|
||||
}
|
||||
|
||||
// An expired slot and a full slot are different failures. A client that cached
|
||||
// the slot list and was left open across midnight sends yesterday's window in
|
||||
// good faith; telling that customer the window "just filled up" is untrue and
|
||||
// points them at the wrong recovery.
|
||||
func TestExpiredSlotIsNotReportedAsFull(t *testing.T) {
|
||||
app := cxTestApp(t, true)
|
||||
|
||||
// A slot id from a date that has certainly passed. It is rejected before
|
||||
// any database access, because the id carries its own date.
|
||||
got := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{
|
||||
"pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558},
|
||||
"slotId": "slot_20200101_t1",
|
||||
"destinations": []map[string]interface{}{
|
||||
{"stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1},
|
||||
},
|
||||
})
|
||||
|
||||
if got.status == fiber.StatusConflict {
|
||||
t.Fatalf("an expired slot answered 409 — that is the capacity race, not a stale id (body: %s)", got.raw)
|
||||
}
|
||||
if got.status >= 500 {
|
||||
t.Fatalf("status = %d, want 4xx (body: %s)", got.status, got.raw)
|
||||
}
|
||||
if message, _ := got.body["message"].(string); strings.Contains(strings.ToLower(message), "filled up") {
|
||||
t.Errorf("message = %q — an expired slot is not a full one", message)
|
||||
}
|
||||
}
|
||||
|
||||
// The server refuses an over-cap booking itself. The client's own limit is UI
|
||||
// guidance — a modified build, or one whose /config/booking-limits fetch
|
||||
// failed, still cannot create a booking the fleet cannot service.
|
||||
func TestMaxDestinationsIsEnforcedServerSide(t *testing.T) {
|
||||
app := cxTestApp(t, true)
|
||||
|
||||
// Above the absolute ceiling, so the refusal lands before any database
|
||||
// work and is assertable here. The configured per-city cap is the real
|
||||
// policy and is exercised separately in cxCustomerApp_test.go.
|
||||
destinations := make([]map[string]interface{}, 0, cxAbsoluteMaxDestinations+1)
|
||||
for i := 0; i <= cxAbsoluteMaxDestinations; i++ {
|
||||
destinations = append(destinations, map[string]interface{}{
|
||||
"stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1,
|
||||
})
|
||||
}
|
||||
|
||||
got := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{
|
||||
"pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558},
|
||||
"slotId": "slot_20991231_t1",
|
||||
"destinations": destinations,
|
||||
})
|
||||
|
||||
if got.status >= 500 {
|
||||
t.Fatalf("status = %d, want a 4xx refusal (body: %s)", got.status, got.raw)
|
||||
}
|
||||
if got.status < 400 {
|
||||
t.Fatalf("status = %d — an over-cap booking was accepted (body: %s)", got.status, got.raw)
|
||||
}
|
||||
}
|
||||
|
||||
// "No destinations at all" and "a destination is missing its state or district"
|
||||
// are different problems with different fixes. They shared one message, which
|
||||
// told a customer who had added nothing to go and correct the state on
|
||||
// destinations they did not have. The messages must stay distinct AND the empty
|
||||
// case must match what the estimate endpoint says for the same mistake.
|
||||
func TestEmptyDestinationsSaysAddOneNotFixTheirDetails(t *testing.T) {
|
||||
app := cxTestApp(t, true)
|
||||
|
||||
booking := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{
|
||||
"pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558},
|
||||
"slotId": cxFutureSlotID,
|
||||
"destinations": []interface{}{},
|
||||
})
|
||||
estimate := cxDo(t, app, http.MethodPost, "/customer/fare/estimate", map[string]interface{}{
|
||||
"pickup": map[string]interface{}{"lat": 11.0168, "lng": 76.9558},
|
||||
"destinations": []interface{}{},
|
||||
})
|
||||
|
||||
t.Logf("booking -> %d %s", booking.status, booking.raw)
|
||||
t.Logf("estimate -> %d %s", estimate.status, estimate.raw)
|
||||
|
||||
bookingMsg, _ := booking.body["message"].(string)
|
||||
estimateMsg, _ := estimate.body["message"].(string)
|
||||
|
||||
if strings.Contains(bookingMsg, "serviceable state and district") {
|
||||
t.Errorf("empty destinations answered %q — that describes a problem the customer does not have; "+
|
||||
"they added nothing, so they need to add one", bookingMsg)
|
||||
}
|
||||
if bookingMsg != estimateMsg {
|
||||
t.Errorf("the same mistake is described two ways: booking says %q, estimate says %q",
|
||||
bookingMsg, estimateMsg)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(bookingMsg), "at least one destination") {
|
||||
t.Errorf("message = %q, want it to name the actual fix (add a destination)", bookingMsg)
|
||||
}
|
||||
}
|
||||
181
controllers/cxIdentifierScramble.go
Normal file
181
controllers/cxIdentifierScramble.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"doormile/utils"
|
||||
)
|
||||
|
||||
// Format-preserving scrambling for the customer-facing identifiers.
|
||||
//
|
||||
// THE PROBLEM. Both identifiers come off a Postgres sequence, because a
|
||||
// sequence is the only generator here that can promise uniqueness — the columns
|
||||
// are UNIQUE, and a random 8-digit id collides with ~43% probability by the
|
||||
// ten-thousandth parcel, which would be a rider unable to complete a pickup.
|
||||
// But a sequence is also readable: DMX10000042 and DMX10000043 are visibly
|
||||
// adjacent, so anyone holding two numbers learns the throughput between them,
|
||||
// and anyone holding one can guess its neighbours.
|
||||
//
|
||||
// THE FIX. Keep the sequence — keep its uniqueness guarantee — and pass the
|
||||
// index through a bijection before formatting it. Same one-to-one property, so
|
||||
// no two parcels can ever collide; unrelated outputs, so nothing is guessable
|
||||
// from a neighbour.
|
||||
//
|
||||
// WHY NOT THE OBVIOUS TRICK. Multiplying by a number coprime with the domain is
|
||||
// also a bijection and is one line. It is wrong here: a multi-destination
|
||||
// pickup hands ONE customer three consecutive sequence values, so the
|
||||
// differences between their three tracking numbers are all exactly the
|
||||
// multiplier. One booking leaks the key, and the whole range becomes walkable.
|
||||
// The mapping has to be non-linear.
|
||||
//
|
||||
// WHAT THIS IS. A 4-round balanced Feistel network keyed with HMAC-SHA256, plus
|
||||
// cycle-walking to keep the result inside the digit range. A Feistel is a
|
||||
// bijection for ANY round function — that is its defining property — so
|
||||
// uniqueness survives regardless of the key. Cycle-walking (re-encrypt until
|
||||
// the output lands in range) preserves bijectivity on the subset.
|
||||
//
|
||||
// WHAT THIS IS NOT. Not a security boundary. Every route that resolves a
|
||||
// tracking number is already authenticated and owner-scoped, and that is what
|
||||
// actually stops a stranger reading someone's parcel. This removes the
|
||||
// information leak in the identifier itself, so the authorisation check is not
|
||||
// the only thing standing between an outsider and your volume figures.
|
||||
|
||||
const (
|
||||
// Tracking numbers occupy DMX10000000..DMX99999999 — 90,000,000 values,
|
||||
// always eight digits so the format never changes width.
|
||||
cxTrackingBase = 10_000_000
|
||||
cxTrackingDomain = 90_000_000
|
||||
// 28 bits (two 14-bit halves) is the smallest even split covering the
|
||||
// domain. Cycle-walking averages ~3 encryptions per id; each is four
|
||||
// HMACs, so this is microseconds.
|
||||
cxTrackingHalfBits = 14
|
||||
|
||||
// Booking references occupy DM-100000..DM-999999 — 900,000 values, always
|
||||
// six digits.
|
||||
cxBookingBase = 100_000
|
||||
cxBookingDomain = 900_000
|
||||
// 20 bits (two 10-bit halves). Cycle-walking averages ~1.2 encryptions.
|
||||
cxBookingHalfBits = 10
|
||||
|
||||
// cxFeistelRounds. Four is the standard minimum for a Feistel to be a
|
||||
// strong pseudorandom permutation (Luby–Rackoff). More rounds cost HMACs
|
||||
// for no property this needs.
|
||||
cxFeistelRounds = 4
|
||||
|
||||
// cxCycleWalkLimit bounds the walk so a pathological key can never hang a
|
||||
// request. Reaching it is astronomically unlikely — each step has a ~2/3
|
||||
// chance of landing in range for tracking numbers — and the caller falls
|
||||
// back to the plain sequence rather than failing a booking.
|
||||
cxCycleWalkLimit = 64
|
||||
)
|
||||
|
||||
// cxScrambleKey keys the round function.
|
||||
//
|
||||
// Overridable via CX_ID_SCRAMBLE_KEY. Changing it changes every identifier
|
||||
// minted AFTERWARDS and none already stored, so rotation is safe but leaves a
|
||||
// visible discontinuity — there is no reason to rotate it, and a good reason
|
||||
// not to.
|
||||
//
|
||||
// The built-in default is not a secret and is not pretending to be one. It
|
||||
// exists so the scrambling works out of the box rather than being silently off
|
||||
// on any deployment that forgot to set an env var — an identifier scheme that
|
||||
// depends on configuration to be safe is one that will be unsafe somewhere.
|
||||
var cxScrambleKey = func() []byte {
|
||||
if k := strings.TrimSpace(os.Getenv("CX_ID_SCRAMBLE_KEY")); k != "" {
|
||||
return []byte(k)
|
||||
}
|
||||
return []byte("doormile-cx-identifier-permutation-v1")
|
||||
}()
|
||||
|
||||
// cxFeistelRound is the round function. It need not be invertible — a Feistel
|
||||
// is a bijection whatever this returns — so any keyed mixing works, and HMAC
|
||||
// gives good diffusion for four bytes of output.
|
||||
func cxFeistelRound(half uint64, round int, key []byte) uint64 {
|
||||
var buf [9]byte
|
||||
binary.BigEndian.PutUint64(buf[:8], half)
|
||||
buf[8] = byte(round)
|
||||
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write(buf[:])
|
||||
sum := mac.Sum(nil)
|
||||
|
||||
return uint64(binary.BigEndian.Uint32(sum[:4]))
|
||||
}
|
||||
|
||||
// cxFeistelEncrypt permutes a value within 2^(2*halfBits).
|
||||
//
|
||||
// Bijective by construction: every round is invertible because the half that is
|
||||
// mixed is carried forward untouched, so the whole network can be run
|
||||
// backwards. That is the property the UNIQUE constraint depends on.
|
||||
func cxFeistelEncrypt(x uint64, halfBits uint, key []byte) uint64 {
|
||||
mask := uint64(1)<<halfBits - 1
|
||||
left := (x >> halfBits) & mask
|
||||
right := x & mask
|
||||
|
||||
for round := 0; round < cxFeistelRounds; round++ {
|
||||
left, right = right, left^(cxFeistelRound(right, round, key)&mask)
|
||||
}
|
||||
return (left << halfBits) | right
|
||||
}
|
||||
|
||||
// cxPermuteIndex maps a sequence index onto a scattered index in the same
|
||||
// domain, one-to-one.
|
||||
//
|
||||
// Cycle-walking: the Feistel operates on the whole power-of-two space, which is
|
||||
// larger than the digit range, so an output that overshoots is re-encrypted
|
||||
// until it lands inside. Re-encrypting a bijection is still a bijection on the
|
||||
// subset, so no two indices can ever converge.
|
||||
func cxPermuteIndex(index, domain uint64, halfBits uint) uint64 {
|
||||
if index >= domain {
|
||||
// Past the end of the fixed-width range. The caller handles this;
|
||||
// returning the index unchanged keeps the function total.
|
||||
return index
|
||||
}
|
||||
|
||||
x := index
|
||||
for i := 0; i < cxCycleWalkLimit; i++ {
|
||||
x = cxFeistelEncrypt(x, halfBits, cxScrambleKey)
|
||||
if x < domain {
|
||||
return x
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable in practice. Falling back to the sequential index keeps the
|
||||
// identifier unique — which is the property that must never break — and
|
||||
// loses only the scattering.
|
||||
utils.Warn("cxPermuteIndex: cycle walk did not converge, using the sequential index",
|
||||
"index", index, "domain", domain)
|
||||
return index
|
||||
}
|
||||
|
||||
// cxScrambledTracking turns a sequence value into the eight digits after DMX.
|
||||
//
|
||||
// Returns ok=false once the sequence runs past the fixed-width range, so the
|
||||
// caller can fall back to plain sequential formatting and let the identifier
|
||||
// grow a digit rather than wrapping onto one already issued.
|
||||
func cxScrambledTracking(seq int64) (uint64, bool) {
|
||||
if seq < cxTrackingBase {
|
||||
return 0, false
|
||||
}
|
||||
index := uint64(seq - cxTrackingBase)
|
||||
if index >= cxTrackingDomain {
|
||||
return 0, false
|
||||
}
|
||||
return cxTrackingBase + cxPermuteIndex(index, cxTrackingDomain, cxTrackingHalfBits), true
|
||||
}
|
||||
|
||||
// cxScrambledBooking is the same for the six digits after DM-.
|
||||
func cxScrambledBooking(seq int64) (uint64, bool) {
|
||||
if seq < cxBookingBase {
|
||||
return 0, false
|
||||
}
|
||||
index := uint64(seq - cxBookingBase)
|
||||
if index >= cxBookingDomain {
|
||||
return 0, false
|
||||
}
|
||||
return cxBookingBase + cxPermuteIndex(index, cxBookingDomain, cxBookingHalfBits), true
|
||||
}
|
||||
281
controllers/cxIdentifierScramble_test.go
Normal file
281
controllers/cxIdentifierScramble_test.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The scrambling exists to remove an information leak, but the property that
|
||||
// MUST survive it is uniqueness: trackingno and bookingno are UNIQUE columns,
|
||||
// and a collision is a rider standing at a door unable to complete a pickup.
|
||||
// Every test here is ultimately about that.
|
||||
|
||||
// No two sequence values may ever produce the same tracking number. Checked
|
||||
// over a large contiguous run, which is exactly the shape real traffic takes.
|
||||
func TestTrackingScrambleIsCollisionFree(t *testing.T) {
|
||||
const sample = 200_000
|
||||
|
||||
seen := make(map[uint64]int64, sample)
|
||||
for seq := int64(cxTrackingBase); seq < cxTrackingBase+sample; seq++ {
|
||||
got, ok := cxScrambledTracking(seq)
|
||||
if !ok {
|
||||
t.Fatalf("seq %d reported out of range inside the domain", seq)
|
||||
}
|
||||
if prev, dup := seen[got]; dup {
|
||||
t.Fatalf("COLLISION: seq %d and seq %d both produced %d — "+
|
||||
"the UNIQUE constraint would reject the second booking", prev, seq, got)
|
||||
}
|
||||
seen[got] = seq
|
||||
}
|
||||
if len(seen) != sample {
|
||||
t.Errorf("produced %d distinct numbers from %d inputs", len(seen), sample)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookingScrambleIsCollisionFree(t *testing.T) {
|
||||
// The booking domain is only 900,000, so the whole thing is checkable —
|
||||
// this is an exhaustive proof of bijectivity, not a sample.
|
||||
seen := make(map[uint64]int64, cxBookingDomain)
|
||||
for seq := int64(cxBookingBase); seq < cxBookingBase+cxBookingDomain; seq++ {
|
||||
got, ok := cxScrambledBooking(seq)
|
||||
if !ok {
|
||||
t.Fatalf("seq %d reported out of range inside the domain", seq)
|
||||
}
|
||||
if prev, dup := seen[got]; dup {
|
||||
t.Fatalf("COLLISION: seq %d and seq %d both produced %d", prev, seq, got)
|
||||
}
|
||||
seen[got] = seq
|
||||
}
|
||||
if len(seen) != cxBookingDomain {
|
||||
t.Fatalf("the permutation is not a bijection: %d distinct outputs from %d inputs",
|
||||
len(seen), cxBookingDomain)
|
||||
}
|
||||
}
|
||||
|
||||
// Output must stay inside the digit range, or the format silently changes
|
||||
// width and every label, column and deep link that assumed it breaks.
|
||||
func TestScrambledIdentifiersKeepTheirWidth(t *testing.T) {
|
||||
for _, seq := range []int64{
|
||||
cxTrackingBase,
|
||||
cxTrackingBase + 1,
|
||||
cxTrackingBase + 12_345,
|
||||
cxTrackingBase + cxTrackingDomain - 1,
|
||||
} {
|
||||
got, ok := cxScrambledTracking(seq)
|
||||
if !ok {
|
||||
t.Fatalf("seq %d out of range", seq)
|
||||
}
|
||||
if got < cxTrackingBase || got > 99_999_999 {
|
||||
t.Errorf("seq %d produced %d, outside the eight-digit range", seq, got)
|
||||
}
|
||||
if formatted := fmt.Sprintf("DMX%08d", got); len(formatted) != 11 {
|
||||
t.Errorf("formatted as %q (%d chars), want 11", formatted, len(formatted))
|
||||
}
|
||||
}
|
||||
|
||||
for _, seq := range []int64{
|
||||
cxBookingBase,
|
||||
cxBookingBase + 1,
|
||||
cxBookingBase + cxBookingDomain - 1,
|
||||
} {
|
||||
got, ok := cxScrambledBooking(seq)
|
||||
if !ok {
|
||||
t.Fatalf("seq %d out of range", seq)
|
||||
}
|
||||
if got < cxBookingBase || got > 999_999 {
|
||||
t.Errorf("seq %d produced %d, outside the six-digit range", seq, got)
|
||||
}
|
||||
if formatted := fmt.Sprintf("DM-%06d", got); len(formatted) != 9 {
|
||||
t.Errorf("formatted as %q (%d chars), want 9", formatted, len(formatted))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The point of the whole exercise: consecutive sequence values must NOT produce
|
||||
// adjacent identifiers. This is the leak being closed.
|
||||
func TestConsecutiveSequenceValuesAreNotAdjacent(t *testing.T) {
|
||||
const run = 500
|
||||
|
||||
var previous uint64
|
||||
adjacent := 0
|
||||
for i := 0; i < run; i++ {
|
||||
got, _ := cxScrambledTracking(int64(cxTrackingBase + i))
|
||||
if i > 0 {
|
||||
diff := int64(got) - int64(previous)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff < 100 {
|
||||
adjacent++
|
||||
}
|
||||
}
|
||||
previous = got
|
||||
}
|
||||
|
||||
// In a well-scattered 90,000,000-wide range, landing within 100 of the
|
||||
// previous value should essentially never happen.
|
||||
if adjacent > 2 {
|
||||
t.Errorf("%d of %d consecutive pairs landed within 100 of each other — "+
|
||||
"the identifiers are still walkable", adjacent, run-1)
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-destination pickup hands ONE customer several consecutive sequence
|
||||
// values at once. If the mapping were linear — multiply by a coprime, the
|
||||
// obvious one-line trick — the differences between those tracking numbers would
|
||||
// all equal the multiplier, and that single booking would hand over the key to
|
||||
// the whole range. This is the test that rejects that design.
|
||||
func TestOneBookingDoesNotLeakTheMapping(t *testing.T) {
|
||||
// Three orders minted back to back, as a three-destination pickup would.
|
||||
a, _ := cxScrambledTracking(cxTrackingBase + 5000)
|
||||
b, _ := cxScrambledTracking(cxTrackingBase + 5001)
|
||||
c, _ := cxScrambledTracking(cxTrackingBase + 5002)
|
||||
|
||||
d1 := int64(b) - int64(a)
|
||||
d2 := int64(c) - int64(b)
|
||||
|
||||
if d1 == d2 {
|
||||
t.Fatalf("consecutive differences are identical (%d) — the mapping is "+
|
||||
"linear, so one multi-destination booking reveals it and the whole "+
|
||||
"range becomes enumerable", d1)
|
||||
}
|
||||
|
||||
// And knowing two neighbours must not predict the third.
|
||||
if int64(c) == int64(b)+d1 {
|
||||
t.Error("the third identifier is predictable from the first two")
|
||||
}
|
||||
}
|
||||
|
||||
// The mapping is deterministic — the same sequence value always yields the same
|
||||
// identifier. It is computed at insert time and stored, so this matters only
|
||||
// for reasoning and tests, but a non-deterministic mapping would mean the
|
||||
// scrambling depended on something it should not.
|
||||
func TestScramblingIsDeterministic(t *testing.T) {
|
||||
for _, seq := range []int64{cxTrackingBase, cxTrackingBase + 99, cxTrackingBase + 123_456} {
|
||||
first, _ := cxScrambledTracking(seq)
|
||||
for i := 0; i < 5; i++ {
|
||||
again, _ := cxScrambledTracking(seq)
|
||||
if again != first {
|
||||
t.Fatalf("seq %d produced %d then %d", seq, first, again)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Past the fixed-width range the caller must be told, so it can let the
|
||||
// identifier grow a digit rather than wrap onto one already issued. Wrapping
|
||||
// would be a duplicate, and a duplicate is a failed booking.
|
||||
func TestExhaustedDomainIsReportedNotWrapped(t *testing.T) {
|
||||
if _, ok := cxScrambledTracking(cxTrackingBase + cxTrackingDomain); ok {
|
||||
t.Error("the first sequence value past the tracking domain was accepted — " +
|
||||
"it would wrap onto an identifier already issued")
|
||||
}
|
||||
if _, ok := cxScrambledBooking(cxBookingBase + cxBookingDomain); ok {
|
||||
t.Error("the first sequence value past the booking domain was accepted")
|
||||
}
|
||||
|
||||
// And the generator falls back to plain sequential formatting there, which
|
||||
// grows a digit rather than colliding.
|
||||
if got := fmt.Sprintf("DM-%06d", cxBookingBase+cxBookingDomain); len(got) != 10 {
|
||||
t.Errorf("the overflow reference formats as %q; it should simply grow a digit", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A value below the sequence start is not a valid index and must be refused
|
||||
// rather than producing a negative or wrapped result.
|
||||
func TestBelowBaseIsRefused(t *testing.T) {
|
||||
if _, ok := cxScrambledTracking(0); ok {
|
||||
t.Error("seq 0 accepted for tracking")
|
||||
}
|
||||
if _, ok := cxScrambledBooking(cxBookingBase - 1); ok {
|
||||
t.Error("a sequence value below the booking base was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// The Feistel itself is a permutation over the full power-of-two space. This is
|
||||
// the property everything else rests on, so it is checked directly rather than
|
||||
// only through its callers.
|
||||
func TestFeistelIsAPermutation(t *testing.T) {
|
||||
const halfBits = 8 // a 16-bit space, small enough to check exhaustively
|
||||
full := uint64(1) << (2 * halfBits)
|
||||
|
||||
seen := make(map[uint64]uint64, full)
|
||||
for x := uint64(0); x < full; x++ {
|
||||
y := cxFeistelEncrypt(x, halfBits, cxScrambleKey)
|
||||
if y >= full {
|
||||
t.Fatalf("encrypt(%d) = %d, outside the %d-wide space", x, y, full)
|
||||
}
|
||||
if prev, dup := seen[y]; dup {
|
||||
t.Fatalf("not a permutation: %d and %d both map to %d", prev, x, y)
|
||||
}
|
||||
seen[y] = x
|
||||
}
|
||||
if uint64(len(seen)) != full {
|
||||
t.Fatalf("covered %d of %d values", len(seen), full)
|
||||
}
|
||||
}
|
||||
|
||||
// A different key must produce a different permutation — otherwise the key is
|
||||
// not actually keying anything.
|
||||
func TestKeyChangesThePermutation(t *testing.T) {
|
||||
const halfBits = 8
|
||||
differences := 0
|
||||
for x := uint64(0); x < 256; x++ {
|
||||
if cxFeistelEncrypt(x, halfBits, []byte("key-one")) !=
|
||||
cxFeistelEncrypt(x, halfBits, []byte("key-two")) {
|
||||
differences++
|
||||
}
|
||||
}
|
||||
if differences < 250 {
|
||||
t.Errorf("only %d of 256 values differed between keys — the key has "+
|
||||
"little effect on the mapping", differences)
|
||||
}
|
||||
}
|
||||
|
||||
// Every surface — customer app, miler app, admin console, hub console — reads
|
||||
// the SAME column, so format consistency is structural: one generator, one
|
||||
// stored value. These assert the generators themselves produce the documented
|
||||
// shape, including on the fallback path that runs when the sequence cannot be
|
||||
// read (no database in a test, which is exactly what exercises it here).
|
||||
func TestGeneratorsProduceTheDocumentedFormat(t *testing.T) {
|
||||
for i := 0; i < 50; i++ {
|
||||
booking := generateBookingNo()
|
||||
if len(booking) < 9 || booking[:3] != "DM-" {
|
||||
t.Fatalf("generateBookingNo() = %q, want DM- followed by at least six digits", booking)
|
||||
}
|
||||
for _, r := range booking[3:] {
|
||||
if r < '0' || r > '9' {
|
||||
t.Fatalf("generateBookingNo() = %q — the part after DM- must be digits only", booking)
|
||||
}
|
||||
}
|
||||
|
||||
tracking := generateTrackingNo()
|
||||
if len(tracking) < 11 || tracking[:3] != "DMX" {
|
||||
t.Fatalf("generateTrackingNo() = %q, want DMX followed by at least eight digits", tracking)
|
||||
}
|
||||
for _, r := range tracking[3:] {
|
||||
if r < '0' || r > '9' {
|
||||
t.Fatalf("generateTrackingNo() = %q — the part after DMX must be digits only", tracking)
|
||||
}
|
||||
}
|
||||
// A tracking number must never be mistakeable for a booking reference:
|
||||
// the assistant and the consoles tell them apart by prefix alone.
|
||||
if tracking[:3] == "DM-" {
|
||||
t.Fatalf("tracking number %q collides with the booking reference prefix", tracking)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The fallback path must never emit a short number that formats with leading
|
||||
// zeros — DM-000042 reads as a broken reference, and a padded id is a support
|
||||
// call.
|
||||
func TestFallbackNumberNeverGoesShort(t *testing.T) {
|
||||
for i := 0; i < 200; i++ {
|
||||
if n := fallbackNumber(6); n < 100_000 || n > 999_999 {
|
||||
t.Fatalf("fallbackNumber(6) = %d, outside the six-digit range", n)
|
||||
}
|
||||
if n := fallbackNumber(8); n < 10_000_000 || n > 99_999_999 {
|
||||
t.Fatalf("fallbackNumber(8) = %d, outside the eight-digit range", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
controllers/cxIdentifiers.go
Normal file
95
controllers/cxIdentifiers.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"doormile/db"
|
||||
"doormile/utils"
|
||||
)
|
||||
|
||||
// Human-facing identifiers.
|
||||
//
|
||||
// A pickup booking is DM-######; an order is DMX########. Both columns are
|
||||
// UNIQUE, and both used to be minted from four random bytes plus a truncated
|
||||
// unix second. Random short ids collide long before the id space runs out, and
|
||||
// a collision here is not a retry — it is a failed booking at the moment the
|
||||
// customer taps Confirm. So both come off a Postgres sequence, which is the
|
||||
// only generator in this system that can promise uniqueness.
|
||||
//
|
||||
// The sequences have no MAXVALUE and no CYCLE (migrations/migrate.go): past
|
||||
// 999999 the reference simply grows a digit rather than wrapping onto an id
|
||||
// that already exists. Rows written before this change keep their old
|
||||
// DM-BK-/DM-TRK- strings; nothing anywhere parses either format, so the two
|
||||
// coexist and no backfill is needed.
|
||||
|
||||
// nextSequenceValue draws the next value from a Postgres sequence.
|
||||
func nextSequenceValue(sequence string) (int64, bool) {
|
||||
if db.DB == nil {
|
||||
return 0, false
|
||||
}
|
||||
var n int64
|
||||
if err := db.DB.Raw(fmt.Sprintf("SELECT nextval('%s')", sequence)).Scan(&n).Error; err != nil {
|
||||
utils.Warn("identifier sequence unavailable, falling back", "sequence", sequence, "error", err)
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// fallbackNumber is used only when the sequence cannot be read — a database
|
||||
// that is unreachable, or a deployment where the migration has not run yet. It
|
||||
// keeps the shape of the identifier (so the client and the console never see a
|
||||
// second format) and takes its entropy from the clock plus a random tail,
|
||||
// which makes a collision vanishingly unlikely for the short window this path
|
||||
// is ever live. It is a degradation, not a design: the sequence is the
|
||||
// guarantee.
|
||||
func fallbackNumber(digits int) int64 {
|
||||
span := int64(1)
|
||||
for i := 0; i < digits; i++ {
|
||||
span *= 10
|
||||
}
|
||||
base := time.Now().UnixNano() % span
|
||||
jitter := rand.Int63n(1000)
|
||||
n := (base + jitter) % span
|
||||
// Never return a value that would render with fewer digits than the format
|
||||
// promises — DM-000042 reads as a broken reference, not a short one.
|
||||
if n < span/10 {
|
||||
n += span / 10
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// generateBookingNo mints a pickup booking reference: DM-482913.
|
||||
//
|
||||
// The sequence guarantees uniqueness; cxScrambledBooking scatters it so
|
||||
// consecutive bookings do not get adjacent references. See
|
||||
// cxIdentifierScramble.go for why the scattering is a keyed permutation rather
|
||||
// than arithmetic.
|
||||
//
|
||||
// Past 900,000 bookings the fixed-width range is exhausted and the reference
|
||||
// grows a digit instead of wrapping onto one already issued. Uniqueness is
|
||||
// never traded for appearance.
|
||||
func generateBookingNo() string {
|
||||
if n, ok := nextSequenceValue("cx_booking_reference_seq"); ok {
|
||||
if scrambled, inRange := cxScrambledBooking(n); inRange {
|
||||
return fmt.Sprintf("DM-%06d", scrambled)
|
||||
}
|
||||
return fmt.Sprintf("DM-%06d", n)
|
||||
}
|
||||
return fmt.Sprintf("DM-%06d", fallbackNumber(6))
|
||||
}
|
||||
|
||||
// generateTrackingNo mints an order's tracking number: DMX10482913. One per
|
||||
// destination, minted when the miler completes the pickup — never at booking
|
||||
// time, because until the parcels are actually collected there is no order to
|
||||
// track.
|
||||
func generateTrackingNo() string {
|
||||
if n, ok := nextSequenceValue("cx_tracking_seq"); ok {
|
||||
if scrambled, inRange := cxScrambledTracking(n); inRange {
|
||||
return fmt.Sprintf("DMX%08d", scrambled)
|
||||
}
|
||||
return fmt.Sprintf("DMX%08d", n)
|
||||
}
|
||||
return fmt.Sprintf("DMX%08d", fallbackNumber(8))
|
||||
}
|
||||
160
controllers/cxOpsController.go
Normal file
160
controllers/cxOpsController.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/internal/cxstage"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// QA support — §11 of the deliverables.
|
||||
//
|
||||
// "A way to force a booking to any stage on staging. Every tracking state must
|
||||
// be reachable for QA — this is what lets us delete the debug stepper."
|
||||
//
|
||||
// The customer app currently walks its tracking screen through a hardcoded
|
||||
// stepper because no real backend could produce those states on demand.
|
||||
// Reaching out_for_delivery honestly needs a rider to accept, drive, weigh a
|
||||
// parcel, hand it to a hub and start a delivery run — which is not something
|
||||
// design QA can do before every screenshot.
|
||||
//
|
||||
// This endpoint is hard-gated. It refuses outright when ENV is production, and
|
||||
// it additionally requires CX_ALLOW_STAGE_OVERRIDE to be set: two independent
|
||||
// switches, because one of them being wrong on a production deploy would hand
|
||||
// anyone with a customer token the ability to mark their own parcel delivered.
|
||||
|
||||
// cxStageOverrideEnabled reports whether the QA stage override may run at all.
|
||||
func cxStageOverrideEnabled() bool {
|
||||
if strings.EqualFold(strings.TrimSpace(os.Getenv("ENV")), "production") {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv("CX_ALLOW_STAGE_OVERRIDE")), "true")
|
||||
}
|
||||
|
||||
// ForceCxStage drives a booking to an arbitrary stage on staging.
|
||||
//
|
||||
// It writes through the same cxstage recorder every real transition uses, so
|
||||
// what QA sees is the real projection over real event rows — not a special
|
||||
// rendering path that could pass while the production one is broken.
|
||||
func ForceCxStage(c *fiber.Ctx) error {
|
||||
if !cxStageOverrideEnabled() {
|
||||
return utils.CxNotFound(c, "Not available")
|
||||
}
|
||||
|
||||
customerID := c.Locals("userid").(int)
|
||||
reference := strings.TrimSpace(c.Params("reference"))
|
||||
|
||||
var req struct {
|
||||
Stage string `json:"stage"`
|
||||
// Reason is recorded on the audit row so a forced transition is
|
||||
// distinguishable from a real one forever after. A staging database
|
||||
// that gets promoted, or an export read months later, must not present
|
||||
// invented history as observed history.
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
|
||||
stage := strings.TrimSpace(req.Stage)
|
||||
if constants.CxStageRank(stage) < 0 {
|
||||
return utils.CxBadRequest(c, "Unknown stage")
|
||||
}
|
||||
|
||||
booking, err := cxLoadBooking(customerID, reference)
|
||||
if err != nil {
|
||||
return utils.CxNotFound(c, "We could not find that pickup")
|
||||
}
|
||||
|
||||
var destinations []models.BookingDestination
|
||||
if err := db.DB.Where("bookingid = ?", booking.Bookingid).
|
||||
Order("seq ASC").Find(&destinations).Error; err != nil {
|
||||
utils.Error("ForceCxStage: destination query failed", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
reason = "forced on staging for QA"
|
||||
}
|
||||
|
||||
tx := db.DB.Begin()
|
||||
|
||||
// Walk every stage up to the target rather than jumping. A timeline with a
|
||||
// hole in it is not a state the production flow can produce, so testing
|
||||
// against one proves nothing about the screen that renders it.
|
||||
for _, s := range []string{
|
||||
constants.CxStageBooked, constants.CxStageAssigned, constants.CxStageOnTheWay,
|
||||
constants.CxStageArrived, constants.CxStagePickedUp, constants.CxStageOrderCreated,
|
||||
constants.CxStageInTransit, constants.CxStageOutForDelivery, constants.CxStageDelivered,
|
||||
} {
|
||||
if constants.CxStageRank(s) > constants.CxStageRank(stage) {
|
||||
break
|
||||
}
|
||||
|
||||
perOrder := constants.CxStageRank(s) >= constants.CxStageOrder[constants.CxStageInTransit]
|
||||
if perOrder && len(destinations) > 0 {
|
||||
for i := range destinations {
|
||||
id := destinations[i].Bookingdestinationid
|
||||
if err := cxstage.Record(tx, cxstage.Event{
|
||||
BookingID: booking.Bookingid,
|
||||
DestinationID: &id,
|
||||
Stage: s,
|
||||
ActorType: constants.CxActorOps,
|
||||
ActorID: &customerID,
|
||||
Source: "POST /customer/ops/bookings/{reference}/stage",
|
||||
Remarks: reason,
|
||||
}); err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("ForceCxStage: record failed", "booking_id", booking.Bookingid, "stage", s, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := cxstage.Record(tx, cxstage.Event{
|
||||
BookingID: booking.Bookingid,
|
||||
Stage: s,
|
||||
ActorType: constants.CxActorOps,
|
||||
ActorID: &customerID,
|
||||
Source: "POST /customer/ops/bookings/{reference}/stage",
|
||||
Remarks: reason,
|
||||
}); err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("ForceCxStage: record failed", "booking_id", booking.Bookingid, "stage", s, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
}
|
||||
|
||||
// Tracking numbers exist from order_created onward, so a forced booking
|
||||
// past that point needs them or the orders render with a null trackingId
|
||||
// and the deep-link path cannot be tested at all.
|
||||
if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStageOrderCreated] {
|
||||
for i := range destinations {
|
||||
if destinations[i].Trackingno != "" {
|
||||
continue
|
||||
}
|
||||
if err := tx.Model(&models.BookingDestination{}).
|
||||
Where("bookingdestinationid = ?", destinations[i].Bookingdestinationid).
|
||||
Update("trackingno", generateTrackingNo()).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("ForceCxStage: could not mint tracking number", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
utils.Error("ForceCxStage: commit failed", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusOK)
|
||||
}
|
||||
251
controllers/cxPickupFanout.go
Normal file
251
controllers/cxPickupFanout.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Pickup fan-out: one visit becomes N orders.
|
||||
//
|
||||
// This is the change §1 of the customer contract turns on. A pickup booking
|
||||
// used to convert into exactly one consignment, because a booking carried
|
||||
// exactly one delivery address in its own columns. A customer-app booking
|
||||
// carries 1..N destinations, and each of those has to become its own
|
||||
// consignment with its own tracking number and its own journey — three parcels
|
||||
// collected in one visit for Chennai, Kochi and Bengaluru travel three separate
|
||||
// routes the moment they leave the door.
|
||||
//
|
||||
// A booking with no destination rows — every console-created express booking,
|
||||
// and every row written before this table existed — produces exactly one leg
|
||||
// built from the flat delivery columns, which is byte-for-byte the behaviour
|
||||
// that was there before. Single-destination is not a special case in either
|
||||
// direction: it is one leg, through the same code.
|
||||
|
||||
// cxPickupLeg is one journey to create at pickup completion.
|
||||
type cxPickupLeg struct {
|
||||
// Destination is nil for a booking that has no destination rows.
|
||||
Destination *models.BookingDestination
|
||||
|
||||
DeliveryLatitude float64
|
||||
DeliveryLongitude float64
|
||||
DeliveryPincode string
|
||||
CodAmount float64
|
||||
|
||||
// Parcels are the packages going to this destination, already weighed by
|
||||
// the miler.
|
||||
Parcels []models.BookingParcel
|
||||
}
|
||||
|
||||
// cxPickupLegs splits a booking into the journeys its parcels are about to
|
||||
// take.
|
||||
func cxPickupLegs(tx *gorm.DB, booking *models.PickupBooking) ([]cxPickupLeg, error) {
|
||||
var destinations []models.BookingDestination
|
||||
if err := tx.Where("bookingid = ?", booking.Bookingid).
|
||||
Order("seq ASC").Find(&destinations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var parcels []models.BookingParcel
|
||||
if err := tx.Where("bookingid = ?", booking.Bookingid).Find(&parcels).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(destinations) == 0 {
|
||||
// The pre-existing shape: one consignment from the booking's own
|
||||
// delivery columns, carrying every parcel on the booking.
|
||||
return []cxPickupLeg{{
|
||||
DeliveryLatitude: booking.Deliverylatitude,
|
||||
DeliveryLongitude: booking.Deliverylongitude,
|
||||
DeliveryPincode: booking.Deliverypincode,
|
||||
Parcels: parcels,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
byDestination := map[int][]models.BookingParcel{}
|
||||
unassigned := []models.BookingParcel{}
|
||||
for _, p := range parcels {
|
||||
if p.Bookingdestinationid == nil {
|
||||
unassigned = append(unassigned, p)
|
||||
continue
|
||||
}
|
||||
byDestination[*p.Bookingdestinationid] = append(byDestination[*p.Bookingdestinationid], p)
|
||||
}
|
||||
|
||||
legs := make([]cxPickupLeg, 0, len(destinations))
|
||||
for i := range destinations {
|
||||
d := destinations[i]
|
||||
|
||||
lat, lng := cxDestinationPoint(&d)
|
||||
leg := cxPickupLeg{
|
||||
Destination: &destinations[i],
|
||||
DeliveryLatitude: lat,
|
||||
DeliveryLongitude: lng,
|
||||
DeliveryPincode: d.Pincode,
|
||||
CodAmount: d.Codamount,
|
||||
Parcels: byDestination[d.Bookingdestinationid],
|
||||
}
|
||||
// Parcels the miler added at the door without naming a destination go
|
||||
// with the first one. Attributing them to nothing would drop them out
|
||||
// of every weight total, and the first destination is the one the rider
|
||||
// was shown.
|
||||
if i == 0 {
|
||||
leg.Parcels = append(leg.Parcels, unassigned...)
|
||||
}
|
||||
legs = append(legs, leg)
|
||||
}
|
||||
return legs, nil
|
||||
}
|
||||
|
||||
// cxDestinationPoint gives a destination the best coordinates it has: the
|
||||
// customer's own map pin if they dropped one, otherwise the district centre.
|
||||
// Only state and district are required at booking time, so the centre is
|
||||
// frequently all there is — and routing skips stops sitting at 0,0.
|
||||
func cxDestinationPoint(d *models.BookingDestination) (lat, lng float64) {
|
||||
if d.Pinlatitude != nil && d.Pinlongitude != nil &&
|
||||
(*d.Pinlatitude != 0 || *d.Pinlongitude != 0) {
|
||||
return *d.Pinlatitude, *d.Pinlongitude
|
||||
}
|
||||
var district models.ServiceableDistrict
|
||||
if err := db.DB.Select("centrelatitude, centrelongitude").
|
||||
Where("districtcode = ?", d.Districtcode).First(&district).Error; err == nil {
|
||||
return district.Centrelatitude, district.Centrelongitude
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// cxLegWeights totals a leg's packages the way the consignment records them.
|
||||
// A leg whose packages were never weighed falls back to the same 0.5 kg
|
||||
// placeholder the single-consignment path already used, so an unweighed pickup
|
||||
// still produces a chargeable consignment rather than a zero-weight one.
|
||||
func cxLegWeights(leg cxPickupLeg) (deadWeight, chargeable, maxL, maxW, maxH float64) {
|
||||
for _, p := range leg.Parcels {
|
||||
vol := calculateVolumetricWeight(p.Length, p.Width, p.Height)
|
||||
deadWeight += p.Weight
|
||||
chargeable += math.Max(p.Weight, vol)
|
||||
if p.Length > maxL {
|
||||
maxL = p.Length
|
||||
}
|
||||
if p.Width > maxW {
|
||||
maxW = p.Width
|
||||
}
|
||||
if p.Height > maxH {
|
||||
maxH = p.Height
|
||||
}
|
||||
}
|
||||
if len(leg.Parcels) == 0 || chargeable == 0 {
|
||||
deadWeight, chargeable = 0.5, 0.5
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// cxLinkLegToOrder records that a destination has become an order: its tracking
|
||||
// number, its consignment, and the delivery date the customer is promised.
|
||||
func cxLinkLegToOrder(tx *gorm.DB, leg cxPickupLeg, consignmentID int, trackingNo string, expected *time.Time, at time.Time) error {
|
||||
if leg.Destination == nil {
|
||||
return nil
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"consignmentid": consignmentID,
|
||||
"trackingno": trackingNo,
|
||||
"stage": constants.CxStageOrderCreated,
|
||||
"updatedat": at,
|
||||
}
|
||||
if expected != nil {
|
||||
updates["expecteddeliveryat"] = *expected
|
||||
}
|
||||
return tx.Model(&models.BookingDestination{}).
|
||||
Where("bookingdestinationid = ?", leg.Destination.Bookingdestinationid).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
// cxRecordVerification stores what the miler measured at the door: the weight
|
||||
// the price settles on, when, and who took the reading. It is the evidence half
|
||||
// of the receipt, and it is per destination because each order is weighed
|
||||
// separately.
|
||||
func cxRecordVerification(tx *gorm.DB, destinationID int, weightKg float64, byUserID int, at time.Time) error {
|
||||
return tx.Model(&models.BookingDestination{}).
|
||||
Where("bookingdestinationid = ?", destinationID).
|
||||
Updates(map[string]interface{}{
|
||||
"verifiedweightkg": weightKg,
|
||||
"verifiedat": at,
|
||||
"verifiedbyuserid": byUserID,
|
||||
"updatedat": at,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// cxDestinationForConsignment finds which order a consignment belongs to.
|
||||
//
|
||||
// The delivery handlers used to reach the booking with
|
||||
// `WHERE consignmentid = ?` on pickupbookings, which held exactly one
|
||||
// consignment id. With a fan-out that column only names the FIRST order, so
|
||||
// that lookup silently found nothing for destinations 2..N — no customer
|
||||
// notification, no stage advance, on every multi-destination booking. The join
|
||||
// goes through bookingdestinations now, which is the table that actually knows.
|
||||
func cxDestinationForConsignment(consignmentID int) (*models.BookingDestination, *models.PickupBooking, bool) {
|
||||
var dest models.BookingDestination
|
||||
if err := db.DB.Where("consignmentid = ?", consignmentID).First(&dest).Error; err == nil {
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.First(&booking, dest.Bookingid).Error; err == nil {
|
||||
return &dest, &booking, true
|
||||
}
|
||||
return &dest, nil, false
|
||||
}
|
||||
|
||||
// No destination row: a console-created booking, or one written before the
|
||||
// fan-out existed. The legacy link still answers for those.
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.Where("consignmentid = ?", consignmentID).First(&booking).Error; err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
return nil, &booking, true
|
||||
}
|
||||
|
||||
// cxDestinationIDFor returns the destination id to attribute a per-order stage
|
||||
// to, or nil when the booking has no destination rows.
|
||||
func cxDestinationIDFor(dest *models.BookingDestination) *int {
|
||||
if dest == nil {
|
||||
return nil
|
||||
}
|
||||
id := dest.Bookingdestinationid
|
||||
return &id
|
||||
}
|
||||
|
||||
// cxEstimatedDelivery is the promise shown to the customer, derived from the
|
||||
// serving district's own promise text rather than a single platform-wide SLA —
|
||||
// "Next-day delivery" into Chennai and "2-day delivery" into a district two
|
||||
// states away are different promises and must not resolve to the same date.
|
||||
func cxEstimatedDelivery(leg cxPickupLeg, from time.Time) *time.Time {
|
||||
days := 2
|
||||
if leg.Destination != nil {
|
||||
var district models.ServiceableDistrict
|
||||
if err := db.DB.Select("promise").
|
||||
Where("districtcode = ?", leg.Destination.Districtcode).
|
||||
First(&district).Error; err == nil {
|
||||
switch district.Promise {
|
||||
case "Same-day delivery":
|
||||
days = 0
|
||||
case "Next-day delivery":
|
||||
days = 1
|
||||
case "2-day delivery":
|
||||
days = 2
|
||||
case "3-day delivery":
|
||||
days = 3
|
||||
}
|
||||
}
|
||||
}
|
||||
// Delivered by end of the promised day, expressed in the wall clock this
|
||||
// database stores.
|
||||
target := from.AddDate(0, 0, days)
|
||||
expected := time.Date(target.Year(), target.Month(), target.Day(), 20, 0, 0, 0, time.UTC)
|
||||
if utils.EpochMillis(expected) < utils.EpochMillis(from) {
|
||||
expected = from
|
||||
}
|
||||
return &expected
|
||||
}
|
||||
350
controllers/cxPlacesController.go
Normal file
350
controllers/cxPlacesController.go
Normal file
@@ -0,0 +1,350 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// Places — §6 of the contract.
|
||||
//
|
||||
// Both endpoints proxy a geocoder rather than handing the app a key. The legacy
|
||||
// rider app shipped a Google Maps key inside the binary; it was extracted and
|
||||
// had to be revoked, and that side now runs on OSM/OSRM with no key at all. The
|
||||
// customer app is never given one: it asks Doormile, Doormile asks the
|
||||
// geocoder, and the answer is cached so the same street typed a hundred times
|
||||
// costs one upstream call.
|
||||
|
||||
const (
|
||||
// cxGeocodeTimeout is short on purpose. The pickup point already has a
|
||||
// device-supplied coordinate; a slow geocoder should degrade the label, not
|
||||
// stall the booking form.
|
||||
cxGeocodeTimeout = 4 * time.Second
|
||||
// cxGeocodeCacheTTL — addresses do not move. A week is conservative.
|
||||
cxGeocodeCacheTTL = 7 * 24 * time.Hour
|
||||
cxSearchCacheTTL = 24 * time.Hour
|
||||
cxMaxSearchResults = 6
|
||||
// cxRecentPlacesShown is what the search sheet opens on: the customer's own
|
||||
// recent and saved places, capped by the design.
|
||||
cxRecentPlacesShown = 4
|
||||
)
|
||||
|
||||
// cxPlace is the one shape a place is returned in, from search, from reverse
|
||||
// geocode and from the saved-address list. title is a short label the UI puts
|
||||
// on one line; sub is the full address under it. Neither is ever null — the
|
||||
// client types both as non-nullable strings.
|
||||
type cxPlace struct {
|
||||
Title string `json:"title"`
|
||||
Sub string `json:"sub"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
}
|
||||
|
||||
// ReverseGeocodeCx turns the device's coordinates into the pickup label.
|
||||
func ReverseGeocodeCx(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
lat, latErr := strconv.ParseFloat(c.Query("lat"), 64)
|
||||
lng, lngErr := strconv.ParseFloat(c.Query("lng"), 64)
|
||||
if latErr != nil || lngErr != nil || (lat == 0 && lng == 0) {
|
||||
return utils.CxBadRequest(c, "We need a location to look up")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("cx:geo:rev:%.5f:%.5f", lat, lng)
|
||||
if cached, ok := cxCacheGet(cacheKey); ok {
|
||||
var place cxPlace
|
||||
if json.Unmarshal([]byte(cached), &place) == nil {
|
||||
return utils.CxOK(c, place)
|
||||
}
|
||||
}
|
||||
|
||||
place, err := cxReverseGeocode(cfg, lat, lng)
|
||||
if err != nil {
|
||||
utils.Warn("ReverseGeocodeCx: upstream failed", "error", err)
|
||||
// A label the customer can correct beats a blocked booking form.
|
||||
// The coordinates are what the rider actually navigates to; the
|
||||
// text is what the customer reads, and they can edit it.
|
||||
return utils.CxOK(c, cxPlace{
|
||||
Title: "Selected location",
|
||||
Sub: fmt.Sprintf("%.5f, %.5f", lat, lng),
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
})
|
||||
}
|
||||
|
||||
if data, merr := json.Marshal(place); merr == nil {
|
||||
cxCacheSet(cacheKey, string(data), cxGeocodeCacheTTL)
|
||||
}
|
||||
return utils.CxOK(c, place)
|
||||
}
|
||||
}
|
||||
|
||||
// SearchCxPlaces backs the pickup-point search sheet.
|
||||
//
|
||||
// An empty query is not an error: the sheet opens on it, and answers with the
|
||||
// customer's own saved and recently used places.
|
||||
func SearchCxPlaces(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
lat, _ := strconv.ParseFloat(c.Query("lat"), 64)
|
||||
lng, _ := strconv.ParseFloat(c.Query("lng"), 64)
|
||||
|
||||
if q == "" {
|
||||
customerID, _ := c.Locals("userid").(int)
|
||||
places := cxRecentPlaces(customerID)
|
||||
return utils.CxList(c, places, len(places), nil)
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("cx:geo:q:%s:%.2f:%.2f", strings.ToLower(q), lat, lng)
|
||||
if cached, ok := cxCacheGet(cacheKey); ok {
|
||||
var places []cxPlace
|
||||
if json.Unmarshal([]byte(cached), &places) == nil {
|
||||
return utils.CxList(c, places, len(places), nil)
|
||||
}
|
||||
}
|
||||
|
||||
places, err := cxSearchPlaces(cfg, q, lat, lng)
|
||||
if err != nil {
|
||||
utils.Warn("SearchCxPlaces: upstream failed", "error", err)
|
||||
// An empty list is a designed state in the sheet ("no matches");
|
||||
// an error is a retry button in the middle of a booking.
|
||||
return utils.CxList(c, []cxPlace{}, 0, nil)
|
||||
}
|
||||
|
||||
if data, merr := json.Marshal(places); merr == nil {
|
||||
cxCacheSet(cacheKey, string(data), cxSearchCacheTTL)
|
||||
}
|
||||
return utils.CxList(c, places, len(places), nil)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upstream ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type nominatimPlace struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Lat string `json:"lat"`
|
||||
Lon string `json:"lon"`
|
||||
Name string `json:"name"`
|
||||
Address struct {
|
||||
Road string `json:"road"`
|
||||
HouseNumber string `json:"house_number"`
|
||||
Neighbourhood string `json:"neighbourhood"`
|
||||
Suburb string `json:"suburb"`
|
||||
City string `json:"city"`
|
||||
Town string `json:"town"`
|
||||
Village string `json:"village"`
|
||||
StateDistrict string `json:"state_district"`
|
||||
State string `json:"state"`
|
||||
Postcode string `json:"postcode"`
|
||||
} `json:"address"`
|
||||
}
|
||||
|
||||
func cxReverseGeocode(cfg *config.Config, lat, lng float64) (cxPlace, error) {
|
||||
endpoint := fmt.Sprintf("%s/reverse?format=jsonv2&lat=%f&lon=%f&addressdetails=1&zoom=18",
|
||||
strings.TrimRight(cfg.GeocoderURL, "/"), lat, lng)
|
||||
|
||||
var place nominatimPlace
|
||||
if err := cxGeocoderGet(cfg, endpoint, &place); err != nil {
|
||||
return cxPlace{}, err
|
||||
}
|
||||
return cxPlaceFrom(place, lat, lng), nil
|
||||
}
|
||||
|
||||
func cxSearchPlaces(cfg *config.Config, q string, lat, lng float64) ([]cxPlace, error) {
|
||||
endpoint := fmt.Sprintf("%s/search?format=jsonv2&addressdetails=1&limit=%d&countrycodes=in&q=%s",
|
||||
strings.TrimRight(cfg.GeocoderURL, "/"), cxMaxSearchResults, url.QueryEscape(q))
|
||||
|
||||
// Bias to where the customer is. A "Brookefields" typed in Coimbatore must
|
||||
// not answer with one in another state first.
|
||||
if lat != 0 || lng != 0 {
|
||||
const box = 0.75 // degrees, roughly 80km
|
||||
endpoint += fmt.Sprintf("&viewbox=%f,%f,%f,%f&bounded=0",
|
||||
lng-box, lat+box, lng+box, lat-box)
|
||||
}
|
||||
|
||||
var raw []nominatimPlace
|
||||
if err := cxGeocoderGet(cfg, endpoint, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
places := make([]cxPlace, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
plat, _ := strconv.ParseFloat(r.Lat, 64)
|
||||
plng, _ := strconv.ParseFloat(r.Lon, 64)
|
||||
places = append(places, cxPlaceFrom(r, plat, plng))
|
||||
}
|
||||
return places, nil
|
||||
}
|
||||
|
||||
func cxGeocoderGet(cfg *config.Config, endpoint string, out interface{}) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cxGeocodeTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Nominatim's usage policy requires an identifiable caller; anonymous
|
||||
// traffic gets throttled or blocked outright.
|
||||
agent := "doormile-backend/1.0"
|
||||
if cfg.GeocoderEmail != "" {
|
||||
agent += " (" + cfg.GeocoderEmail + ")"
|
||||
}
|
||||
req.Header.Set("User-Agent", agent)
|
||||
req.Header.Set("Accept-Language", "en")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("geocoder returned %d", resp.StatusCode)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// cxPlaceFrom builds the two-line label. title is capped at the 32 characters
|
||||
// the design allots it, and falls back through the parts of the address most
|
||||
// likely to be recognisable at a glance.
|
||||
func cxPlaceFrom(p nominatimPlace, lat, lng float64) cxPlace {
|
||||
title := strings.TrimSpace(p.Name)
|
||||
if title == "" {
|
||||
title = joinNonEmpty(" ", p.Address.HouseNumber, p.Address.Road)
|
||||
}
|
||||
if title == "" {
|
||||
title = firstNonEmpty(p.Address.Neighbourhood, p.Address.Suburb,
|
||||
p.Address.City, p.Address.Town, p.Address.Village)
|
||||
}
|
||||
if title == "" {
|
||||
// Last resort: the leading segment of the display name.
|
||||
if i := strings.Index(p.DisplayName, ","); i > 0 {
|
||||
title = strings.TrimSpace(p.DisplayName[:i])
|
||||
} else {
|
||||
title = "Selected location"
|
||||
}
|
||||
}
|
||||
if r := []rune(title); len(r) > 32 {
|
||||
title = strings.TrimSpace(string(r[:32]))
|
||||
}
|
||||
|
||||
sub := joinNonEmpty(", ",
|
||||
firstNonEmpty(p.Address.Neighbourhood, p.Address.Suburb),
|
||||
firstNonEmpty(p.Address.City, p.Address.Town, p.Address.Village, p.Address.StateDistrict),
|
||||
p.Address.Postcode)
|
||||
if sub == "" {
|
||||
sub = strings.TrimSpace(p.DisplayName)
|
||||
}
|
||||
if sub == "" {
|
||||
sub = fmt.Sprintf("%.5f, %.5f", lat, lng)
|
||||
}
|
||||
|
||||
return cxPlace{Title: title, Sub: sub, Lat: lat, Lng: lng}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// cxRecentPlaces answers an empty search with the places this customer already
|
||||
// uses — their saved addresses first, then the pickup points of their recent
|
||||
// bookings. Booking again from the same doorstep is the common case, and making
|
||||
// them re-type it is the friction this removes.
|
||||
func cxRecentPlaces(customerID int) []cxPlace {
|
||||
places := make([]cxPlace, 0, cxRecentPlacesShown)
|
||||
seen := map[string]bool{}
|
||||
|
||||
add := func(p cxPlace) {
|
||||
if len(places) >= cxRecentPlacesShown {
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("%.4f:%.4f", p.Lat, p.Lng)
|
||||
if seen[key] || (p.Lat == 0 && p.Lng == 0) {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
places = append(places, p)
|
||||
}
|
||||
|
||||
if customerID != 0 {
|
||||
var saved []models.AppCustomerLocation
|
||||
if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active").
|
||||
Order("isdefault DESC, appcustomerlocationid DESC").
|
||||
Limit(cxRecentPlacesShown).Find(&saved).Error; err == nil {
|
||||
for _, l := range saved {
|
||||
title := l.Label
|
||||
if title == "" {
|
||||
title = l.Address
|
||||
}
|
||||
add(cxPlace{
|
||||
Title: title,
|
||||
Sub: joinNonEmpty(", ", l.Address, l.City, l.Pincode),
|
||||
Lat: l.Latitude,
|
||||
Lng: l.Longitude,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var recent []models.PickupBooking
|
||||
if err := db.DB.Select("pickuptitle, pickupsub, pickupaddress, pickuppincode, pickuplatitude, pickuplongitude").
|
||||
Where("appcustomerid = ?", customerID).
|
||||
Order("bookingid DESC").Limit(10).Find(&recent).Error; err == nil {
|
||||
for i := range recent {
|
||||
b := recent[i]
|
||||
add(cxPlace{
|
||||
Title: cxPickupTitle(&b),
|
||||
Sub: cxPickupSub(&b),
|
||||
Lat: b.Pickuplatitude,
|
||||
Lng: b.Pickuplongitude,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return places
|
||||
}
|
||||
|
||||
// ── Cache ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// cxCacheGet / cxCacheSet are best-effort. A geocoder answer is a convenience,
|
||||
// never a correctness requirement, so a Redis outage costs latency and upstream
|
||||
// quota rather than the feature.
|
||||
func cxCacheGet(key string) (string, bool) {
|
||||
if db.Rdb == nil {
|
||||
return "", false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
value, err := db.Rdb.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func cxCacheSet(key, value string, ttl time.Duration) {
|
||||
if db.Rdb == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
if err := db.Rdb.Set(ctx, key, value, ttl).Err(); err != nil {
|
||||
utils.Warn("cxCacheSet: failed", "key", key, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"doormile/db"
|
||||
"doormile/dto"
|
||||
"doormile/internal/assignment"
|
||||
"doormile/internal/legs"
|
||||
"doormile/internal/routing"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
@@ -70,14 +71,11 @@ func zoneName(pincode string) string {
|
||||
}
|
||||
|
||||
// haversineKM returns the great-circle distance between two lat/lon points in km.
|
||||
// haversineKM stays the name the whole controllers package calls, and now
|
||||
// delegates to internal/legs so the route sequencer measures distance with the
|
||||
// identical implementation rather than a second copy of it.
|
||||
func haversineKM(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const earthRadiusKM = 6371.0
|
||||
toRad := func(deg float64) float64 { return deg * math.Pi / 180 }
|
||||
dLat := toRad(lat2 - lat1)
|
||||
dLon := toRad(lon2 - lon1)
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
return legs.HaversineKM(lat1, lon1, lat2, lon2)
|
||||
}
|
||||
|
||||
// humanizeRelativeTime renders a timestamp as "5 min ago" / "2 hrs ago" / "3 days ago".
|
||||
@@ -315,15 +313,27 @@ func GetHubUnassignedBookings(c *fiber.Ctx) error {
|
||||
var customer models.AppCustomer
|
||||
db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer)
|
||||
|
||||
// What kind of place this is collected from, and which one. A Base/Hub
|
||||
// pickup carries the base id, so the dispatch board can show that the
|
||||
// parcel starts at a base rather than at a customer's door — and the row
|
||||
// the rider app receives carries the same pair.
|
||||
customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname)
|
||||
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName)
|
||||
|
||||
response = append(response, fiber.Map{
|
||||
"bookingid": b.Bookingid,
|
||||
"bookingno": b.Bookingno,
|
||||
"customer_name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
||||
"pickup_address": b.Pickupaddress,
|
||||
"pickup_pincode": b.Pickuppincode,
|
||||
"delivery_address": b.Deliveryaddress,
|
||||
"parcels": b.Parcels,
|
||||
"created_at": b.Createdat,
|
||||
"bookingid": b.Bookingid,
|
||||
"bookingno": b.Bookingno,
|
||||
"customer_name": customerName,
|
||||
"pickup_source_type": sourceType,
|
||||
"sourceid": sourceID,
|
||||
"pickuplocationid": sourceID,
|
||||
"pickup_source_name": sourceName,
|
||||
"pickup_address": sourceAddress,
|
||||
"pickup_pincode": b.Pickuppincode,
|
||||
"delivery_address": b.Deliveryaddress,
|
||||
"delivery_pincode": b.Deliverypincode,
|
||||
"parcels": b.Parcels,
|
||||
"created_at": b.Createdat,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -389,17 +399,25 @@ func GetHubBookingsRange(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname)
|
||||
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName)
|
||||
|
||||
response = append(response, fiber.Map{
|
||||
"bookingid": b.Bookingid,
|
||||
"bookingno": b.Bookingno,
|
||||
"customer_name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
||||
"pickup_address": b.Pickupaddress,
|
||||
"pickup_pincode": b.Pickuppincode,
|
||||
"delivery_address": b.Deliveryaddress,
|
||||
"parcels": b.Parcels,
|
||||
"status": hubBookingDisplayStatus(b.Status, hasMiler),
|
||||
"milername": milerName,
|
||||
"created_at": b.Createdat,
|
||||
"bookingid": b.Bookingid,
|
||||
"bookingno": b.Bookingno,
|
||||
"customer_name": customerName,
|
||||
"pickup_source_type": sourceType,
|
||||
"sourceid": sourceID,
|
||||
"pickuplocationid": sourceID,
|
||||
"pickup_source_name": sourceName,
|
||||
"pickup_address": sourceAddress,
|
||||
"pickup_pincode": b.Pickuppincode,
|
||||
"delivery_address": b.Deliveryaddress,
|
||||
"delivery_pincode": b.Deliverypincode,
|
||||
"parcels": b.Parcels,
|
||||
"status": hubBookingDisplayStatus(b.Status, hasMiler),
|
||||
"milername": milerName,
|
||||
"created_at": b.Createdat,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -437,6 +455,10 @@ func renderInboundConsignment(cs models.Consignment) fiber.Map {
|
||||
"temperature": "N/A",
|
||||
"status": cs.Status,
|
||||
"updatedat": cs.Updatedat,
|
||||
// The physical-receipt fact, distinct from updatedat, which moves on any
|
||||
// write. Null on rows inwarded before this column existed.
|
||||
"inwardedat": cs.Inwardedat,
|
||||
"inbound_status": "received",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,11 +561,18 @@ func CreateInboundScan(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
consignment.Status = constants.ConsignmentInwardedAtHub
|
||||
consignment.Currenthubid = &hubID
|
||||
consignment.Condition = req.Condition
|
||||
consignment.Shelf = recommendedShelf
|
||||
consignment.Updatedat = time.Now()
|
||||
consignment.Updatedat = now
|
||||
// The inbound scan is a physical receipt, so it stamps the same received-at
|
||||
// fact the rider handover does. Kept first-write-wins: a second scan of the
|
||||
// same parcel must not move the time it actually arrived.
|
||||
if consignment.Inwardedat == nil {
|
||||
consignment.Inwardedat = &now
|
||||
}
|
||||
|
||||
if err := db.DB.Save(&consignment).Error; err != nil {
|
||||
return utils.Internal(c, "failed to update consignment")
|
||||
|
||||
286
controllers/hubInboundController.go
Normal file
286
controllers/hubInboundController.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// --------------------
|
||||
// BASE INBOUND — what is on its way in, and confirming it arrived
|
||||
//
|
||||
// The console side of the rider handover. Wire vocabulary stays hub
|
||||
// (Inwarded_at_Hub, currenthubid); the rider app renders it as Base.
|
||||
// --------------------
|
||||
|
||||
// scopeConsignmentsToOwnTenant is the consignment counterpart of
|
||||
// scopeBookingsToOwnTenant: partner-tenant hub staff see only their own tenant's
|
||||
// parcels, Doormile staff see everything. Same rule, different table — without
|
||||
// it a partner's staff would read every other client's parcels passing through
|
||||
// the same base.
|
||||
func scopeConsignmentsToOwnTenant(c *fiber.Ctx, query *gorm.DB) *gorm.DB {
|
||||
staff, err := getCurrentHubStaff(c)
|
||||
if err != nil || isDoormileStaff(staff) {
|
||||
return query
|
||||
}
|
||||
return query.Where("tenantid = ?", *staff.Tenantid)
|
||||
}
|
||||
|
||||
// canHubStaffAccessConsignment proves ownership of a consignment addressed by id
|
||||
// before it is written to, rather than trusting the path parameter.
|
||||
func canHubStaffAccessConsignment(c *fiber.Ctx, cn *models.Consignment) bool {
|
||||
staff, err := getCurrentHubStaff(c)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if isDoormileStaff(staff) {
|
||||
return true
|
||||
}
|
||||
return staff.Tenantid != nil && cn.Tenantid == *staff.Tenantid
|
||||
}
|
||||
|
||||
// GetHubInboundExpected lists parcels a rider is currently carrying towards this
|
||||
// base — collected, routed here, not yet handed over. Between a rider collecting
|
||||
// an intercity parcel and inwarding it, nobody at the destination base could see
|
||||
// it was coming; this is that view.
|
||||
//
|
||||
// It reads consignments on Created — collected, in a rider's hands, with a base
|
||||
// as the next leg — whose current base is this one. Under the compatibility flow
|
||||
// a hub-routed parcel is marked Inwarded_at_Hub at pickup and so never appears
|
||||
// here; that is expected, and GetHubInboundToday covers those.
|
||||
func GetHubInboundExpected(c *fiber.Ctx) error {
|
||||
hubID := c.Locals("hubid").(int)
|
||||
|
||||
query := db.DB.Where("currenthubid = ? AND status = ? AND deletedat IS NULL",
|
||||
hubID, constants.ConsignmentCreated)
|
||||
query = scopeConsignmentsToOwnTenant(c, query)
|
||||
|
||||
var consignments []models.Consignment
|
||||
if err := query.Order("createdat DESC").Find(&consignments).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch expected inbound consignments")
|
||||
}
|
||||
|
||||
// Batched lookups — this stays a handful of queries however many parcels are
|
||||
// in flight towards the base.
|
||||
ids := make([]int, 0, len(consignments))
|
||||
for _, cs := range consignments {
|
||||
ids = append(ids, cs.Consignmentid)
|
||||
}
|
||||
bookingByConsignment := map[int]models.PickupBooking{}
|
||||
riderIDs := []int{}
|
||||
customerIDs := []int{}
|
||||
if len(ids) > 0 {
|
||||
var bookings []models.PickupBooking
|
||||
db.DB.Where("consignmentid IN ?", ids).Find(&bookings)
|
||||
for _, b := range bookings {
|
||||
if b.Consignmentid != nil {
|
||||
bookingByConsignment[*b.Consignmentid] = b
|
||||
}
|
||||
if b.Assignedmileruserid != nil {
|
||||
riderIDs = append(riderIDs, *b.Assignedmileruserid)
|
||||
}
|
||||
customerIDs = append(customerIDs, b.Appcustomerid)
|
||||
}
|
||||
}
|
||||
riderByID := map[int]models.AppUser{}
|
||||
if len(riderIDs) > 0 {
|
||||
var riders []models.AppUser
|
||||
db.DB.Where("userid IN ?", riderIDs).Find(&riders)
|
||||
for _, r := range riders {
|
||||
riderByID[r.Userid] = r
|
||||
}
|
||||
}
|
||||
customerByID := map[int]models.AppCustomer{}
|
||||
if len(customerIDs) > 0 {
|
||||
var customers []models.AppCustomer
|
||||
db.DB.Where("appcustomerid IN ?", customerIDs).Find(&customers)
|
||||
for _, cu := range customers {
|
||||
customerByID[cu.Appcustomerid] = cu
|
||||
}
|
||||
}
|
||||
|
||||
response := make([]fiber.Map, 0, len(consignments))
|
||||
for i := range consignments {
|
||||
cs := consignments[i]
|
||||
row := fiber.Map{
|
||||
"consignmentid": cs.Consignmentid,
|
||||
"trackingno": cs.Trackingno,
|
||||
// destination_base is the base this parcel moves on to after here, and
|
||||
// is only known once something routes it onward — null more often than
|
||||
// not. The delivery pincode is the reliable statement of where it ends
|
||||
// up, so both are given rather than one standing in for the other.
|
||||
"destination_base": renderBase(loadHub(cs.Destinationhubid)),
|
||||
"final_destination": cs.Deliverypincode,
|
||||
"delivery_pincode": cs.Deliverypincode,
|
||||
"chargeableweight": cs.Chargeableweight,
|
||||
"current_state": cs.Status,
|
||||
"inbound_status": "expected",
|
||||
"next_action": nextActionForConsignment(cs.Status),
|
||||
"collected_at": cs.Createdat,
|
||||
"pickup_pincode": cs.Pickuppincode,
|
||||
}
|
||||
if b, ok := bookingByConsignment[cs.Consignmentid]; ok {
|
||||
customerName := ""
|
||||
if cu, ok := customerByID[b.Appcustomerid]; ok {
|
||||
customerName = strings.TrimSpace(cu.Firstname + " " + cu.Lastname)
|
||||
}
|
||||
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName)
|
||||
row["bookingid"] = b.Bookingid
|
||||
row["bookingno"] = b.Bookingno
|
||||
row["customer_name"] = customerName
|
||||
row["pickup_source_type"] = sourceType
|
||||
row["pickup_source_id"] = sourceID
|
||||
row["source"] = sourceName
|
||||
row["pickup_address"] = sourceAddress
|
||||
row["destination_address"] = b.Deliveryaddress
|
||||
if b.Assignedmileruserid != nil {
|
||||
row["rider_userid"] = *b.Assignedmileruserid
|
||||
if r, ok := riderByID[*b.Assignedmileruserid]; ok {
|
||||
row["rider"] = r.Authname
|
||||
row["rider_phone"] = r.Contactno
|
||||
}
|
||||
}
|
||||
}
|
||||
response = append(response, row)
|
||||
}
|
||||
|
||||
return utils.List(c, response, int64(len(response)))
|
||||
}
|
||||
|
||||
// ReconcileHubInbound is the base's side of the rider handover: staff either
|
||||
// confirm the parcel is physically here, or record that it never arrived despite
|
||||
// a rider marking it handed over.
|
||||
//
|
||||
// received=true is the ordinary case and is idempotent — confirming a parcel
|
||||
// that is already inwarded re-affirms it rather than failing, because staff
|
||||
// working through a pile will hit some rows twice.
|
||||
//
|
||||
// received=false is the reconciliation path. It deliberately does not quietly
|
||||
// move the parcel backwards: it raises an exception naming the discrepancy, so a
|
||||
// parcel a rider swears was handed over and staff never saw becomes a tracked
|
||||
// open item rather than a disagreement nobody owns.
|
||||
func ReconcileHubInbound(c *fiber.Ctx) error {
|
||||
hubID := c.Locals("hubid").(int)
|
||||
// HubStaffAuth sets userid to the hubstaffaccountid, which is NOT an
|
||||
// appusers.userid. consignmentexceptions.reportedbyuserid and
|
||||
// consignmenthistory.userid both carry a real FK to appusers(userid), so
|
||||
// writing a hub-staff id into either violates it — the row is rejected and the
|
||||
// whole action 500s. The staff identity goes into the free-text fields
|
||||
// instead, and the FK-bearing columns are left null. createdby on
|
||||
// consignmentexceptions carries no FK, so it can hold the staff id.
|
||||
staffAccountID, _ := c.Locals("userid").(int)
|
||||
consignmentID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return utils.BadRequest(c, "invalid consignment ID")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
// Pointer so an omitted field is never read as "not received".
|
||||
Received *bool `json:"received"`
|
||||
Remarks string `json:"remarks"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
if req.Received == nil {
|
||||
return utils.BadRequest(c, "received is required (true = the parcel is physically here, false = it never arrived)")
|
||||
}
|
||||
|
||||
var consignment models.Consignment
|
||||
if err := db.DB.Where("consignmentid = ? AND deletedat IS NULL", consignmentID).
|
||||
First(&consignment).Error; err != nil {
|
||||
return utils.NotFound(c, "consignment not found")
|
||||
}
|
||||
if !canHubStaffAccessConsignment(c, &consignment) {
|
||||
return utils.NotFound(c, "consignment not found")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if !*req.Received {
|
||||
description := strings.TrimSpace(req.Remarks)
|
||||
if description == "" {
|
||||
description = "Rider recorded a handover at this base but the parcel was not physically received."
|
||||
}
|
||||
exception := models.ConsignmentException{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: &hubID,
|
||||
// Lost is the closest type the consignmentexceptions CHECK constraint
|
||||
// already allows, and it is honest: a parcel recorded as handed over
|
||||
// that nobody can find is lost until it turns up. A dedicated
|
||||
// Handover_Not_Received type would need that constraint widened first.
|
||||
Exceptiontype: constants.ExceptionLost,
|
||||
Severity: "High",
|
||||
Description: fmt.Sprintf("%s (reported by hub staff account %d)", description, staffAccountID),
|
||||
Status: constants.ExceptionOpen,
|
||||
Createdby: staffAccountID,
|
||||
}
|
||||
if err := db.DB.Create(&exception).Error; err != nil {
|
||||
return utils.Internal(c, "failed to raise handover exception")
|
||||
}
|
||||
db.DB.Create(&models.ConsignmentHistory{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: &hubID,
|
||||
Eventstatus: consignment.Status,
|
||||
Remarks: fmt.Sprintf("Handover disputed at base by hub staff account %d: %s",
|
||||
staffAccountID, description),
|
||||
})
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"consignmentstatus": consignment.Status,
|
||||
"received": false,
|
||||
"exceptionid": exception.Exceptionid,
|
||||
"exceptiontype": exception.Exceptiontype,
|
||||
})
|
||||
}
|
||||
|
||||
alreadyInwarded := consignment.Status == constants.ConsignmentInwardedAtHub
|
||||
if !alreadyInwarded {
|
||||
consignment.Status = constants.ConsignmentInwardedAtHub
|
||||
consignment.Currenthubid = &hubID
|
||||
if consignment.Originhubid == nil {
|
||||
consignment.Originhubid = &hubID
|
||||
}
|
||||
consignment.Updatedat = now
|
||||
consignment.Updatedby = staffAccountID
|
||||
}
|
||||
if consignment.Inwardedat == nil {
|
||||
consignment.Inwardedat = &now
|
||||
}
|
||||
if err := db.DB.Save(&consignment).Error; err != nil {
|
||||
return utils.Internal(c, "failed to record receipt")
|
||||
}
|
||||
|
||||
if !alreadyInwarded {
|
||||
remarks := strings.TrimSpace(req.Remarks)
|
||||
if remarks == "" {
|
||||
remarks = "Physical receipt confirmed at base"
|
||||
}
|
||||
db.DB.Create(&models.ConsignmentHistory{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: &hubID,
|
||||
Eventstatus: constants.ConsignmentInwardedAtHub,
|
||||
Remarks: fmt.Sprintf("%s (hub staff account %d)", remarks, staffAccountID),
|
||||
})
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"consignmentstatus": consignment.Status,
|
||||
"inwardedat": consignment.Inwardedat,
|
||||
"received": true,
|
||||
"already_received": alreadyInwarded,
|
||||
})
|
||||
}
|
||||
534
controllers/logisticsHandoverController.go
Normal file
534
controllers/logisticsHandoverController.go
Normal file
@@ -0,0 +1,534 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// --------------------
|
||||
// BASE / HUB HANDOVER — the logistics next-leg surface
|
||||
//
|
||||
// Vocabulary note, because two words are in play for one thing: the wire says
|
||||
// hub (inward_at_hub, Inwarded_at_Hub, next_hub, pickup_source_type "hub") and
|
||||
// the rider app renders that as Base. Nothing here changes a wire value to suit
|
||||
// the app's wording, and nothing in the app's wording should leak back in here.
|
||||
// --------------------
|
||||
|
||||
// hubHandoverEnabled gates the two-step hub flow: a hub-routed parcel stops at
|
||||
// Created — collected, in the rider's hands, on its way to a base — and only
|
||||
// reaches Inwarded_at_Hub when the handover is actually recorded, by the rider
|
||||
// (POST /miler/consignments/:id/inward-at-hub) or by base staff (the console
|
||||
// inbound scan).
|
||||
//
|
||||
// Default OFF, and it must stay off until a rider-app build that calls the
|
||||
// handover endpoint is live. With it off, pickup-complete keeps marking a
|
||||
// hub-routed parcel Inwarded_at_Hub the instant it is collected — which is not
|
||||
// true of where the parcel physically is, but is what the current app and the
|
||||
// console's inbound views expect. Flipping it early would leave every intercity
|
||||
// parcel sitting on Created with no button in the rider's app to advance it and
|
||||
// no row in the base's inbound list.
|
||||
//
|
||||
// Read at request time (env MILER_HUB_HANDOVER_ENABLED=true) so it can be turned
|
||||
// on without a redeploy, same as MILER_COLLECTED_STATE_ENABLED. Everything else
|
||||
// in this file — next_hub, the handover endpoint itself, next_action on the
|
||||
// queue read, base master data, inbound visibility — is ungated and safe for the
|
||||
// current app.
|
||||
func hubHandoverEnabled() bool {
|
||||
return strings.EqualFold(os.Getenv("MILER_HUB_HANDOVER_ENABLED"), "true")
|
||||
}
|
||||
|
||||
// renderBase is the one shape a base is ever returned in, so pickup-complete,
|
||||
// the booking rows, the handover response and GET /miler/bases cannot drift
|
||||
// apart. All six fields every time: the id keys the handover, the name is the
|
||||
// heading the rider reads, address and pincode are what they read at the gate,
|
||||
// and the coordinates are the only thing that can drive Navigate. Five of six
|
||||
// still leaves a rider unable to get there.
|
||||
func renderBase(hub *models.Hub) fiber.Map {
|
||||
if hub == nil {
|
||||
return nil
|
||||
}
|
||||
return fiber.Map{
|
||||
"id": hub.Hubid,
|
||||
"name": hub.Hubname,
|
||||
"address": hub.Address,
|
||||
"pincode": hub.Pincode,
|
||||
"latitude": hub.Latitude,
|
||||
"longitude": hub.Longitude,
|
||||
}
|
||||
}
|
||||
|
||||
// loadHub reads one base by id, ignoring soft-deleted rows. Returns nil rather
|
||||
// than an error for a missing id so callers can treat "no base" and "unknown
|
||||
// base" the same way where that is the right call.
|
||||
func loadHub(hubID *int) *models.Hub {
|
||||
if hubID == nil || *hubID == 0 {
|
||||
return nil
|
||||
}
|
||||
var hub models.Hub
|
||||
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", *hubID).First(&hub).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return &hub
|
||||
}
|
||||
|
||||
// nearestActiveHub finds the closest active base to a point. Used only as a last
|
||||
// resort when neither the booking nor the rider names one — a parcel with
|
||||
// nowhere to go is worse than a parcel sent to the nearest gate. Returns nil
|
||||
// when no active base has usable coordinates.
|
||||
func nearestActiveHub(lat, lon float64) *models.Hub {
|
||||
if lat == 0 && lon == 0 {
|
||||
return nil
|
||||
}
|
||||
var hubs []models.Hub
|
||||
if err := db.DB.Where("deletedat IS NULL AND status = ?", "Active").Find(&hubs).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
var best *models.Hub
|
||||
bestKM := 0.0
|
||||
for i := range hubs {
|
||||
h := &hubs[i]
|
||||
if h.Latitude == 0 && h.Longitude == 0 {
|
||||
continue
|
||||
}
|
||||
d := haversineKM(lat, lon, h.Latitude, h.Longitude)
|
||||
if best == nil || d < bestKM {
|
||||
best, bestKM = h, d
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// resolveHandoverHub decides which base a hub-routed parcel is carried to. The
|
||||
// decision is the backend's, never the app's — the app is told where to go and
|
||||
// navigates there.
|
||||
//
|
||||
// Order, most authoritative first:
|
||||
// 1. the base the booking was routed to (nearesthubid), when the console or the
|
||||
// dispatch layer set one. Nothing populates this column today; it is checked
|
||||
// first so that the moment something does, it wins without another change here.
|
||||
// 2. the collecting rider's own base — the operational default: a rider brings
|
||||
// the parcel back to where they work out of.
|
||||
// 3. the active base nearest the pickup point, for a rider with no base set.
|
||||
// 4. any base at all, so a parcel is never left with nowhere to go.
|
||||
func resolveHandoverHub(booking *models.PickupBooking, riderHubID *int) *models.Hub {
|
||||
if hub := loadHub(booking.Nearesthubid); hub != nil {
|
||||
return warnIfUnnavigable(hub)
|
||||
}
|
||||
if hub := loadHub(riderHubID); hub != nil {
|
||||
return warnIfUnnavigable(hub)
|
||||
}
|
||||
if hub := nearestActiveHub(booking.Pickuplatitude, booking.Pickuplongitude); hub != nil {
|
||||
return hub
|
||||
}
|
||||
var hub models.Hub
|
||||
if db.DB.Where("deletedat IS NULL").Order("hubid").First(&hub).Error == nil {
|
||||
return warnIfUnnavigable(&hub)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// warnIfUnnavigable flags a base the rider cannot actually be routed to. The
|
||||
// correct base is still returned — sending a rider to a different base because
|
||||
// this one has bad master data would be worse than sending them to the right one
|
||||
// with a missing pin. It is a data problem, and it needs to be visible as one.
|
||||
func warnIfUnnavigable(hub *models.Hub) *models.Hub {
|
||||
if hub.Latitude == 0 && hub.Longitude == 0 {
|
||||
utils.Warn("base has no coordinates — Navigate will not work for riders sent here",
|
||||
"hubid", hub.Hubid, "hubname", hub.Hubname)
|
||||
}
|
||||
if strings.TrimSpace(hub.Address) == "" {
|
||||
utils.Warn("base has no address — the rider has nothing to read at the gate",
|
||||
"hubid", hub.Hubid, "hubname", hub.Hubname)
|
||||
}
|
||||
return hub
|
||||
}
|
||||
|
||||
// derivePickupSourceType classifies where a booking is collected from for rows
|
||||
// written before pickupsourcetype existed, and as a safety net for any writer
|
||||
// that forgets to set it. A stored value always wins — this only fills a blank.
|
||||
//
|
||||
// A base-origin booking names a base; a client-site pickup names a tenant
|
||||
// location (a kitchen, branch or depot — "merchant"); everything else is a
|
||||
// person's door. "customer" is the honest answer for the last case and is
|
||||
// returned as a value, never as an omission.
|
||||
func derivePickupSourceType(b *models.PickupBooking) string {
|
||||
if b.Pickupsourcetype != "" {
|
||||
return b.Pickupsourcetype
|
||||
}
|
||||
if b.Pickuphubid != nil {
|
||||
return constants.PickupSourceHub
|
||||
}
|
||||
if b.Tenantlocationid != nil {
|
||||
return constants.PickupSourceMerchant
|
||||
}
|
||||
return constants.PickupSourceCustomer
|
||||
}
|
||||
|
||||
// pickupSource resolves the source-type, id, name and address the rider app puts
|
||||
// at the top of a pickup stop. customerName is the booking's customer, used for
|
||||
// the door-pickup case so a collection at a house is titled with the sender's
|
||||
// name rather than the rider's own base name.
|
||||
//
|
||||
// sourceID is nil for a customer pickup — there is no configured location and
|
||||
// inventing one would be a lie. That is precisely why pickup_source_type is
|
||||
// carried on the row: the app can then tell "no id because it is a front door"
|
||||
// from "no id because nobody filled it in".
|
||||
func pickupSource(b *models.PickupBooking, customerName string) (sourceType string, sourceID *int, name, address string) {
|
||||
sourceType = derivePickupSourceType(b)
|
||||
address = b.Pickupaddress
|
||||
|
||||
switch sourceType {
|
||||
case constants.PickupSourceHub:
|
||||
sourceID = b.Pickuphubid
|
||||
if hub := loadHub(b.Pickuphubid); hub != nil {
|
||||
name = hub.Hubname
|
||||
if address == "" {
|
||||
address = hub.Address
|
||||
}
|
||||
}
|
||||
case constants.PickupSourceMerchant, constants.PickupSourceStore:
|
||||
sourceID = b.Tenantlocationid
|
||||
if b.Tenantlocationid != nil {
|
||||
var loc models.TenantLocation
|
||||
if db.DB.Where("tenantlocationid = ?", *b.Tenantlocationid).First(&loc).Error == nil {
|
||||
name = loc.Locationname
|
||||
if address == "" {
|
||||
address = loc.Address
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Customer door: the sender's own name and the address on the booking.
|
||||
name = strings.TrimSpace(customerName)
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(b.Providerlocation)
|
||||
}
|
||||
return sourceType, sourceID, name, address
|
||||
}
|
||||
|
||||
// nextActionForConsignment maps a consignment's state to what the rider does
|
||||
// next with it. This is the single definition — pickup-complete and the queue
|
||||
// read both call it, so a poll can never disagree with the answer the pivot
|
||||
// gave. Anything terminal returns "none" so a finished parcel retires from the
|
||||
// rider's screen instead of lingering.
|
||||
func nextActionForConsignment(status string) string {
|
||||
switch status {
|
||||
case constants.ConsignmentCreated:
|
||||
// Collected and still in the rider's hands, routed to a base: carry it
|
||||
// there and hand it over. Under the compatibility flow a hub-routed
|
||||
// parcel never sits here — it is already Inwarded_at_Hub.
|
||||
return constants.NextActionInwardAtHub
|
||||
case constants.ConsignmentCollectedByMiler:
|
||||
return constants.NextActionStartDelivery
|
||||
case constants.ConsignmentOutForDelivery:
|
||||
return constants.NextActionDeliver
|
||||
case constants.ConsignmentInwardedAtHub:
|
||||
return constants.NextActionHandedToHub
|
||||
default:
|
||||
// Tripsheet_Loaded, In_Transit, Delivered, RTO, Returned, Missing,
|
||||
// Damaged — all past this rider's leg.
|
||||
return constants.NextActionNone
|
||||
}
|
||||
}
|
||||
|
||||
// nextHubForConsignment names the base a parcel is on its way to, for a
|
||||
// consignment still in a rider's hands. A parcel that has already been inwarded
|
||||
// has no next base — it is at one.
|
||||
func nextHubForConsignment(cn *models.Consignment) fiber.Map {
|
||||
if cn == nil || cn.Status != constants.ConsignmentCreated {
|
||||
return nil
|
||||
}
|
||||
return renderBase(loadHub(cn.Currenthubid))
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// GET /miler/bases — base master data on a rider token
|
||||
//
|
||||
// The rider app could previously only see GET /admin/tenants/:id/locations,
|
||||
// which is a different dataset entirely (a client's own sites) and is closed to
|
||||
// a miler token anyway: /admin/* requires roles 1/3/4 and a rider is role 5, so
|
||||
// that route answers 401 for them by design, not by oversight.
|
||||
// --------------------
|
||||
|
||||
func MilerGetBases(c *fiber.Ctx) error {
|
||||
query := db.DB.Where("deletedat IS NULL")
|
||||
if status := c.Query("status"); status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
} else {
|
||||
query = query.Where("status = ?", "Active")
|
||||
}
|
||||
if appLocationID := c.Query("applocationid"); appLocationID != "" {
|
||||
query = query.Where("applocationid = ?", appLocationID)
|
||||
}
|
||||
|
||||
var hubs []models.Hub
|
||||
if err := query.Find(&hubs).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch bases")
|
||||
}
|
||||
|
||||
// Ordered nearest-first from wherever the rider last reported being, so the
|
||||
// base they are most likely to want is at the top. Falls back to id order
|
||||
// when the rider has no position yet.
|
||||
milerUserID := c.Locals("userid").(int)
|
||||
var profile models.MilerProfile
|
||||
hasPos := db.DB.Where("userid = ?", milerUserID).First(&profile).Error == nil &&
|
||||
(profile.Currentlatitude != 0 || profile.Currentlongitude != 0)
|
||||
|
||||
rows := make([]fiber.Map, 0, len(hubs))
|
||||
for i := range hubs {
|
||||
row := renderBase(&hubs[i])
|
||||
if hasPos && (hubs[i].Latitude != 0 || hubs[i].Longitude != 0) {
|
||||
row["distance_km"] = haversineKM(profile.Currentlatitude, profile.Currentlongitude,
|
||||
hubs[i].Latitude, hubs[i].Longitude)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if hasPos {
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
di, oki := rows[i]["distance_km"].(float64)
|
||||
dj, okj := rows[j]["distance_km"].(float64)
|
||||
switch {
|
||||
case oki && okj:
|
||||
return di < dj
|
||||
case oki:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return utils.List(c, rows, int64(len(rows)))
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// POST /miler/consignments/:id/inward-at-hub — the rider handover
|
||||
//
|
||||
// The authoritative record that a rider physically handed a parcel in at a base.
|
||||
// Idempotent (retries at a loading bay with bad signal are normal, and the route
|
||||
// also carries the shared Idempotency-Key middleware), and it answers with the
|
||||
// resulting state rather than a bare 200 — every lifecycle transition the app
|
||||
// makes is checked against the state that comes back.
|
||||
// --------------------
|
||||
|
||||
func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
|
||||
milerUserID := c.Locals("userid").(int)
|
||||
consignmentID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "invalid consignment ID")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
HubID *int `json:"hub_id"`
|
||||
// hubid accepted as an alias: the same value has been spelled both ways
|
||||
// across this API's history and a handover is not worth failing over a
|
||||
// missing underscore.
|
||||
HubIDAlt *int `json:"hubid"`
|
||||
Latitude *float64 `json:"latitude"`
|
||||
Longitude *float64 `json:"longitude"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lon *float64 `json:"lon"`
|
||||
}
|
||||
// A body is optional — a rider handing a parcel into the base it is already
|
||||
// routed to needs to send nothing at all.
|
||||
_ = c.BodyParser(&req)
|
||||
|
||||
consignment, code, err := milerConsignmentForRider(milerUserID, consignmentID)
|
||||
if err != nil {
|
||||
if code == constants.ErrConsignmentNotFound {
|
||||
return utils.Fail(c, fiber.StatusNotFound, code, "consignment not found")
|
||||
}
|
||||
return utils.Fail(c, fiber.StatusForbidden, code, "this consignment is not assigned to you")
|
||||
}
|
||||
|
||||
hubID := req.HubID
|
||||
if hubID == nil {
|
||||
hubID = req.HubIDAlt
|
||||
}
|
||||
if hubID == nil {
|
||||
// Nothing named: hand it into the base it was routed to.
|
||||
hubID = consignment.Currenthubid
|
||||
}
|
||||
if hubID == nil {
|
||||
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrHubRequired,
|
||||
"hub_id is required — this consignment is not routed to a base")
|
||||
}
|
||||
hub := loadHub(hubID)
|
||||
if hub == nil {
|
||||
return utils.Fail(c, fiber.StatusNotFound, constants.ErrHubNotFound, "hub_id does not match a known base")
|
||||
}
|
||||
|
||||
// Already inwarded: answer with the state that stands rather than failing, so
|
||||
// a retry after a dropped response confirms rather than errors. This is also
|
||||
// what a rider on the compatibility flow hits every time — there,
|
||||
// pickup-complete already marked the parcel Inwarded_at_Hub.
|
||||
if consignment.Status == constants.ConsignmentInwardedAtHub {
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"consignmentstatus": consignment.Status,
|
||||
"inwardedat": consignment.Inwardedat,
|
||||
"hub": renderBase(loadHub(consignment.Currenthubid)),
|
||||
"next_action": nextActionForConsignment(consignment.Status),
|
||||
"already_inwarded": true,
|
||||
})
|
||||
}
|
||||
|
||||
// Only a parcel actually in this rider's hands can be handed over. A parcel
|
||||
// already out for delivery has to be delivered or skipped; a delivered or
|
||||
// returned one is past this leg entirely.
|
||||
if consignment.Status != constants.ConsignmentCreated &&
|
||||
consignment.Status != constants.ConsignmentCollectedByMiler {
|
||||
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
|
||||
fmt.Sprintf("consignment is %s — it cannot be handed over at a base from this state", consignment.Status))
|
||||
}
|
||||
|
||||
lat, lon := 0.0, 0.0
|
||||
if req.Latitude != nil {
|
||||
lat = *req.Latitude
|
||||
} else if req.Lat != nil {
|
||||
lat = *req.Lat
|
||||
}
|
||||
if req.Longitude != nil {
|
||||
lon = *req.Longitude
|
||||
} else if req.Lon != nil {
|
||||
lon = *req.Lon
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
tx := db.DB.Begin()
|
||||
|
||||
consignment.Status = constants.ConsignmentInwardedAtHub
|
||||
consignment.Currenthubid = &hub.Hubid
|
||||
if consignment.Originhubid == nil {
|
||||
consignment.Originhubid = &hub.Hubid
|
||||
}
|
||||
consignment.Inwardedat = &now
|
||||
consignment.Updatedat = now
|
||||
consignment.Updatedby = milerUserID
|
||||
if err := tx.Save(consignment).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to record the handover")
|
||||
}
|
||||
|
||||
history := models.ConsignmentHistory{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: &hub.Hubid,
|
||||
Userid: &milerUserID,
|
||||
Eventstatus: constants.ConsignmentInwardedAtHub,
|
||||
Remarks: fmt.Sprintf("Rider handed parcel in at %s (%.5f, %.5f)",
|
||||
hub.Hubname, lat, lon),
|
||||
}
|
||||
if err := tx.Create(&history).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to record handover history")
|
||||
}
|
||||
|
||||
// The rider's leg ends here, so the assignment closes and they return to the
|
||||
// pool. riderkms is the distance actually ridden on this leg — pickup point to
|
||||
// the base gate — and ridercharges the order amount, both written the same way
|
||||
// MilerDeliverConsignment writes them for a final-mile leg. Without this an
|
||||
// intercity rider's every job reported zero distance and zero value.
|
||||
// Resolved through bookingdestinations, not through
|
||||
// pickupbookings.consignmentid. That column names only the FIRST order of a
|
||||
// multi-destination pickup, so joining on it found nothing for orders 2..N
|
||||
// — and an intercity rider handing in the second parcel of a three-stop
|
||||
// pickup had their assignment left open and their distance recorded as zero.
|
||||
if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil {
|
||||
booking := *bookingPtr
|
||||
dropLat, dropLon := lat, lon
|
||||
if dropLat == 0 && dropLon == 0 {
|
||||
dropLat, dropLon = hub.Latitude, hub.Longitude
|
||||
}
|
||||
riderKms := haversineKM(consignment.Pickuplatitude, consignment.Pickuplongitude, dropLat, dropLon)
|
||||
|
||||
orderAmount := 0.0
|
||||
var serviceOpt models.BookingServiceOption
|
||||
if tx.Where("bookingid = ?", booking.Bookingid).Order("createdat DESC").
|
||||
First(&serviceOpt).Error == nil {
|
||||
orderAmount = serviceOpt.Estimatedprice
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.BookingAssignment{}).
|
||||
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
|
||||
booking.Bookingid, milerUserID,
|
||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||
Updates(map[string]interface{}{
|
||||
"assignmentstatus": constants.AssignmentCompleted,
|
||||
"completedat": now,
|
||||
"riderkms": riderKms,
|
||||
"ridercharges": orderAmount,
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to close assignment")
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
||||
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to update miler availability")
|
||||
}
|
||||
|
||||
// The customer's "In transit" milestone. Recorded against THIS order, not
|
||||
// the booking, because the other parcels from the same visit may still be
|
||||
// in the rider's hands.
|
||||
notifyInTransit, err := recordCxConsignmentStage(tx, consignment.Consignmentid,
|
||||
constants.ConsignmentInwardedAtHub, constants.CxActorMiler, &milerUserID,
|
||||
"POST /miler/consignments/{id}/inward-at-hub")
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("MilerInwardConsignmentAtHub: could not record in_transit",
|
||||
"consignment_id", consignment.Consignmentid, "error", err)
|
||||
return utils.Internal(c, "failed to record the handover")
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to record the handover")
|
||||
}
|
||||
|
||||
notifyInTransit()
|
||||
|
||||
// Best-effort, on an already-bound subject — a dropped event must never fail
|
||||
// a handover the rider has physically completed.
|
||||
if db.Js != nil {
|
||||
payload := map[string]interface{}{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"status": constants.ConsignmentInwardedAtHub,
|
||||
"hubid": hub.Hubid,
|
||||
"mileruserid": milerUserID,
|
||||
"inwardedat": now.UnixMilli(),
|
||||
}
|
||||
if data, err := json.Marshal(payload); err == nil {
|
||||
if _, err := db.Js.Publish("booking.status.updated", data); err != nil {
|
||||
utils.Warn("MilerInwardConsignmentAtHub: NATS publish failed",
|
||||
"consignment_id", consignment.Consignmentid, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"consignmentid": consignment.Consignmentid,
|
||||
"trackingno": consignment.Trackingno,
|
||||
"consignmentstatus": consignment.Status,
|
||||
"inwardedat": consignment.Inwardedat,
|
||||
"hub": renderBase(hub),
|
||||
"next_action": nextActionForConsignment(consignment.Status),
|
||||
"already_inwarded": false,
|
||||
})
|
||||
}
|
||||
149
controllers/logisticsHandover_test.go
Normal file
149
controllers/logisticsHandover_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/models"
|
||||
)
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func TestNextActionForConsignment(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// The whole point of request 27: a hub-routed parcel sits on Created
|
||||
// while it is being carried to a base, and the app must be able to
|
||||
// rebuild that leg from server state after a restart.
|
||||
name: "collected and routed to a base means carry it there",
|
||||
status: constants.ConsignmentCreated,
|
||||
want: constants.NextActionInwardAtHub,
|
||||
},
|
||||
{
|
||||
name: "collected hyperlocal parcel waits for start-delivery",
|
||||
status: constants.ConsignmentCollectedByMiler,
|
||||
want: constants.NextActionStartDelivery,
|
||||
},
|
||||
{
|
||||
name: "out for delivery means deliver",
|
||||
status: constants.ConsignmentOutForDelivery,
|
||||
want: constants.NextActionDeliver,
|
||||
},
|
||||
{
|
||||
name: "already handed in at a base leaves the rider nothing to do",
|
||||
status: constants.ConsignmentInwardedAtHub,
|
||||
want: constants.NextActionHandedToHub,
|
||||
},
|
||||
{
|
||||
name: "delivered is past this rider's leg",
|
||||
status: constants.ConsignmentDelivered,
|
||||
want: constants.NextActionNone,
|
||||
},
|
||||
{
|
||||
name: "in transit between bases is not a rider action",
|
||||
status: constants.ConsignmentInTransit,
|
||||
want: constants.NextActionNone,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := nextActionForConsignment(tc.status); got != tc.want {
|
||||
t.Errorf("nextActionForConsignment(%q) = %q, want %q", tc.status, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePickupSourceType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
booking models.PickupBooking
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "a stored type always wins",
|
||||
booking: models.PickupBooking{Pickupsourcetype: constants.PickupSourceStore, Tenantlocationid: intPtr(7)},
|
||||
want: constants.PickupSourceStore,
|
||||
},
|
||||
{
|
||||
name: "a booking naming a base is a base pickup",
|
||||
booking: models.PickupBooking{Pickuphubid: intPtr(1)},
|
||||
want: constants.PickupSourceHub,
|
||||
},
|
||||
{
|
||||
name: "a booking naming a client site is a merchant pickup",
|
||||
booking: models.PickupBooking{Tenantlocationid: intPtr(7)},
|
||||
want: constants.PickupSourceMerchant,
|
||||
},
|
||||
{
|
||||
// The case the whole column exists for: a front-door pickup has no
|
||||
// location id, and "customer" must be a value rather than a blank.
|
||||
name: "a booking naming no location at all is a customer door",
|
||||
booking: models.PickupBooking{Pickupaddress: "12 Race Course Road"},
|
||||
want: constants.PickupSourceCustomer,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := derivePickupSourceType(&tc.booking); got != tc.want {
|
||||
t.Errorf("derivePickupSourceType() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBaseCarriesAllSixFields(t *testing.T) {
|
||||
// Five of six leaves a rider unable to get there: the id keys the handover,
|
||||
// the name is the heading, address and pincode are read at the gate, and the
|
||||
// coordinates are the only thing that can drive Navigate.
|
||||
hub := models.Hub{
|
||||
Hubid: 1,
|
||||
Hubname: "Coimbatore Hub",
|
||||
Address: "14 Avinashi Road, Peelamedu, Coimbatore",
|
||||
Pincode: "641004",
|
||||
Latitude: 11.0272,
|
||||
Longitude: 76.9905,
|
||||
}
|
||||
|
||||
got := renderBase(&hub)
|
||||
for _, field := range []string{"id", "name", "address", "pincode", "latitude", "longitude"} {
|
||||
if _, ok := got[field]; !ok {
|
||||
t.Errorf("renderBase() is missing %q", field)
|
||||
}
|
||||
}
|
||||
if len(got) != 6 {
|
||||
t.Errorf("renderBase() returned %d fields, want exactly 6: %v", len(got), got)
|
||||
}
|
||||
|
||||
if renderBase(nil) != nil {
|
||||
t.Error("renderBase(nil) should be nil, so an unresolved base is absent rather than empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubHandoverEnabled(t *testing.T) {
|
||||
// Default OFF matters operationally: turning it on before a rider-app build
|
||||
// that can hand a parcel over would strand every intercity parcel on Created
|
||||
// with no button to advance it.
|
||||
t.Setenv("MILER_HUB_HANDOVER_ENABLED", "")
|
||||
os.Unsetenv("MILER_HUB_HANDOVER_ENABLED")
|
||||
if hubHandoverEnabled() {
|
||||
t.Error("hub handover must default to off when the env var is unset")
|
||||
}
|
||||
|
||||
t.Setenv("MILER_HUB_HANDOVER_ENABLED", "TRUE")
|
||||
if !hubHandoverEnabled() {
|
||||
t.Error("hub handover should be on for TRUE, matching the case-insensitive read used elsewhere")
|
||||
}
|
||||
|
||||
t.Setenv("MILER_HUB_HANDOVER_ENABLED", "1")
|
||||
if hubHandoverEnabled() {
|
||||
t.Error(`only "true" turns the flow on — "1" must not`)
|
||||
}
|
||||
}
|
||||
137
controllers/logisticsRouting_test.go
Normal file
137
controllers/logisticsRouting_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"doormile/constants"
|
||||
)
|
||||
|
||||
// The bulk-upload test sheet, checked against the rule that actually decides.
|
||||
//
|
||||
// krow_talent_app/tests/fixtures/doormile-logistics-test.xlsx carries 16 rows
|
||||
// and an "Expected Routing" column saying, in words, what each one should do.
|
||||
// That column is only a claim until something checks it, and the thing that
|
||||
// decides is here, in Go — so it is checked here rather than restated in a
|
||||
// JavaScript test, which would only prove that two copies of the rule agree
|
||||
// with each other.
|
||||
//
|
||||
// If a row of the sheet is edited, this table is what says whether the sheet is
|
||||
// still testing what it says it tests.
|
||||
//
|
||||
// Pickup is the sheet's single sender: Jayanthi's kitchen, Edayarpalayam,
|
||||
// Coimbatore 641025.
|
||||
const (
|
||||
sheetPickupPincode = "641025"
|
||||
sheetPickupLat = 11.0168
|
||||
sheetPickupLng = 76.9558
|
||||
)
|
||||
|
||||
func TestBulkTestSheetRoutesAsDocumented(t *testing.T) {
|
||||
cases := []struct {
|
||||
row int
|
||||
receiver string
|
||||
pincode string
|
||||
lat, lng float64
|
||||
wantLocal bool
|
||||
wantAction string
|
||||
wantConsStat string // with the hub-handover flow ON
|
||||
why string
|
||||
}{
|
||||
// Customer -> Base. A different postal area, so the parcel cannot be
|
||||
// carried to the receiver by the collecting rider.
|
||||
{1, "Suresh Kumar", "600001", 13.091, 80.285, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "641 -> 600, Chennai"},
|
||||
{2, "Priya Raghavan", "600028", 13.018, 80.256, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "641 -> 600, Chennai"},
|
||||
{3, "Anil Reddy", "500081", 17.44, 78.3489, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate, Hyderabad"},
|
||||
{4, "Meera Krishnan", "500032", 17.4156, 78.3378, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate with COD"},
|
||||
{5, "Rahul Menon", "560001", 12.975, 77.606, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate, Bengaluru"},
|
||||
{6, "Divya Nair", "560066", 12.9698, 77.75, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate with COD"},
|
||||
{7, "Karthik Subramani", "625001", 9.9195, 78.119, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "same state, different area"},
|
||||
{8, "Lakshmi Devi", "636001", 11.664, 78.146, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "same state, different area"},
|
||||
|
||||
// Customer -> Customer. The control group: if any of these routes to a
|
||||
// base, the prefix rule has broken.
|
||||
{9, "Ganesh Iyer", "641004", 11.029, 76.993, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area"},
|
||||
{10, "Revathi Balaji", "641012", 11.018, 76.966, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area, COD"},
|
||||
{11, "Vignesh Murugan", "641025", 11.008, 76.928, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "identical pincode"},
|
||||
{12, "Anitha Selvam", "641038", 11.023, 76.945, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area"},
|
||||
|
||||
// No pincode: the decision falls to straight-line distance. Both
|
||||
// outcomes are present, because a fallback that only ever answers one
|
||||
// way is not being tested.
|
||||
{13, "Mohan Das", "", 11.05, 77.01, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "~8km, inside the 30km fallback"},
|
||||
{14, "Sridhar Venkat", "", 13.0827, 80.2707, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "~430km, far outside the fallback"},
|
||||
{15, "Bhavani Shankar", "64", 13.06, 80.24, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "2-digit pincode is unusable, distance decides"},
|
||||
|
||||
// The row that proves the prefix rule outranks distance.
|
||||
{16, "Ramesh Palanisamy", "642001", 10.658, 77.008, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "~40km but 642 is a different area"},
|
||||
}
|
||||
|
||||
if len(cases) != 16 {
|
||||
t.Fatalf("the sheet has 16 rows, this table has %d — they must not drift apart", len(cases))
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.receiver, func(t *testing.T) {
|
||||
gotLocal := isHyperlocalBooking(
|
||||
sheetPickupPincode, tc.pincode,
|
||||
sheetPickupLat, sheetPickupLng,
|
||||
tc.lat, tc.lng,
|
||||
)
|
||||
if gotLocal != tc.wantLocal {
|
||||
t.Fatalf("row %d (%s): hyperlocal = %v, want %v — %s",
|
||||
tc.row, tc.receiver, gotLocal, tc.wantLocal, tc.why)
|
||||
}
|
||||
|
||||
// What the rider is actually told to do with it, from the same
|
||||
// helper pickup-complete and the queue read both use.
|
||||
status := constants.ConsignmentCreated
|
||||
if gotLocal {
|
||||
status = constants.ConsignmentOutForDelivery
|
||||
}
|
||||
if status != tc.wantConsStat {
|
||||
t.Errorf("row %d: consignment state %s, want %s", tc.row, status, tc.wantConsStat)
|
||||
}
|
||||
if got := nextActionForConsignment(status); got != tc.wantAction {
|
||||
t.Errorf("row %d: next_action %s, want %s", tc.row, got, tc.wantAction)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The sheet is only worth uploading if it actually splits both ways. A file
|
||||
// that turned out to be all-hyperlocal would pass every assertion above and
|
||||
// still test nothing — which is exactly the problem with the tenant's own
|
||||
// export that this sheet was written to replace.
|
||||
func TestBulkTestSheetExercisesBothLegs(t *testing.T) {
|
||||
type dest struct {
|
||||
pincode string
|
||||
lat, lng float64
|
||||
}
|
||||
dests := []dest{
|
||||
{"600001", 13.091, 80.285}, {"600028", 13.018, 80.256},
|
||||
{"500081", 17.44, 78.3489}, {"500032", 17.4156, 78.3378},
|
||||
{"560001", 12.975, 77.606}, {"560066", 12.9698, 77.75},
|
||||
{"625001", 9.9195, 78.119}, {"636001", 11.664, 78.146},
|
||||
{"641004", 11.029, 76.993}, {"641012", 11.018, 76.966},
|
||||
{"641025", 11.008, 76.928}, {"641038", 11.023, 76.945},
|
||||
{"", 11.05, 77.01}, {"", 13.0827, 80.2707},
|
||||
{"64", 13.06, 80.24}, {"642001", 10.658, 77.008},
|
||||
}
|
||||
|
||||
var local, hub int
|
||||
for _, d := range dests {
|
||||
if isHyperlocalBooking(sheetPickupPincode, d.pincode, sheetPickupLat, sheetPickupLng, d.lat, d.lng) {
|
||||
local++
|
||||
} else {
|
||||
hub++
|
||||
}
|
||||
}
|
||||
|
||||
if hub < 5 {
|
||||
t.Errorf("only %d rows route through a base — too few to exercise the handover flow", hub)
|
||||
}
|
||||
if local < 3 {
|
||||
t.Errorf("only %d rows go direct to the customer — no control group", local)
|
||||
}
|
||||
t.Logf("sheet splits %d base-handover / %d direct-to-customer", hub, local)
|
||||
}
|
||||
@@ -254,6 +254,28 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// A customer-app pickup fans out into one consignment per destination, and
|
||||
// each of those is a SEPARATE delivery the rider has to make. Without this
|
||||
// the queue showed one row per booking keyed on pickupbookings.consignmentid
|
||||
// — which names only the first order — so on a three-destination pickup two
|
||||
// parcels would exist in the rider's bag with no stop, no deliver button and
|
||||
// no way to close them.
|
||||
//
|
||||
// Bookings with no destination rows (every console/express booking, and
|
||||
// everything written before the fan-out) are untouched: they still produce
|
||||
// exactly one row, built from the booking's own columns.
|
||||
destinationsByBooking := map[int][]models.BookingDestination{}
|
||||
if len(bookingIDs) > 0 {
|
||||
var destinations []models.BookingDestination
|
||||
db.DB.Where("bookingid IN ?", bookingIDs).Order("seq ASC").Find(&destinations)
|
||||
for _, d := range destinations {
|
||||
destinationsByBooking[d.Bookingid] = append(destinationsByBooking[d.Bookingid], d)
|
||||
if d.Consignmentid != nil {
|
||||
consignmentIDs = append(consignmentIDs, *d.Consignmentid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// step / road-optimized sequence lives on the active assignment row, written
|
||||
// by the express route optimizer. Step 0 = not sequenced (single-stop or
|
||||
// optimizer down), never a position — passed through verbatim.
|
||||
@@ -287,92 +309,157 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
|
||||
var customer models.AppCustomer
|
||||
db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer)
|
||||
|
||||
// Cash-to-collect: prefer the consignment's COD once it exists, otherwise
|
||||
// fall back to a pending Cash payment on the booking. Prepaid/UPI stays 0.
|
||||
codAmount := 0.0
|
||||
paymentMode := ""
|
||||
if b.Consignmentid != nil {
|
||||
if cn, ok := codByConsignment[*b.Consignmentid]; ok {
|
||||
paymentMode = cn.Paymentmode
|
||||
codAmount = cn.Codamount - cn.Codcollected
|
||||
if codAmount < 0 {
|
||||
codAmount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
if codAmount == 0 {
|
||||
for _, p := range b.Payments {
|
||||
if p.Paymentmode == constants.PaymentModeCash && p.Paymentstatus == constants.PaymentStatusPending {
|
||||
codAmount = p.Amount
|
||||
paymentMode = p.Paymentmode
|
||||
break
|
||||
}
|
||||
if paymentMode == "" {
|
||||
paymentMode = p.Paymentmode
|
||||
}
|
||||
}
|
||||
}
|
||||
// Where this parcel is collected FROM, and what kind of place that is, so
|
||||
// Home can title the stop correctly. Without pickup_source_type every
|
||||
// logistics pickup was grouped under the rider's own base name and a
|
||||
// collection at a shop looked identical to one at a house.
|
||||
//
|
||||
// sourceid is null for a customer door and that is a real answer, not a
|
||||
// missing one — the type is what makes the difference legible. pickuplocationid
|
||||
// mirrors sourceid: the app has used both spellings for the same thing, and
|
||||
// the booking row never carried either before now.
|
||||
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b,
|
||||
customer.Firstname+" "+customer.Lastname)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
stops := milerStopsForBooking(&b, destinationsByBooking[b.Bookingid])
|
||||
|
||||
row := fiber.Map{
|
||||
"bookingid": b.Bookingid,
|
||||
"bookingreference": b.Bookingno,
|
||||
"status": b.Status,
|
||||
"stoptype": milerStopType(b.Status),
|
||||
"consignmentid": b.Consignmentid,
|
||||
"consignmentstatus": consignmentStatus,
|
||||
"pickupaddress": b.Pickupaddress,
|
||||
"pickuplatitude": b.Pickuplatitude,
|
||||
"pickuplongitude": b.Pickuplongitude,
|
||||
"deliveryaddress": b.Deliveryaddress,
|
||||
"deliverylatitude": b.Deliverylatitude,
|
||||
"deliverylongitude": b.Deliverylongitude,
|
||||
"customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
||||
"customerphone": customer.Phone,
|
||||
// Arrival fact: the rider app derives its "Arrived" rung from
|
||||
// pickup-scheduled + a non-null reachedat, so this survives an app
|
||||
// restart without a separate booking status. Null until the rider hits
|
||||
// the reached endpoint. arrivallatitude/longitude are the GPS captured
|
||||
// at that moment (null if the app sent none).
|
||||
"reachedat": b.Arrivedat,
|
||||
"arrivallatitude": b.Arrivallatitude,
|
||||
"arrivallongitude": b.Arrivallongitude,
|
||||
"parcels": b.Parcels,
|
||||
"serviceoptions": b.ServiceOptions,
|
||||
"codamount": codAmount,
|
||||
"paymentmode": paymentMode,
|
||||
"createdat": b.Createdat,
|
||||
// Route sequencing — 0/empty when the stop was never sequenced.
|
||||
// sequencedat is the authoritative-order signal: non-null means the
|
||||
// console/optimizer fixed this stop's position and the app must follow
|
||||
// step exactly; null means no route was assigned and the app is free to
|
||||
// fall back to its own nearest-first ordering.
|
||||
"step": 0,
|
||||
"cumulativekms": 0.0,
|
||||
"etaminutes": 0,
|
||||
"cumulativeeta": 0,
|
||||
"sequencedat": nil,
|
||||
for _, stop := range stops {
|
||||
// Cash-to-collect: prefer the consignment's COD once it exists, otherwise
|
||||
// fall back to a pending Cash payment on the booking. Prepaid/UPI stays 0.
|
||||
codAmount := 0.0
|
||||
paymentMode := ""
|
||||
var consignmentStatus string
|
||||
|
||||
// next_action / next_hub: the leg this parcel is on, rebuilt from server
|
||||
// state on every poll. pickup-complete used to be the only place that ever
|
||||
// said it, so a restart mid-leg left the app with nothing authoritative to
|
||||
// read — consignment status alone cannot separate a hub-routed parcel from
|
||||
// a freshly-collected hyperlocal one, since both can sit on Created.
|
||||
nextAction := constants.NextActionPickup
|
||||
var nextHub fiber.Map
|
||||
|
||||
if stop.consignmentID != nil {
|
||||
if cn, ok := codByConsignment[*stop.consignmentID]; ok {
|
||||
consignmentStatus = cn.Status
|
||||
paymentMode = cn.Paymentmode
|
||||
codAmount = cn.Codamount - cn.Codcollected
|
||||
if codAmount < 0 {
|
||||
codAmount = 0
|
||||
}
|
||||
nextAction = nextActionForConsignment(cn.Status)
|
||||
nextHub = nextHubForConsignment(&cn)
|
||||
} else {
|
||||
// The consignment row did not load. Fall back to what this
|
||||
// destination asked for rather than to zero — a rider shown
|
||||
// ₹0 collects nothing, and the customer's money is the one
|
||||
// thing that must not silently vanish from a stop.
|
||||
codAmount = stop.codAmount
|
||||
}
|
||||
} else if stop.codAmount > 0 {
|
||||
// Not yet collected: the collection is what the customer
|
||||
// requested for this door.
|
||||
codAmount = stop.codAmount
|
||||
}
|
||||
if b.Status == constants.BookingCancelled {
|
||||
nextAction = constants.NextActionNone
|
||||
}
|
||||
|
||||
// The pickup fee is charged once for the visit, so it is only offered
|
||||
// against the first stop — asking a rider to collect it again at the
|
||||
// second parcel would double-charge the customer.
|
||||
if codAmount == 0 && stop.seq == 0 {
|
||||
for _, p := range b.Payments {
|
||||
if p.Paymentmode == constants.PaymentModeCash && p.Paymentstatus == constants.PaymentStatusPending {
|
||||
codAmount = p.Amount
|
||||
paymentMode = p.Paymentmode
|
||||
break
|
||||
}
|
||||
if paymentMode == "" {
|
||||
paymentMode = p.Paymentmode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
row := fiber.Map{
|
||||
"bookingid": b.Bookingid,
|
||||
"bookingreference": b.Bookingno,
|
||||
"status": b.Status,
|
||||
"stoptype": milerStopType(b.Status),
|
||||
// consignmentid/consignmentstatus so the app can call deliver, skip and
|
||||
// start-delivery straight from the list without a per-order lookup.
|
||||
"consignmentid": stop.consignmentID,
|
||||
"consignmentstatus": consignmentStatus,
|
||||
"trackingno": stop.trackingNo,
|
||||
// Which of the booking's destinations this stop is, and how many
|
||||
// there are in total, so the app can say "Stop 2 of 3" instead of
|
||||
// showing three identical-looking rows. Always 0 and 1 for a
|
||||
// single-destination or console booking.
|
||||
"destinationseq": stop.seq,
|
||||
"destinationcount": len(stops),
|
||||
"next_action": nextAction,
|
||||
"next_hub": nextHub,
|
||||
|
||||
"pickup_source_type": sourceType,
|
||||
"sourceid": sourceID,
|
||||
"pickuplocationid": sourceID,
|
||||
"pickup_source_name": sourceName,
|
||||
"pickupaddress": sourceAddress,
|
||||
"pickuplatitude": b.Pickuplatitude,
|
||||
"pickuplongitude": b.Pickuplongitude,
|
||||
|
||||
"deliveryaddress": stop.deliveryAddress,
|
||||
"deliverylatitude": stop.deliveryLat,
|
||||
"deliverylongitude": stop.deliveryLng,
|
||||
"recipientname": stop.recipientName,
|
||||
"recipientphone": stop.recipientPhone,
|
||||
|
||||
"customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
||||
"customerphone": customer.Phone,
|
||||
// Arrival fact: the rider app derives its "Arrived" rung from
|
||||
// pickup-scheduled + a non-null reachedat, so this survives an app
|
||||
// restart without a separate booking status. Null until the rider hits
|
||||
// the reached endpoint. arrivallatitude/longitude are the GPS captured
|
||||
// at that moment (null if the app sent none).
|
||||
"reachedat": b.Arrivedat,
|
||||
"arrivallatitude": b.Arrivallatitude,
|
||||
"arrivallongitude": b.Arrivallongitude,
|
||||
"parcels": stop.parcels,
|
||||
"serviceoptions": b.ServiceOptions,
|
||||
"codamount": codAmount,
|
||||
// collectionamt is the same number under the name the rider app
|
||||
// reads. Added rather than renamed: `codamount` is what this
|
||||
// endpoint has always emitted and the deployed build parses it,
|
||||
// so removing it would break every stop on every existing
|
||||
// device. Both are written from one variable, so they cannot
|
||||
// drift apart.
|
||||
"collectionamt": codAmount,
|
||||
"paymentmode": paymentMode,
|
||||
"createdat": b.Createdat,
|
||||
// Route sequencing — 0/empty when the stop was never sequenced.
|
||||
// sequencedat is the authoritative-order signal: non-null means the
|
||||
// console/optimizer fixed this stop's position and the app must follow
|
||||
// step exactly; null means no route was assigned and the app is free to
|
||||
// fall back to its own nearest-first ordering.
|
||||
"step": 0,
|
||||
"cumulativekms": 0.0,
|
||||
"etaminutes": 0,
|
||||
"cumulativeeta": 0,
|
||||
"sequencedat": nil,
|
||||
}
|
||||
if a, ok := seqByBooking[b.Bookingid]; ok {
|
||||
row["step"] = a.Step
|
||||
row["cumulativekms"] = a.Cumulativekms
|
||||
row["etaminutes"] = a.Etaminutes
|
||||
row["cumulativeeta"] = a.Cumulativeeta
|
||||
row["sequencedat"] = a.Sequencedat
|
||||
}
|
||||
response = append(response, row)
|
||||
}
|
||||
if a, ok := seqByBooking[b.Bookingid]; ok {
|
||||
row["step"] = a.Step
|
||||
row["cumulativekms"] = a.Cumulativekms
|
||||
row["etaminutes"] = a.Etaminutes
|
||||
row["cumulativeeta"] = a.Cumulativeeta
|
||||
row["sequencedat"] = a.Sequencedat
|
||||
}
|
||||
response = append(response, row)
|
||||
}
|
||||
|
||||
// Sequenced stops ascend by step; unsequenced (step 0) fall to the end while
|
||||
// keeping the newest-first order the app already relied on.
|
||||
// keeping the newest-first order the app already relied on. Stops from one
|
||||
// booking share its step, so they stay adjacent and in destination order.
|
||||
sort.SliceStable(response, func(i, j int) bool {
|
||||
si, sj := response[i]["step"].(int), response[j]["step"].(int)
|
||||
switch {
|
||||
@@ -390,6 +477,121 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
|
||||
return utils.List(c, response, int64(len(response)))
|
||||
}
|
||||
|
||||
// milerStop is one thing the rider actually has to do with one parcel — a
|
||||
// pickup before collection, a delivery leg after it.
|
||||
type milerStop struct {
|
||||
seq int
|
||||
consignmentID *int
|
||||
trackingNo string
|
||||
deliveryAddress string
|
||||
deliveryLat float64
|
||||
deliveryLng float64
|
||||
recipientName string
|
||||
recipientPhone string
|
||||
// codAmount is the collection the CUSTOMER asked for at THIS door, read off
|
||||
// the destination row. The consignment's own figure wins at delivery time
|
||||
// because it also knows what has already been collected — but this is what
|
||||
// makes the per-stop split provable without a database, and it is the value
|
||||
// used if the consignment row could not be loaded. Money must never fall
|
||||
// back to another destination's number.
|
||||
codAmount float64
|
||||
parcels []models.BookingParcel
|
||||
}
|
||||
|
||||
// milerStopsForBooking decides how many stops a booking is worth to the rider.
|
||||
//
|
||||
// Before the parcels are collected it is always ONE stop: the rider makes a
|
||||
// single visit to the customer's door, whatever it is carrying away. After
|
||||
// collection a customer-app pickup becomes one stop per destination, because
|
||||
// each parcel now has its own journey, its own tracking number and its own
|
||||
// deliver/skip action.
|
||||
//
|
||||
// A booking with no destination rows — every console/express booking, and
|
||||
// everything written before the fan-out — is always one stop built from the
|
||||
// booking's own columns, exactly as before.
|
||||
func milerStopsForBooking(b *models.PickupBooking, destinations []models.BookingDestination) []milerStop {
|
||||
single := []milerStop{{
|
||||
seq: 0,
|
||||
consignmentID: b.Consignmentid,
|
||||
deliveryAddress: b.Deliveryaddress,
|
||||
deliveryLat: b.Deliverylatitude,
|
||||
deliveryLng: b.Deliverylongitude,
|
||||
parcels: b.Parcels,
|
||||
}}
|
||||
// A booking with exactly one destination row still carries its COD there.
|
||||
if len(destinations) == 1 {
|
||||
single[0].codAmount = destinations[0].Codamount
|
||||
}
|
||||
|
||||
if len(destinations) == 0 {
|
||||
return single
|
||||
}
|
||||
|
||||
// Not collected yet: one visit, one stop. The destinations are still only an
|
||||
// intention, and showing three rows for a pickup that has not happened would
|
||||
// have the rider drive to the same door three times.
|
||||
collected := false
|
||||
for _, d := range destinations {
|
||||
if d.Consignmentid != nil {
|
||||
collected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !collected {
|
||||
// Destination 0's address is already mirrored onto the booking, so the
|
||||
// single pre-pickup stop is correct as built above.
|
||||
return single
|
||||
}
|
||||
|
||||
parcelsByDestination := map[int][]models.BookingParcel{}
|
||||
for _, p := range b.Parcels {
|
||||
if p.Bookingdestinationid != nil {
|
||||
parcelsByDestination[*p.Bookingdestinationid] = append(parcelsByDestination[*p.Bookingdestinationid], p)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop order is `seq`, guaranteed here rather than inherited from whatever
|
||||
// ORDER BY the caller happened to use. The queue query does sort by seq
|
||||
// today, so this changes nothing — but the ordering IS the route the rider
|
||||
// drives, and leaving it as an unstated precondition means the next caller,
|
||||
// or an edited query, silently reorders someone's afternoon. Sorted on a
|
||||
// copy so the caller's slice is never mutated underneath it.
|
||||
ordered := make([]models.BookingDestination, len(destinations))
|
||||
copy(ordered, destinations)
|
||||
sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Seq < ordered[j].Seq })
|
||||
destinations = ordered
|
||||
|
||||
stops := make([]milerStop, 0, len(destinations))
|
||||
for _, d := range destinations {
|
||||
lat, lng := 0.0, 0.0
|
||||
if d.Pinlatitude != nil && d.Pinlongitude != nil {
|
||||
lat, lng = *d.Pinlatitude, *d.Pinlongitude
|
||||
}
|
||||
stops = append(stops, milerStop{
|
||||
seq: d.Seq,
|
||||
consignmentID: d.Consignmentid,
|
||||
trackingNo: d.Trackingno,
|
||||
deliveryAddress: joinNonEmpty(", ", d.Building, d.Street, d.Landmark, d.Districtname, d.Statename),
|
||||
deliveryLat: lat,
|
||||
deliveryLng: lng,
|
||||
recipientName: d.Recipientname,
|
||||
recipientPhone: d.Recipientphone,
|
||||
codAmount: d.Codamount,
|
||||
parcels: parcelsByDestination[d.Bookingdestinationid],
|
||||
})
|
||||
}
|
||||
|
||||
// Destination 0's coordinates are mirrored onto the booking and may have
|
||||
// been corrected there by the rider at the door, so prefer those when the
|
||||
// destination itself never got a pin.
|
||||
if stops[0].deliveryLat == 0 && stops[0].deliveryLng == 0 {
|
||||
stops[0].deliveryLat = b.Deliverylatitude
|
||||
stops[0].deliveryLng = b.Deliverylongitude
|
||||
}
|
||||
|
||||
return stops
|
||||
}
|
||||
|
||||
// milerConsignmentForRider loads a consignment and confirms it belongs to this
|
||||
// rider — either it is linked to a booking currently assigned to them, or they
|
||||
// are the one who collected it (Createdby). Returns a stable error code on
|
||||
@@ -403,6 +605,19 @@ func milerConsignmentForRider(milerUserID, consignmentID int) (*models.Consignme
|
||||
db.DB.Model(&models.PickupBooking{}).
|
||||
Where("consignmentid = ? AND assignedmileruserid = ?", consignmentID, milerUserID).
|
||||
Count(&count)
|
||||
|
||||
// pickupbookings.consignmentid names only the FIRST order of a
|
||||
// multi-destination pickup, so the count above misses orders 2..N entirely.
|
||||
// The destination table is what actually knows which booking a consignment
|
||||
// belongs to.
|
||||
if count == 0 {
|
||||
db.DB.Model(&models.BookingDestination{}).
|
||||
Joins("JOIN pickupbookings ON pickupbookings.bookingid = bookingdestinations.bookingid").
|
||||
Where("bookingdestinations.consignmentid = ? AND pickupbookings.assignedmileruserid = ?",
|
||||
consignmentID, milerUserID).
|
||||
Count(&count)
|
||||
}
|
||||
|
||||
if count == 0 && consignment.Createdby != milerUserID {
|
||||
return nil, constants.ErrConsignmentNotAssigned, fmt.Errorf("not this rider's consignment")
|
||||
}
|
||||
@@ -442,6 +657,12 @@ func MilerGetConsignment(c *fiber.Ctx) error {
|
||||
"can_start_delivery": consignment.Status == constants.ConsignmentCollectedByMiler,
|
||||
"can_deliver": consignment.Status == constants.ConsignmentOutForDelivery,
|
||||
"can_skip": consignment.Status == constants.ConsignmentOutForDelivery || consignment.Status == constants.ConsignmentCollectedByMiler,
|
||||
// The same leg information the queue read and pickup-complete give, so a
|
||||
// single-consignment refresh is as authoritative as a full poll.
|
||||
"next_action": nextActionForConsignment(consignment.Status),
|
||||
"next_hub": nextHubForConsignment(consignment),
|
||||
"can_inward_at_hub": consignment.Status == constants.ConsignmentCreated,
|
||||
"inwardedat": consignment.Inwardedat,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -504,14 +725,30 @@ func MilerStartDelivery(c *fiber.Ctx) error {
|
||||
return utils.Internal(c, "failed to update miler availability")
|
||||
}
|
||||
|
||||
notifyOutForDelivery, err := recordCxConsignmentStage(tx, consignment.Consignmentid,
|
||||
constants.ConsignmentOutForDelivery, constants.CxActorMiler, &milerUserID,
|
||||
"POST /miler/consignments/{id}/start-delivery")
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("MilerStartDelivery: could not record out_for_delivery",
|
||||
"consignment_id", consignment.Consignmentid, "error", err)
|
||||
return utils.Internal(c, "failed to start delivery")
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to start delivery")
|
||||
}
|
||||
|
||||
notifyOutForDelivery()
|
||||
|
||||
// Tell the customer it's on the way, and hand the receiver their OTP (only the
|
||||
// receiver — the rider is told it at the door).
|
||||
var booking models.PickupBooking
|
||||
if db.DB.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil {
|
||||
//
|
||||
// Resolved through bookingdestinations: pickupbookings.consignmentid names
|
||||
// only the first order of a multi-destination pickup, so joining on it meant
|
||||
// no customer was ever told about orders 2..N going out for delivery.
|
||||
if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil {
|
||||
booking := *bookingPtr
|
||||
var customer models.AppCustomer
|
||||
if db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error == nil && customer.Devicetoken != "" {
|
||||
body := "Your parcel is out for delivery."
|
||||
@@ -558,16 +795,32 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
|
||||
return utils.BadRequest(c, "deliveredtoname is required")
|
||||
}
|
||||
|
||||
var consignment models.Consignment
|
||||
if err := db.DB.First(&consignment, consignmentID).Error; err != nil {
|
||||
return utils.NotFound(c, "consignment not found")
|
||||
}
|
||||
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID).
|
||||
First(&booking).Error; err != nil {
|
||||
// Ownership and the booking behind the parcel, via the one helper that
|
||||
// understands a multi-destination pickup. This used to be a direct
|
||||
// `WHERE consignmentid = ? AND assignedmileruserid = ?` on pickupbookings —
|
||||
// which names only the FIRST order of a pickup, so a rider delivering the
|
||||
// second parcel of a three-destination visit was told "assigned consignment
|
||||
// not found" and could not close the delivery at all.
|
||||
// Both failures answer 404 with the exact messages this endpoint has always
|
||||
// returned. milerConsignmentForRider distinguishes "no such consignment"
|
||||
// from "not yours" and start-delivery reports that as a 403, but the
|
||||
// deployed rider app was built against a 404 here and changing a live
|
||||
// endpoint's status code is not this work's business. Only the LOOKUP is
|
||||
// fixed; the contract is byte-identical.
|
||||
consignmentPtr, code, err := milerConsignmentForRider(milerUserID, consignmentID)
|
||||
if err != nil {
|
||||
if code == constants.ErrConsignmentNotFound {
|
||||
return utils.NotFound(c, "consignment not found")
|
||||
}
|
||||
return utils.NotFound(c, "assigned consignment not found")
|
||||
}
|
||||
consignment := *consignmentPtr
|
||||
|
||||
_, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid)
|
||||
if !ok || bookingPtr == nil {
|
||||
return utils.NotFound(c, "assigned consignment not found")
|
||||
}
|
||||
booking := *bookingPtr
|
||||
|
||||
if consignment.Status != constants.ConsignmentOutForDelivery {
|
||||
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
|
||||
@@ -662,10 +915,27 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
|
||||
return utils.Internal(c, "failed to close assignment")
|
||||
}
|
||||
|
||||
// The customer's Delivered milestone, against THIS order. The booking only
|
||||
// reads as completed once every destination has landed — cxstage rolls the
|
||||
// booking up from the least-advanced order, so a two-parcel pickup with one
|
||||
// still in transit stays "In transit" rather than telling the customer
|
||||
// everything arrived.
|
||||
notifyDelivered, err := recordCxConsignmentStage(tx, consignment.Consignmentid,
|
||||
constants.ConsignmentDelivered, constants.CxActorMiler, &milerUserID,
|
||||
"POST /miler/consignments/{id}/deliver")
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("MilerDeliverConsignment: could not record delivered",
|
||||
"consignment_id", consignment.Consignmentid, "error", err)
|
||||
return utils.Internal(c, "failed to confirm delivery")
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to confirm delivery")
|
||||
}
|
||||
|
||||
notifyDelivered()
|
||||
|
||||
if db.Js != nil {
|
||||
payload := map[string]interface{}{
|
||||
"bookingid": booking.Bookingid,
|
||||
|
||||
@@ -2,7 +2,6 @@ package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -16,6 +15,8 @@ import (
|
||||
"doormile/db"
|
||||
"doormile/dto"
|
||||
"doormile/internal/assignment"
|
||||
"doormile/internal/cxstage"
|
||||
"doormile/internal/legs"
|
||||
"doormile/internal/notify"
|
||||
"doormile/internal/routing"
|
||||
"doormile/models"
|
||||
@@ -25,12 +26,6 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func generateTrackingNo() string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("DM-TRK-%X-%d", b, time.Now().Unix()%100000)
|
||||
}
|
||||
|
||||
func LoginMiler(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
req := new(dto.MilerLoginRequest)
|
||||
@@ -528,10 +523,40 @@ func AcceptMilerAssignment(c *fiber.Ctx) error {
|
||||
return utils.Internal(c, "failed to update miler availability")
|
||||
}
|
||||
|
||||
// Accepting is the customer's "on the way": the rider has seen the job and
|
||||
// is heading over. `assigned` was already recorded when the assignment was
|
||||
// created (assignMilerTx / commitAssignment) and is re-asserted here only
|
||||
// as a safety net for a booking assigned before this surface existed —
|
||||
// Record dedupes, so it appends nothing when it is already on the timeline.
|
||||
//
|
||||
// Deriving on_the_way from the GPS stream instead would mean re-deriving it
|
||||
// on every ping: thousands of writes to learn something the accept already
|
||||
// said.
|
||||
stageAt := utils.DBNow()
|
||||
for _, stage := range []string{constants.CxStageAssigned, constants.CxStageOnTheWay} {
|
||||
if err := cxstage.Record(tx, cxstage.Event{
|
||||
BookingID: assignment.Bookingid,
|
||||
Stage: stage,
|
||||
ActorType: constants.CxActorMiler,
|
||||
ActorID: &milerUserID,
|
||||
Source: "POST /miler/assignments/{id}/accept",
|
||||
At: stageAt,
|
||||
}); err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("AcceptMilerAssignment: could not record customer stage",
|
||||
"booking_id", assignment.Bookingid, "stage", stage, "error", err)
|
||||
return utils.Internal(c, "failed to accept assignment")
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to commit assignment acceptance")
|
||||
}
|
||||
|
||||
// No push here: `assigned` was already announced when the assignment was
|
||||
// created, and on_the_way deliberately rolls up on the timeline. The
|
||||
// existing "Miler Accepted" notification below is the one the customer gets.
|
||||
|
||||
// Accepting a stop moves it into the active set the optimizer orders over, so
|
||||
// re-sequence the rider off the request path. No-op below two active stops.
|
||||
routing.SequenceMilerStopsAsync(milerUserID)
|
||||
@@ -686,6 +711,17 @@ func MilerCancelAssignment(c *fiber.Ctx) error {
|
||||
return utils.Internal(c, "failed to update miler availability")
|
||||
}
|
||||
|
||||
// The pickup is NOT cancelled — it goes back into the pool. The customer's
|
||||
// stage has to walk back with it, or they keep seeing "Miler assigned" and
|
||||
// a rider card for someone who is no longer coming.
|
||||
if err := cxstage.Release(tx, booking.Bookingid, req.Reason,
|
||||
constants.CxActorMiler, &milerUserID,
|
||||
"POST /miler/bookings/{id}/cancel"); err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("MilerCancelAssignment: could not release the customer stage", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.Internal(c, "failed to commit cancellation")
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to commit cancellation")
|
||||
}
|
||||
@@ -750,9 +786,29 @@ func BookingReachedCustomer(c *fiber.Ctx) error {
|
||||
return utils.Internal(c, "failed to update miler availability")
|
||||
}
|
||||
|
||||
// Arrival is the LAST cancellable stage on the customer side, so it has to
|
||||
// be recorded transactionally with the arrival fact itself. A gap between
|
||||
// the two is a window in which the customer can still cancel a pickup the
|
||||
// rider is already standing at.
|
||||
if err := cxstage.Record(tx, cxstage.Event{
|
||||
BookingID: booking.Bookingid,
|
||||
Stage: constants.CxStageArrived,
|
||||
ActorType: constants.CxActorMiler,
|
||||
ActorID: &milerUserID,
|
||||
Source: "POST /miler/bookings/{id}/reached",
|
||||
At: utils.DBNow(),
|
||||
}); err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("BookingReachedCustomer: could not record arrived stage", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.Internal(c, "failed to record arrival")
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to confirm arrival")
|
||||
}
|
||||
|
||||
go cxstage.Notify(booking.Bookingid, nil, constants.CxStageArrived)
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"bookingid": booking.Bookingid,
|
||||
"status": booking.Status,
|
||||
@@ -858,12 +914,19 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
|
||||
return utils.NotFound(c, "assigned booking not found")
|
||||
}
|
||||
|
||||
// Photos are the evidence half of the receipt. Weight without a photograph
|
||||
// is a number the customer has no way to check, and this is the only point
|
||||
// in the flow where anyone is standing next to the parcel. Sent as storage
|
||||
// keys from the presigned upload (POST /miler/uploads/sign), not as raw
|
||||
// URLs: the customer is served a short-lived signed link derived from the
|
||||
// key, never a permanent one.
|
||||
type ParcelUpdate struct {
|
||||
ParcelID int `json:"parcel_id"`
|
||||
Weight float64 `json:"weight"`
|
||||
Length float64 `json:"length"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
ParcelID int `json:"parcel_id"`
|
||||
Weight float64 `json:"weight"`
|
||||
Length float64 `json:"length"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Photos []string `json:"photos"`
|
||||
}
|
||||
var req struct {
|
||||
Parcels []ParcelUpdate `json:"parcels"`
|
||||
@@ -886,8 +949,13 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
|
||||
parcelMap[parcels[i].Bookingparcelid] = &parcels[i]
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
now := utils.DBNow()
|
||||
var totalChargeable float64
|
||||
// Chargeable weight per destination, so each order settles on the weight of
|
||||
// its own parcels rather than on the whole visit's total.
|
||||
perDestination := map[int]float64{}
|
||||
|
||||
tx := db.DB.Begin()
|
||||
|
||||
for _, upd := range req.Parcels {
|
||||
p, ok := parcelMap[upd.ParcelID]
|
||||
@@ -899,10 +967,51 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
|
||||
p.Width = upd.Width
|
||||
p.Height = upd.Height
|
||||
p.Updatedat = now
|
||||
db.DB.Save(p)
|
||||
if err := tx.Save(p).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to save parcel measurements")
|
||||
}
|
||||
|
||||
volumetric := calculateVolumetricWeight(upd.Length, upd.Width, upd.Height)
|
||||
totalChargeable += math.Max(upd.Weight, volumetric)
|
||||
chargeable := math.Max(upd.Weight, volumetric)
|
||||
totalChargeable += chargeable
|
||||
if p.Bookingdestinationid != nil {
|
||||
perDestination[*p.Bookingdestinationid] += chargeable
|
||||
}
|
||||
|
||||
for _, key := range upd.Photos {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
photo := models.BookingParcelPhoto{
|
||||
Bookingid: bookingID,
|
||||
Bookingdestinationid: p.Bookingdestinationid,
|
||||
Objectkey: key,
|
||||
Capturedbyuserid: &milerUserID,
|
||||
Capturedat: now,
|
||||
}
|
||||
if err := tx.Create(&photo).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to save parcel photo")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The verification block the customer's receipt reads. Written here, at the
|
||||
// door, where the measurement was actually taken — pickup-complete restates
|
||||
// it from the same parcel rows when the price settles, so the two cannot
|
||||
// disagree.
|
||||
for destinationID, weight := range perDestination {
|
||||
if err := cxRecordVerification(tx, destinationID, weight, milerUserID, now); err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("BookingParcelConfirm: could not record verification", "booking_id", bookingID, "error", err)
|
||||
return utils.Internal(c, "failed to record the parcel weight")
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return utils.Internal(c, "failed to confirm parcels")
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
@@ -957,10 +1066,7 @@ func BookingPaymentCollect(c *fiber.Ctx) error {
|
||||
// final-mile delivery. Pincodes shorter than 3 characters are treated as
|
||||
// unknown rather than matching, so bad data falls back to the safe hub route.
|
||||
func isHyperlocal(pickupPincode, deliveryPincode string) bool {
|
||||
if len(pickupPincode) < 3 || len(deliveryPincode) < 3 {
|
||||
return false
|
||||
}
|
||||
return pickupPincode[:3] == deliveryPincode[:3]
|
||||
return legs.SamePostalArea(pickupPincode, deliveryPincode)
|
||||
}
|
||||
|
||||
// maxHyperlocalKM bounds the straight-line pickup→delivery distance under which
|
||||
@@ -969,7 +1075,7 @@ func isHyperlocal(pickupPincode, deliveryPincode string) bool {
|
||||
// created kitchen→customer bookings (e.g. DailyGrubs) frequently carry accurate
|
||||
// coordinates but no delivery pincode, and must not be wrongly routed through a
|
||||
// hub. When both pincodes are present the prefix rule still wins.
|
||||
const maxHyperlocalKM = 30.0
|
||||
const maxHyperlocalKM = legs.MaxHyperlocalKM
|
||||
|
||||
// isHyperlocalBooking decides whether a booking can skip the hub and be carried
|
||||
// straight to the final mile. It prefers the pincode-prefix rule (isHyperlocal)
|
||||
@@ -977,18 +1083,7 @@ const maxHyperlocalKM = 30.0
|
||||
// between the pickup and delivery coordinates when a pincode is missing — so a
|
||||
// same-area booking whose address carried no pincode isn't sent to a hub.
|
||||
func isHyperlocalBooking(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng float64) bool {
|
||||
// Both pincodes present: the prefix rule decides definitively (a matching
|
||||
// pincode is hyperlocal, a differing one is genuinely inter-area — don't let
|
||||
// distance override that).
|
||||
if len(pickupPincode) >= 3 && len(deliveryPincode) >= 3 {
|
||||
return isHyperlocal(pickupPincode, deliveryPincode)
|
||||
}
|
||||
// A pincode is missing: fall back to straight-line distance when we have both
|
||||
// coordinates.
|
||||
if pLat != 0 && pLng != 0 && dLat != 0 && dLng != 0 {
|
||||
return haversineKM(pLat, pLng, dLat, dLng) <= maxHyperlocalKM
|
||||
}
|
||||
return false
|
||||
return legs.IsHyperlocal(pickupPincode, deliveryPincode, pLat, pLng, dLat, dLng)
|
||||
}
|
||||
|
||||
// collectedStateEnabled gates the two-step hyperlocal delivery flow
|
||||
@@ -1043,60 +1138,30 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
var parcels []models.BookingParcel
|
||||
tx.Where("bookingid = ?", bookingID).Find(&parcels)
|
||||
|
||||
var totalDead, totalChargeable, maxL, maxW, maxH float64
|
||||
for _, p := range parcels {
|
||||
vol := calculateVolumetricWeight(p.Length, p.Width, p.Height)
|
||||
totalDead += p.Weight
|
||||
totalChargeable += math.Max(p.Weight, vol)
|
||||
if p.Length > maxL {
|
||||
maxL = p.Length
|
||||
}
|
||||
if p.Width > maxW {
|
||||
maxW = p.Width
|
||||
}
|
||||
if p.Height > maxH {
|
||||
maxH = p.Height
|
||||
}
|
||||
}
|
||||
if len(parcels) == 0 {
|
||||
totalDead = 0.5
|
||||
totalChargeable = 0.5
|
||||
// One visit, N orders. A customer-app booking fans out into one consignment
|
||||
// per destination — each with its own tracking number and its own journey —
|
||||
// while a console-created booking, which has no destination rows, produces
|
||||
// the single consignment it always did. cxPickupLegs is what decides which
|
||||
// of those this is; nothing below needs to know.
|
||||
legs, err := cxPickupLegs(tx, &booking)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("BookingPickupComplete: could not resolve pickup legs", "booking_id", bookingID, "error", err)
|
||||
return utils.Internal(c, "failed to read the parcels on this booking")
|
||||
}
|
||||
|
||||
trackingNo := generateTrackingNo()
|
||||
|
||||
// The base this parcel belongs to. Backend decides — the app is told where to
|
||||
// go and never picks a base itself. resolveHandoverHub prefers the base the
|
||||
// booking was routed to, then the collecting rider's own base (the previous
|
||||
// behaviour, and still the operational default), then the nearest active base
|
||||
// to the pickup point — that last step replaces a fallback that took whichever
|
||||
// hub row happened to come back first.
|
||||
handoverHub := resolveHandoverHub(&booking, profile.Hubid)
|
||||
var defaultHubID *int
|
||||
if profile.Hubid != nil {
|
||||
defaultHubID = profile.Hubid
|
||||
if handoverHub != nil {
|
||||
defaultHubID = &handoverHub.Hubid
|
||||
} else {
|
||||
utils.Warn("BookingPickupComplete: miler has no assigned hub, falling back to first hub row", "miler_user_id", milerUserID, "booking_id", bookingID)
|
||||
var hub models.Hub
|
||||
if tx.First(&hub).Error == nil {
|
||||
defaultHubID = &hub.Hubid
|
||||
}
|
||||
}
|
||||
|
||||
// Hyperlocal shortcut: pickup and delivery in the same postal area mean no
|
||||
// hub-to-hub tripsheet leg is needed, so the same miler carries it to the
|
||||
// final mile instead of parking it at the hub.
|
||||
//
|
||||
// With the collected-state flow ON it lands in Collected_By_Miler — collected
|
||||
// but not yet out for delivery — and the rider taps start-delivery to move it
|
||||
// to Out_for_Delivery, which lets the console tell "collected" from "actively
|
||||
// delivering". With it OFF (default, and what the current app expects) it goes
|
||||
// straight to Out_for_Delivery exactly as before.
|
||||
consignmentStatus := constants.ConsignmentInwardedAtHub
|
||||
if isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode,
|
||||
booking.Pickuplatitude, booking.Pickuplongitude,
|
||||
booking.Deliverylatitude, booking.Deliverylongitude) {
|
||||
if collectedStateEnabled() {
|
||||
consignmentStatus = constants.ConsignmentCollectedByMiler
|
||||
} else {
|
||||
consignmentStatus = constants.ConsignmentOutForDelivery
|
||||
}
|
||||
utils.Warn("BookingPickupComplete: no base could be resolved for this pickup", "miler_user_id", milerUserID, "booking_id", bookingID)
|
||||
}
|
||||
|
||||
// The consignment's tenant is the booking's own tenant (set explicitly at
|
||||
@@ -1110,86 +1175,277 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
||||
consignmentTenantID = *booking.Tenantid
|
||||
}
|
||||
|
||||
// Carried over so the consignment stays traceable to the client site it was
|
||||
// collected from — for a food client that's the kitchen, and "how many
|
||||
// parcels went out of which kitchen" is unanswerable without it.
|
||||
consignment := models.Consignment{
|
||||
Trackingno: trackingNo,
|
||||
Tenantid: consignmentTenantID,
|
||||
Pickuplocationid: booking.Pickuplocationid,
|
||||
Tenantlocationid: booking.Tenantlocationid,
|
||||
Pickuplatitude: booking.Pickuplatitude,
|
||||
Pickuplongitude: booking.Pickuplongitude,
|
||||
Deliverylatitude: booking.Deliverylatitude,
|
||||
Deliverylongitude: booking.Deliverylongitude,
|
||||
Pickuppincode: booking.Pickuppincode,
|
||||
Deliverypincode: booking.Deliverypincode,
|
||||
Length: maxL,
|
||||
Width: maxW,
|
||||
Height: maxH,
|
||||
Deadweight: totalDead,
|
||||
Volumetricweight: totalChargeable - totalDead,
|
||||
Chargeableweight: totalChargeable,
|
||||
Paymentmode: "Prepaid",
|
||||
Status: consignmentStatus,
|
||||
Estimateddeliveryat: nil,
|
||||
Createdby: milerUserID,
|
||||
Originhubid: defaultHubID,
|
||||
Currenthubid: defaultHubID,
|
||||
var tenant models.Tenant
|
||||
tenantNeedsOTP := false
|
||||
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil {
|
||||
tenantNeedsOTP = tenant.Requiredeliveryotp
|
||||
}
|
||||
|
||||
// Money collected at the door. Split across the legs below rather than
|
||||
// stamped whole onto each one: a single payment covering a three-stop
|
||||
// pickup must not appear three times in the books.
|
||||
var payment models.BookingPayment
|
||||
if tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil {
|
||||
if payment.Paymentstatus == constants.PaymentStatusPaid {
|
||||
consignment.Codcollected = payment.Amount
|
||||
} else {
|
||||
consignment.Codamount = payment.Amount
|
||||
hasPayment := tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil
|
||||
|
||||
created := make([]models.Consignment, 0, len(legs))
|
||||
trackingNos := make([]string, 0, len(legs))
|
||||
riderMarkedBusy := false
|
||||
assignmentStillOpen := false
|
||||
|
||||
for i, leg := range legs {
|
||||
totalDead, totalChargeable, maxL, maxW, maxH := cxLegWeights(leg)
|
||||
trackingNo := generateTrackingNo()
|
||||
|
||||
// A hub-routed parcel: with the hub-handover flow ON it stops at Created —
|
||||
// collected, in the rider's hands, on its way to a base — and only reaches
|
||||
// Inwarded_at_Hub when the handover is actually recorded. With it OFF
|
||||
// (default, and what the current app expects) it is marked Inwarded_at_Hub
|
||||
// here, which is not where the parcel physically is but is what the current
|
||||
// app and the console's inbound views read.
|
||||
consignmentStatus := constants.ConsignmentInwardedAtHub
|
||||
if hubHandoverEnabled() {
|
||||
consignmentStatus = constants.ConsignmentCreated
|
||||
}
|
||||
|
||||
// Hyperlocal shortcut: pickup and delivery in the same postal area mean no
|
||||
// hub-to-hub tripsheet leg is needed, so the same miler carries it to the
|
||||
// final mile instead of parking it at the hub. Decided per leg, because on
|
||||
// a multi-destination pickup one parcel can be going round the corner while
|
||||
// another is going to another state.
|
||||
//
|
||||
// With the collected-state flow ON it lands in Collected_By_Miler — collected
|
||||
// but not yet out for delivery — and the rider taps start-delivery to move it
|
||||
// to Out_for_Delivery, which lets the console tell "collected" from "actively
|
||||
// delivering". With it OFF (default, and what the current app expects) it goes
|
||||
// straight to Out_for_Delivery exactly as before.
|
||||
if isHyperlocalBooking(booking.Pickuppincode, leg.DeliveryPincode,
|
||||
booking.Pickuplatitude, booking.Pickuplongitude,
|
||||
leg.DeliveryLatitude, leg.DeliveryLongitude) {
|
||||
if collectedStateEnabled() {
|
||||
consignmentStatus = constants.ConsignmentCollectedByMiler
|
||||
} else {
|
||||
consignmentStatus = constants.ConsignmentOutForDelivery
|
||||
}
|
||||
}
|
||||
|
||||
// Carried over so the consignment stays traceable to the client site it was
|
||||
// collected from — for a food client that's the kitchen, and "how many
|
||||
// parcels went out of which kitchen" is unanswerable without it.
|
||||
consignment := models.Consignment{
|
||||
Trackingno: trackingNo,
|
||||
Tenantid: consignmentTenantID,
|
||||
Pickuplocationid: booking.Pickuplocationid,
|
||||
Tenantlocationid: booking.Tenantlocationid,
|
||||
Pickuplatitude: booking.Pickuplatitude,
|
||||
Pickuplongitude: booking.Pickuplongitude,
|
||||
Deliverylatitude: leg.DeliveryLatitude,
|
||||
Deliverylongitude: leg.DeliveryLongitude,
|
||||
Pickuppincode: booking.Pickuppincode,
|
||||
Deliverypincode: leg.DeliveryPincode,
|
||||
Length: maxL,
|
||||
Width: maxW,
|
||||
Height: maxH,
|
||||
Deadweight: totalDead,
|
||||
Volumetricweight: totalChargeable - totalDead,
|
||||
Chargeableweight: totalChargeable,
|
||||
Paymentmode: "Prepaid",
|
||||
Status: consignmentStatus,
|
||||
Estimateddeliveryat: cxEstimatedDelivery(leg, now),
|
||||
Createdby: milerUserID,
|
||||
Originhubid: defaultHubID,
|
||||
Currenthubid: defaultHubID,
|
||||
}
|
||||
|
||||
// COD the customer asked to have collected at THIS door, on their behalf.
|
||||
// Doormile is the carrier, not the seller — this money is never Doormile's.
|
||||
if leg.CodAmount > 0 {
|
||||
consignment.Codamount = leg.CodAmount
|
||||
consignment.Paymentmode = "COD"
|
||||
}
|
||||
}
|
||||
|
||||
// A parcel that goes straight out for delivery here (collected-state flow off)
|
||||
// needs its receiver OTP before commit — same as before. When the flow is on,
|
||||
// a hyperlocal parcel stops at Collected_By_Miler and its OTP is issued later
|
||||
// at start-delivery instead, so this block simply doesn't fire. Only clients
|
||||
// that ask for one get an OTP (Tenant.Requiredeliveryotp).
|
||||
if consignmentStatus == constants.ConsignmentOutForDelivery {
|
||||
var tenant models.Tenant
|
||||
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil && tenant.Requiredeliveryotp {
|
||||
// Under the compatibility flow the parcel is treated as received at the base
|
||||
// the moment it is collected, so the received-at fact is stamped here too —
|
||||
// otherwise every parcel inwarded this way would have a null handover time
|
||||
// and base reconciliation would have nothing to compare against.
|
||||
if consignmentStatus == constants.ConsignmentInwardedAtHub {
|
||||
consignment.Inwardedat = &now
|
||||
}
|
||||
|
||||
// The pickup fee the miler collected covers the whole visit, so it is
|
||||
// recorded once — against the first order — rather than repeated on
|
||||
// every leg.
|
||||
if hasPayment && i == 0 {
|
||||
if payment.Paymentstatus == constants.PaymentStatusPaid {
|
||||
consignment.Codcollected = payment.Amount
|
||||
} else {
|
||||
consignment.Codamount += payment.Amount
|
||||
consignment.Paymentmode = "COD"
|
||||
}
|
||||
}
|
||||
|
||||
// A parcel that goes straight out for delivery here (collected-state flow off)
|
||||
// needs its receiver OTP before commit — same as before. When the flow is on,
|
||||
// a hyperlocal parcel stops at Collected_By_Miler and its OTP is issued later
|
||||
// at start-delivery instead, so this block simply doesn't fire. Only clients
|
||||
// that ask for one get an OTP (Tenant.Requiredeliveryotp).
|
||||
if consignmentStatus == constants.ConsignmentOutForDelivery && tenantNeedsOTP {
|
||||
consignment.Deliveryotp = utils.GenerateNumericOTP(6)
|
||||
}
|
||||
|
||||
if err := tx.Create(&consignment).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to convert booking to consignment")
|
||||
}
|
||||
|
||||
if err := cxLinkLegToOrder(tx, leg, consignment.Consignmentid, trackingNo,
|
||||
consignment.Estimateddeliveryat, now); err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("BookingPickupComplete: could not link destination to order", "booking_id", bookingID, "error", err)
|
||||
return utils.Internal(c, "failed to link the destination to its order")
|
||||
}
|
||||
|
||||
// What the miler weighed at this door, kept where the customer's receipt
|
||||
// reads it. Without this the verification block stays empty and the
|
||||
// receipt loses the evidence behind the settled price.
|
||||
if leg.Destination != nil {
|
||||
if err := cxRecordVerification(tx, leg.Destination.Bookingdestinationid,
|
||||
totalChargeable, milerUserID, now); err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("BookingPickupComplete: could not record verification", "booking_id", bookingID, "error", err)
|
||||
return utils.Internal(c, "failed to record the parcel weight")
|
||||
}
|
||||
}
|
||||
|
||||
history := models.ConsignmentHistory{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: defaultHubID,
|
||||
Userid: &milerUserID,
|
||||
Eventstatus: consignmentStatus,
|
||||
Remarks: "Package collected by miler and converted to consignment",
|
||||
}
|
||||
if err := tx.Create(&history).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to record consignment history")
|
||||
}
|
||||
|
||||
// Two separate questions, deliberately not merged.
|
||||
//
|
||||
// "Is the rider marked busy?" keeps the EXACT rule this endpoint has
|
||||
// always applied — Created or Collected_By_Miler only. A hyperlocal
|
||||
// parcel that goes straight to Out_for_Delivery has always left the
|
||||
// rider Available here, even though the comment below says otherwise.
|
||||
// That mismatch is pre-existing and is the default path today; changing
|
||||
// it would alter live rider availability, which is not this work's
|
||||
// business. Flagged in docs/customer-app-api.md, not silently fixed.
|
||||
//
|
||||
// "Is the assignment still open?" is the one that has to understand the
|
||||
// fan-out: it closes only when every leg has been handed over at a base,
|
||||
// which for a single-leg booking is identical to the previous behaviour.
|
||||
if consignmentStatus == constants.ConsignmentCollectedByMiler ||
|
||||
consignmentStatus == constants.ConsignmentCreated {
|
||||
riderMarkedBusy = true
|
||||
}
|
||||
if consignmentStatus != constants.ConsignmentInwardedAtHub {
|
||||
assignmentStillOpen = true
|
||||
}
|
||||
|
||||
// The customer's per-order stage. in_transit is recorded here only on the
|
||||
// compatibility flow, where the parcel really is treated as received at
|
||||
// the base the instant it is collected; on the handover flow it waits for
|
||||
// the rider to actually hand it over.
|
||||
destinationID := cxDestinationIDFor(leg.Destination)
|
||||
if err := cxstage.Record(tx, cxstage.Event{
|
||||
BookingID: bookingID,
|
||||
DestinationID: destinationID,
|
||||
Stage: constants.CxStageOrderCreated,
|
||||
ActorType: constants.CxActorMiler,
|
||||
ActorID: &milerUserID,
|
||||
Source: "POST /miler/bookings/{id}/pickup-complete",
|
||||
At: utils.DBNow(),
|
||||
}); err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("BookingPickupComplete: could not record order_created", "booking_id", bookingID, "error", err)
|
||||
return utils.Internal(c, "failed to record the pickup")
|
||||
}
|
||||
|
||||
// On the compatibility flow a parcel is already past order_created the
|
||||
// instant it is collected — hub-routed ones are stamped Inwarded_at_Hub
|
||||
// here, and hyperlocal ones go straight to Out_for_Delivery. Recording
|
||||
// what the status actually says keeps the timeline honest: those
|
||||
// transitions really did happen at this moment, and omitting them would
|
||||
// leave a parcel showing "Package collected" while the rider is already
|
||||
// carrying it to the door.
|
||||
if implied, ok := cxStageForConsignmentStatus(consignmentStatus); ok {
|
||||
if err := cxstage.Record(tx, cxstage.Event{
|
||||
BookingID: bookingID,
|
||||
DestinationID: destinationID,
|
||||
Stage: implied,
|
||||
ActorType: constants.CxActorMiler,
|
||||
ActorID: &milerUserID,
|
||||
Source: "POST /miler/bookings/{id}/pickup-complete",
|
||||
At: utils.DBNow(),
|
||||
}); err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("BookingPickupComplete: could not record per-order stage", "booking_id", bookingID, "error", err)
|
||||
return utils.Internal(c, "failed to record the pickup")
|
||||
}
|
||||
}
|
||||
|
||||
created = append(created, consignment)
|
||||
trackingNos = append(trackingNos, trackingNo)
|
||||
}
|
||||
|
||||
if err := tx.Create(&consignment).Error; err != nil {
|
||||
// Recorded before order_created in wall-clock terms — the parcels were in the
|
||||
// rider's hands before the orders existed — but written after, because the
|
||||
// weights the price settles on are only known once the legs are built.
|
||||
if err := cxstage.Record(tx, cxstage.Event{
|
||||
BookingID: bookingID,
|
||||
Stage: constants.CxStagePickedUp,
|
||||
ActorType: constants.CxActorMiler,
|
||||
ActorID: &milerUserID,
|
||||
Source: "POST /miler/bookings/{id}/pickup-complete",
|
||||
At: utils.DBNow(),
|
||||
}); err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to convert booking to consignment")
|
||||
utils.Error("BookingPickupComplete: could not record picked_up", "booking_id", bookingID, "error", err)
|
||||
return utils.Internal(c, "failed to record the pickup")
|
||||
}
|
||||
|
||||
booking.Consignmentid = &consignment.Consignmentid
|
||||
// pickupbookings.consignmentid names the FIRST order only. It is kept for
|
||||
// the console and the legacy reads that still join on it; anything that
|
||||
// needs the whole set goes through bookingdestinations.
|
||||
first := created[0]
|
||||
booking.Consignmentid = &first.Consignmentid
|
||||
booking.Status = constants.BookingConvertedConsignment
|
||||
if err := tx.Save(&booking).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to link booking to consignment")
|
||||
}
|
||||
|
||||
history := models.ConsignmentHistory{
|
||||
Consignmentid: consignment.Consignmentid,
|
||||
Hubid: defaultHubID,
|
||||
Userid: &milerUserID,
|
||||
Eventstatus: consignmentStatus,
|
||||
Remarks: "Package collected by miler and converted to consignment",
|
||||
}
|
||||
if err := tx.Create(&history).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to record consignment history")
|
||||
// Unchanged from before the fan-out: a parcel still on its way to a base
|
||||
// keeps the rider marked busy; anything else frees them up.
|
||||
postPickupAvailability := constants.MilerAvailable
|
||||
if riderMarkedBusy {
|
||||
postPickupAvailability = constants.MilerPickedUp
|
||||
}
|
||||
|
||||
// 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
|
||||
if consignmentStatus == constants.ConsignmentCollectedByMiler {
|
||||
postPickupAvailability = constants.MilerPickedUp
|
||||
// A parcel that is already inwarded at the base ends this rider's leg, so the
|
||||
// assignment closes with it. Without this the assignment stayed open forever
|
||||
// on the compatibility flow and the rider could not go off duty — MilerEndDuty
|
||||
// refuses while any assignment is still Assigned/Accepted. On the handover
|
||||
// flow the assignment stays open on purpose and closes at inward-at-hub.
|
||||
if !assignmentStillOpen {
|
||||
if err := tx.Model(&models.BookingAssignment{}).
|
||||
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
|
||||
bookingID, milerUserID,
|
||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||
Updates(map[string]interface{}{
|
||||
"assignmentstatus": constants.AssignmentCompleted,
|
||||
"completedat": now,
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return utils.Internal(c, "failed to close assignment")
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
||||
Update("availabilitystatus", postPickupAvailability).Error; err != nil {
|
||||
@@ -1201,49 +1457,90 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
||||
return utils.Internal(c, "failed to complete pickup")
|
||||
}
|
||||
|
||||
// One notification for the milestone, not one per order: three tracking
|
||||
// numbers arriving as three buzzes for a single visit is noise, and
|
||||
// order_created deliberately rolls up on the timeline.
|
||||
go cxstage.Notify(bookingID, nil, constants.CxStagePickedUp)
|
||||
|
||||
// If an OTP was issued here (parcel went straight out for delivery), it goes to
|
||||
// the receiver in this notification — the rider is told it at the door. When
|
||||
// the collected-state flow is on, no OTP exists yet and the notification is
|
||||
// just "collected"; the OTP rides the start-delivery notification instead.
|
||||
var customer models.AppCustomer
|
||||
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
|
||||
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo)
|
||||
payload := map[string]string{
|
||||
"booking_id": strconv.Itoa(bookingID),
|
||||
"tracking_no": trackingNo,
|
||||
}
|
||||
if consignment.Deliveryotp != "" {
|
||||
body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignment.Deliveryotp)
|
||||
payload["delivery_otp"] = consignment.Deliveryotp
|
||||
}
|
||||
if notifyErr := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); notifyErr != nil {
|
||||
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", bookingID, "error", notifyErr)
|
||||
}
|
||||
}
|
||||
notifyCustomerOnPickup(&booking, created, trackingNos)
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"tracking_no": trackingNo,
|
||||
"consignment_id": consignment.Consignmentid,
|
||||
"consignmentstatus": consignment.Status,
|
||||
// next_action says what the rider does next; next_hub says where. An
|
||||
// inward_at_hub with no base named leaves a rider holding a parcel with
|
||||
// nowhere to take it, so the two travel together — and next_hub carries all
|
||||
// six fields, because the coordinates are the only thing that can drive
|
||||
// Navigate and the address and pincode are what the rider reads at the gate.
|
||||
//
|
||||
// consignment_id is always present: the delivery leg is keyed on it, and
|
||||
// without it the app cannot name the parcel it is about to act on.
|
||||
//
|
||||
// The single-consignment fields still describe the FIRST order, unchanged,
|
||||
// so the deployed rider app keeps working exactly as before. `consignments`
|
||||
// is additive and carries the full set for a build that can show them.
|
||||
resp := fiber.Map{
|
||||
"tracking_no": trackingNos[0],
|
||||
"consignment_id": first.Consignmentid,
|
||||
"consignmentstatus": first.Status,
|
||||
"status": first.Status,
|
||||
"booking_no": booking.Bookingno,
|
||||
"booking_status": booking.Status,
|
||||
"next_action": pickupNextAction(consignment.Status),
|
||||
})
|
||||
"next_action": nextActionForConsignment(first.Status),
|
||||
"consignments": renderPickupOrders(created, trackingNos),
|
||||
}
|
||||
if first.Status == constants.ConsignmentCreated ||
|
||||
first.Status == constants.ConsignmentInwardedAtHub {
|
||||
resp["next_hub"] = renderBase(handoverHub)
|
||||
}
|
||||
return utils.OK(c, resp)
|
||||
}
|
||||
|
||||
// pickupNextAction tells the app what the rider does next after a pickup, so it
|
||||
// doesn't have to encode the hub-vs-hyperlocal branch itself:
|
||||
// - Collected_By_Miler → tap start-delivery (collected-state flow on)
|
||||
// - Out_for_Delivery → deliver directly (hyperlocal, collected-state off)
|
||||
// - Inwarded_at_Hub → handed to the hub, done for this rider
|
||||
func pickupNextAction(consignmentStatus string) string {
|
||||
switch consignmentStatus {
|
||||
case constants.ConsignmentCollectedByMiler:
|
||||
return "start_delivery"
|
||||
case constants.ConsignmentOutForDelivery:
|
||||
return "deliver"
|
||||
default:
|
||||
return "handed_to_hub"
|
||||
// renderPickupOrders lists every order a pickup produced, so a rider carrying
|
||||
// three parcels from one visit can be shown three stops rather than one.
|
||||
func renderPickupOrders(consignments []models.Consignment, trackingNos []string) []fiber.Map {
|
||||
out := make([]fiber.Map, 0, len(consignments))
|
||||
for i := range consignments {
|
||||
cn := consignments[i]
|
||||
out = append(out, fiber.Map{
|
||||
"consignment_id": cn.Consignmentid,
|
||||
"tracking_no": trackingNos[i],
|
||||
"consignmentstatus": cn.Status,
|
||||
"next_action": nextActionForConsignment(cn.Status),
|
||||
"delivery_pincode": cn.Deliverypincode,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// notifyCustomerOnPickup tells the customer their parcels were collected, and
|
||||
// hands the receiver any delivery OTP that was issued. Best-effort: a push that
|
||||
// fails must never fail a pickup that already committed.
|
||||
func notifyCustomerOnPickup(booking *models.PickupBooking, consignments []models.Consignment, trackingNos []string) {
|
||||
var customer models.AppCustomer
|
||||
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err != nil {
|
||||
return
|
||||
}
|
||||
if customer.Devicetoken == "" {
|
||||
return
|
||||
}
|
||||
|
||||
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNos[0])
|
||||
if len(trackingNos) > 1 {
|
||||
body = fmt.Sprintf("%d parcels picked up — first tracking no: %s", len(trackingNos), trackingNos[0])
|
||||
}
|
||||
payload := map[string]string{
|
||||
"booking_id": strconv.Itoa(booking.Bookingid),
|
||||
"tracking_no": trackingNos[0],
|
||||
"reference": booking.Bookingno,
|
||||
}
|
||||
if len(consignments) > 0 && consignments[0].Deliveryotp != "" {
|
||||
body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignments[0].Deliveryotp)
|
||||
payload["delivery_otp"] = consignments[0].Deliveryotp
|
||||
}
|
||||
if err := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); err != nil {
|
||||
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", booking.Bookingid, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
"doormile/dto"
|
||||
"doormile/internal/mail"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
otpTTL = 5 * time.Minute
|
||||
otpMaxAttempts = 5
|
||||
// otpVerifiedTTL is how long a successful email verification stays usable as
|
||||
// proof of identity for a follow-up action such as a PIN reset. Long enough
|
||||
// to type a new PIN, short enough that a stale verification can't be
|
||||
// redeemed later.
|
||||
otpVerifiedTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
func generateOtpCode() string {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||
if err != nil {
|
||||
return "000000"
|
||||
}
|
||||
return fmt.Sprintf("%06d", n.Int64())
|
||||
}
|
||||
|
||||
func otpKey(email string) string { return fmt.Sprintf("otp:email:%s", email) }
|
||||
func otpAttemptsKey(email string) string { return fmt.Sprintf("otp:email:%s:attempts", email) }
|
||||
|
||||
// otpVerifiedKey marks an email as recently proven. Verification previously
|
||||
// left no trace at all, so nothing downstream could require it — which is why
|
||||
// ResetCustomerPin was able to overwrite a PIN on nothing but a phone number.
|
||||
func otpVerifiedKey(email string) string { return fmt.Sprintf("otp:email:%s:verified", email) }
|
||||
|
||||
// ConsumeEmailVerification reports whether the email was verified recently, and
|
||||
// clears the marker so a single verification can authorise exactly one action.
|
||||
func ConsumeEmailVerification(email string) bool {
|
||||
if db.Rdb == nil || email == "" {
|
||||
return false
|
||||
}
|
||||
ctx := context.Background()
|
||||
n, err := db.Rdb.Del(ctx, otpVerifiedKey(email)).Result()
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
func SendCustomerEmailOtp(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
req := new(dto.SendEmailOtpRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
if req.Email == "" {
|
||||
return utils.BadRequest(c, "email is required")
|
||||
}
|
||||
|
||||
if db.Rdb == nil {
|
||||
return utils.Internal(c, "verification service unavailable")
|
||||
}
|
||||
|
||||
code := generateOtpCode()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := db.Rdb.Set(ctx, otpKey(req.Email), code, otpTTL).Err(); err != nil {
|
||||
return utils.Internal(c, "failed to generate verification code")
|
||||
}
|
||||
db.Rdb.Del(ctx, otpAttemptsKey(req.Email))
|
||||
|
||||
if err := mail.SendOTPEmail(cfg, req.Email, code); err != nil {
|
||||
utils.Warn("failed to send OTP email", "email", req.Email, "error", err)
|
||||
return utils.Internal(c, "failed to send verification email")
|
||||
}
|
||||
|
||||
return utils.Message(c, "verification code sent")
|
||||
}
|
||||
}
|
||||
|
||||
func VerifyCustomerEmailOtp() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
req := new(dto.VerifyEmailOtpRequest)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
if req.Email == "" || req.Otp == "" {
|
||||
return utils.BadRequest(c, "email and otp are required")
|
||||
}
|
||||
|
||||
if db.Rdb == nil {
|
||||
return utils.Internal(c, "verification service unavailable")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
key := otpKey(req.Email)
|
||||
|
||||
stored, err := db.Rdb.Get(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
return utils.BadRequest(c, "verification code expired or not found, please resend")
|
||||
} else if err != nil {
|
||||
return utils.Internal(c, "failed to verify code")
|
||||
}
|
||||
|
||||
if stored != req.Otp {
|
||||
attemptsKey := otpAttemptsKey(req.Email)
|
||||
attempts, _ := db.Rdb.Incr(ctx, attemptsKey).Result()
|
||||
db.Rdb.Expire(ctx, attemptsKey, otpTTL)
|
||||
if attempts >= otpMaxAttempts {
|
||||
db.Rdb.Del(ctx, key, attemptsKey)
|
||||
return utils.BadRequest(c, "too many incorrect attempts, please request a new code")
|
||||
}
|
||||
return utils.Unauthorized(c, "incorrect verification code")
|
||||
}
|
||||
|
||||
db.Rdb.Del(ctx, key, otpAttemptsKey(req.Email))
|
||||
// Recorded so a follow-up PIN reset can prove this email was verified.
|
||||
db.Rdb.Set(ctx, otpVerifiedKey(req.Email), "1", otpVerifiedTTL)
|
||||
return utils.Message(c, "email verified successfully")
|
||||
}
|
||||
}
|
||||
80
cors_test.go
Normal file
80
cors_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// Which Origin headers count as "this machine".
|
||||
//
|
||||
// This exists because Flutter Web's dev server binds a random high port on every
|
||||
// launch, so no fixed allowlist can name it — the app hit
|
||||
// PreflightMissingAllowOriginHeader from http://localhost:65256 and would have
|
||||
// hit it again from a different port tomorrow.
|
||||
//
|
||||
// The reason it is worth a test rather than a one-line helper: the obvious
|
||||
// implementation is a prefix or substring match on "localhost", and that quietly
|
||||
// admits http://localhost.attacker.com — a completely different machine that
|
||||
// merely starts with the right word. Combined with AllowCredentials, that would
|
||||
// let an attacker-controlled page make authenticated calls as the signed-in user.
|
||||
// A parsed-host comparison is the only version that is actually safe, and this
|
||||
// pins it.
|
||||
|
||||
func TestLoopbackOriginsAreAllowed(t *testing.T) {
|
||||
for _, origin := range []string{
|
||||
"http://localhost:65256", // the port Flutter Web picked; it changes every run
|
||||
"http://localhost:5173",
|
||||
"http://localhost",
|
||||
"https://localhost:8443",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1",
|
||||
"http://[::1]:8080",
|
||||
} {
|
||||
if !isLoopbackOrigin(origin) {
|
||||
t.Errorf("%q is this machine and should be allowed in development", origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookalikeOriginsAreRefused(t *testing.T) {
|
||||
// Every one of these contains "localhost" or "127.0.0.1" as a substring and
|
||||
// is a different host. A prefix or Contains check would admit all of them.
|
||||
for _, origin := range []string{
|
||||
"http://localhost.attacker.com",
|
||||
"https://localhost.evil.io:443",
|
||||
"http://notlocalhost",
|
||||
"http://mylocalhost:3000",
|
||||
"http://127.0.0.1.attacker.com",
|
||||
"http://evil.com/?x=http://localhost:3000",
|
||||
"http://evil.com#localhost",
|
||||
} {
|
||||
if isLoopbackOrigin(origin) {
|
||||
t.Errorf("%q is NOT this machine and must be refused", origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonHTTPSchemesAreRefused(t *testing.T) {
|
||||
// An Origin is a scheme/host/port triple. Anything else is either a browser
|
||||
// that will not send it or something forged, and neither should be trusted.
|
||||
for _, origin := range []string{
|
||||
"file://localhost",
|
||||
"ftp://localhost:21",
|
||||
"javascript:alert(1)",
|
||||
"chrome-extension://abcdefghijklmnop",
|
||||
} {
|
||||
if isLoopbackOrigin(origin) {
|
||||
t.Errorf("%q is not an http(s) origin and must be refused", origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedOriginsAreRefused(t *testing.T) {
|
||||
for _, origin := range []string{
|
||||
"",
|
||||
"null", // what a sandboxed iframe sends
|
||||
"not a url at all",
|
||||
"://missing-scheme",
|
||||
} {
|
||||
if isLoopbackOrigin(origin) {
|
||||
t.Errorf("%q is not a usable origin and must be refused", origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
80
docs/CHANGELOG.md
Normal file
80
docs/CHANGELOG.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Doormile Backend — Changelog & Summary of Recent Changes
|
||||
|
||||
This document provides a comprehensive log of the major features, architectural upgrades, schema modifications, and API changes recently implemented in the `doormile_backend`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Customer App v1 API Rebuild (`doormile_cx`)
|
||||
|
||||
The customer-facing surface was completely rebuilt from the legacy single-destination / PIN-based flow to the production Customer App v1 contract.
|
||||
|
||||
### Key Additions & Refactorings:
|
||||
- **Authentication (`controllers/cxAuthController.go`)**:
|
||||
- Replaced legacy PIN authentication with 4-digit OTP verification via SMS/Email (`/customer/auth/send-otp`, `/customer/auth/verify-otp`).
|
||||
- Refresh token rotation and session management (`/customer/auth/refresh`, `/customer/auth/logout`, `/customer/auth/logout-all`).
|
||||
- Profile management and saved delivery locations (`/customer/profile`, `/customer/locations`).
|
||||
- **Multi-Destination Booking & Fanout (`controllers/cxBookingController.go`, `controllers/cxPickupFanout.go`)**:
|
||||
- Support for multi-stop pickups and multi-destination consignments under single parent bookings.
|
||||
- Pickup fanout algorithm ensuring correct route clustering and assignment creation.
|
||||
- **Dynamic Fare Engine (`controllers/cxFareController.go`)**:
|
||||
- Automated distance-based, weight-tiered, and peak-hour pricing calculations (`/customer/fare/estimate`).
|
||||
- **Stage Rollup & Tracking Lifecycle (`internal/cxstage/stage.go`, `controllers/cxBookingView.go`)**:
|
||||
- Unified lifecycle status machine mapping complex internal consignment and hub states to simplified customer stages (`created`, `assigned`, `arrived`, `picked_up`, `at_hub`, `out_for_delivery`, `delivered`, `cancelled`).
|
||||
- Strict 5-minute cancellation window enforced server-side.
|
||||
- **Public Reference Obfuscation (`controllers/cxIdentifiers.go`, `controllers/cxIdentifierScramble.go`)**:
|
||||
- Replaced sequential database ID leaks in public endpoints with collision-resistant Sqids/hash tokens.
|
||||
- **Catalogue, Serviceability & Places (`controllers/cxCatalogueController.go`, `controllers/cxPlacesController.go`)**:
|
||||
- Dynamic serviceability limits, slot schedules, and parcel category catalog queries (`/customer/catalogue`, `/customer/serviceability/limits`, `/customer/serviceability/slots`).
|
||||
- Places autocomplete proxy and reverse geocoding (`/customer/places/autocomplete`, `/customer/places/reverse-geocode`).
|
||||
- **Push Device Token Registry (`controllers/cxDeviceController.go`)**:
|
||||
- FCM/APNS device token registration for push notifications (`/customer/devices/register`, `/customer/devices/deregister`).
|
||||
- **Ops Staging Overrides (`controllers/cxOpsController.go`)**:
|
||||
- Development and QA testing endpoint for simulating order stage transitions in non-production environments (`POST /ops/bookings/:ref/stage`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Logistics Base Handover & Hub Routing
|
||||
|
||||
- **Logistics Handover (`controllers/logisticsHandoverController.go`)**:
|
||||
- Handover workflows between milers and logistics bases / hubs.
|
||||
- Audit logging of parcel check-ins and handoffs.
|
||||
- **Hub Inbound Processing (`controllers/hubInboundController.go`)**:
|
||||
- Bag scanning, parcel inwarding, and multi-hub dispatch reconciliation.
|
||||
- **Leg Optimizer & Routing (`internal/legs/legs.go`, `internal/routing/optimizer.go`)**:
|
||||
- Multi-hop inter-hub routing and transit leg calculations.
|
||||
|
||||
---
|
||||
|
||||
## 3. Miler App & Assignment Enhancements
|
||||
|
||||
- **Arrival Confirmation Facts**:
|
||||
- Added support for `reachedat` / `arrivedat` timestamps on booking assignments and miler action payloads.
|
||||
- **Miler POD S3/Spaces Upload (`internal/storage/spaces.go`)**:
|
||||
- Presigned upload URL generation (`/miler/uploads/sign`) allowing milers to upload Proof of Delivery photos directly to object storage.
|
||||
- **Tenant Context**:
|
||||
- Exposed `tenantname` in `verify-pin` and miler profile responses.
|
||||
- **Consignment Status Check Expansion**:
|
||||
- Updated database checks to permit `Collected_By_Miler` and `Cancelled` statuses.
|
||||
|
||||
---
|
||||
|
||||
## 4. Middleware, Observability & Core Utilities
|
||||
|
||||
- **Request ID & Structured Logging (`middlewares/requestid.go`, `middlewares/logger.go`)**:
|
||||
- Correlation IDs attached to incoming requests and structured log entries.
|
||||
- **Epoch Timestamp Conversions (`utils/epoch.go`)**:
|
||||
- Standardized millisecond/second epoch converters for unified JSON responses.
|
||||
- **Database Migrations & Models (`models/customer_app.go`, `migrations/migrate.go`)**:
|
||||
- Database schema migrations for customer auth tokens, devices, OTP logs, and extended booking columns.
|
||||
|
||||
---
|
||||
|
||||
## 5. Comprehensive Test Suite
|
||||
|
||||
Added automated test suites covering all new and modified packages:
|
||||
- `controllers/cxCustomerApp_test.go` — Customer auth, booking creation, and validation tests.
|
||||
- `controllers/cxHttp_test.go` — End-to-end HTTP endpoint tests.
|
||||
- `routes/routes_customer_test.go` — Route registration and regression guards.
|
||||
- `internal/cxstage/stage_test.go` — Lifecycle state rollup logic and cancellation-window enforcement.
|
||||
- `utils/epoch_test.go` — Epoch timestamp validation.
|
||||
- `controllers/logisticsHandover_test.go` & `controllers/logisticsRouting_test.go` — Handover and routing tests.
|
||||
@@ -13,8 +13,14 @@ things a new dev (or a fresh Claude session on another machine) needs:
|
||||
Related docs already in this repo:
|
||||
- [`CLAUDE.md`](../CLAUDE.md) — the full project memory (architecture, data
|
||||
model, route surface). Start there for *what the system is*.
|
||||
- [`docs/CHANGELOG.md`](CHANGELOG.md) — summary of recent backend changes and new endpoints.
|
||||
- [`docs/customer-app-api.md`](customer-app-api.md) — Customer App v1 API design and endpoint specs.
|
||||
- [`docs/customer-api-testing.md`](customer-api-testing.md) — step-by-step test guide and curl references for customer endpoints.
|
||||
- [`docs/openapi-customer.yaml`](openapi-customer.yaml) — OpenAPI 3.0 specification for customer API.
|
||||
- [`docs/doormile-flow.md`](doormile-flow.md) — end-to-end booking/assignment flow.
|
||||
- [`docs/miler-app-api.md`](miler-app-api.md), [`docs/express-console-api.md`](express-console-api.md) — API contracts.
|
||||
- [`docs/logistics-base-handover.md`](logistics-base-handover.md) — the pickup-source
|
||||
and base-handover flow (requests 25–31), including the state transitions.
|
||||
- [`docs/jupiter2doormile.md`](jupiter2doormile.md) — the legacy→new migration map.
|
||||
- [`docs/test-booking-runbook.md`](test-booking-runbook.md) — how to run a test booking.
|
||||
- [`skills.md`](../skills.md) — note on the installed Claude skill pack.
|
||||
@@ -133,6 +139,13 @@ scope fits; don't let a broad trigger override house style.
|
||||
- **Known live flag:** `MILER_COLLECTED_STATE_ENABLED=true` (hyperlocal two-step
|
||||
pickup). `TRUSTED_PROXIES` must be set (api.doormile.com sits behind a
|
||||
reverse proxy) or per-IP rate limits collapse all clients into one bucket.
|
||||
- **`MILER_HUB_HANDOVER_ENABLED` — default off, and must stay off** until a rider
|
||||
build that calls `POST /miler/consignments/:id/inward-at-hub` is live. On, a
|
||||
hub-routed parcel stops at `Created` until the rider records the handover; off,
|
||||
pickup-complete marks it `Inwarded_at_Hub` immediately, which is what the
|
||||
deployed app expects. Flipping it early strands every intercity parcel on
|
||||
`Created` with no button in the app to advance it and no row in any base's
|
||||
received list. See [`logistics-base-handover.md`](logistics-base-handover.md).
|
||||
|
||||
### 2.4 Timezone convention (subtle — read before touching any time field)
|
||||
The DB and backend time helpers run on **IST (Asia/Kolkata) wall-clock**.
|
||||
@@ -146,7 +159,10 @@ Redis zset scores and time-window queries by 5h30m.
|
||||
Status-column CHECK constraints are **not** created by GORM AutoMigrate — they
|
||||
predate this codebase. Adding a new status *constant* in Go is not enough; the
|
||||
DB rejects it with **SQLSTATE 23514**. Before adding any status enum value,
|
||||
widen the matching `*_status_check` constraint in `migrations/migrate.go`.
|
||||
widen the matching `*_status_check` constraint in `migrations/migrate.go`. This
|
||||
is also why the base-handover reconciliation path raises a `Lost` exception
|
||||
rather than a more precise `Handover_Not_Received` — the latter would need
|
||||
`consignmentexceptions` widened first.
|
||||
Constraints exist on: `consignments`, `pickupbookings`,
|
||||
`milerprofiles.availabilitystatus`, `bookingassignments`,
|
||||
`consignmentexceptions`, `tripsheets`. `consignmenthistory` has no status check.
|
||||
|
||||
392
docs/customer-api-testing.md
Normal file
392
docs/customer-api-testing.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# Customer API — file reference & test guide
|
||||
|
||||
Companion to [`customer-app-api.md`](customer-app-api.md), which explains *why*.
|
||||
This one is the flat reference: what each file is for, and how to call every
|
||||
endpoint.
|
||||
|
||||
**Base URL:** `https://api.doormile.com/api/v1`
|
||||
**Staging:** `https://staging-api.doormile.com/api/v1`
|
||||
|
||||
---
|
||||
|
||||
## 1. What `seed_customer_app.sql` is for
|
||||
|
||||
**It is the only thing that makes the booking form work.** The customer app's
|
||||
first four calls read from tables that ship empty, so without this file the app
|
||||
opens to a blank state picker and nothing can be booked.
|
||||
|
||||
Run it **once, after the Go service has started at least once** — the service
|
||||
runs `AutoMigrate` on boot (`main.go:97`), and this file fills the tables that
|
||||
creates. Running it before the first boot fails: the tables do not exist yet.
|
||||
|
||||
```bash
|
||||
psql "postgres://admin:PASSWORD@HOST:5433/logistics" -f seed_customer_app.sql
|
||||
```
|
||||
|
||||
It is **idempotent** — safe to re-run. Every insert is `ON CONFLICT DO UPDATE`
|
||||
or guarded by `WHERE NOT EXISTS`.
|
||||
|
||||
### What it inserts, and why each part matters
|
||||
|
||||
| Rows | Purpose |
|
||||
|---|---|
|
||||
| **5 states** (TN, KL, KA, TG, PY) | `GET /serviceability/states`. Puducherry is seeded serviceable with **no open district** on purpose — it is the only way to exercise the app's "Opening soon" screen. |
|
||||
| **16 districts** | `GET /serviceability/states/{code}/districts`. Madurai, Kozhikode and Dakshina Kannada are seeded **unavailable with a reason**, because the app renders those names in a "coming soon" line. Each carries `centrelatitude`/`centrelongitude` — for most destinations that is the *only* geography the parcel has until the miler corrects it at the door, and it is what the fare estimate is priced against. |
|
||||
| **6 pickup slot templates** | `GET /pickup-slots`. Template `t5` is seeded at **capacity 0** — the only way to reach the "Fully booked" state in design QA without actually filling a window. |
|
||||
| **1 booking-limits row** | `GET /config/booking-limits`. **`maxdestinations` is seeded at 1, not 5** — see §4 below. |
|
||||
| **2 test customers** | `+919999900001`, `+919999900002`. Pair with `CX_STAGING_OTP=1234` so automated tests can sign in without a real handset. |
|
||||
| **A hub-attachment UPDATE** | Links each district to its nearest active hub by proximity, so the destination card can name a serving base. Done by distance rather than hard-coded ids because hub ids differ per environment. |
|
||||
|
||||
It also **mirrors the client's in-app mock exactly**, because those cases back
|
||||
the app's 18 widget tests and its design QA. Change the seeded availability and
|
||||
a green client test suite stops meaning anything.
|
||||
|
||||
---
|
||||
|
||||
## 2. Changed files — what each one is for
|
||||
|
||||
### New files
|
||||
|
||||
| File | What it is for |
|
||||
|---|---|
|
||||
| `models/customer_app.go` | The 9 new tables. `BookingDestination` is the one that makes multi-destination expressible. |
|
||||
| `internal/cxstage/stage.go` | Writes the customer's stage timeline from the miler's operational writes. The one place a stage is recorded. |
|
||||
| `internal/sms/sms.go` | The seam for an SMS gateway. **No provider is wired in** — codes go to the log until one is. |
|
||||
| `controllers/cxAuthController.go` | OTP auth: request, signup, verify, refresh, logout, me. |
|
||||
| `controllers/cxCatalogueController.go` | States, districts, pickup slots, booking limits. |
|
||||
| `controllers/cxPlacesController.go` | Geocoder proxy + Redis cache. The app is never handed a map key. |
|
||||
| `controllers/cxFareController.go` | Fare estimate for one pickup visit. |
|
||||
| `controllers/cxBookingController.go` | Create, list, detail, cancel, patch destination, order-by-tracking-id. |
|
||||
| `controllers/cxBookingView.go` | Builds the canonical booking JSON. Every read that returns a booking goes through it. |
|
||||
| `controllers/cxPickupFanout.go` | Splits one pickup into N orders at pickup-complete. |
|
||||
| `controllers/cxConsignmentHooks.go` | Maps a consignment status to a per-order customer stage. |
|
||||
| `controllers/cxDeviceController.go` | Push token registration. |
|
||||
| `controllers/cxIdentifiers.go` | Mints `DM-######` and `DMX########` from Postgres sequences. |
|
||||
| `controllers/cxOpsController.go` | QA-only: force a booking to any stage. Double-gated. |
|
||||
| `utils/response_cx.go` | The customer response envelope. Separate from `utils.OK`/`Fail` on purpose. |
|
||||
| `utils/epoch.go` | IST→epoch-millis conversion. Every customer timestamp goes through it. |
|
||||
| `middlewares/requestid.go` | Echoes `X-Request-Id` on every response. |
|
||||
| `seed_customer_app.sql` | §1 above. |
|
||||
| `docs/openapi-customer.yaml` | The spec — 24 paths, 28 operations. |
|
||||
| `docs/customer-app-api.md` | The change record and reasoning. |
|
||||
| `scratch/cx_readonly_probe.go` | Read-only check of whether the migration is additive against a real DB. Writes nothing. |
|
||||
| `controllers/cxHttp_test.go` | HTTP status-code and envelope tests. |
|
||||
| `controllers/cxCustomerApp_test.go` | Pure-logic and fan-out tests. |
|
||||
| `routes/routes_customer_test.go` | Routing + the guard proving miler/console are untouched. |
|
||||
| `internal/cxstage/stage_test.go`, `utils/epoch_test.go` | Stage rollup and timestamp tests. |
|
||||
|
||||
### Modified files
|
||||
|
||||
| File | What changed |
|
||||
|---|---|
|
||||
| `routes/routes.go` | Customer routes 19 → 28. |
|
||||
| `controllers/customerController.go` | PIN auth + old booking handlers **deleted**; profile and locations kept. |
|
||||
| `controllers/milerController.go` | Pickup-complete now fans out; parcel-confirm takes photos; stage hooks. |
|
||||
| `controllers/milerAppController.go` | Rider queue emits one stop per order; two broken lookups fixed. |
|
||||
| `controllers/logisticsHandoverController.go` | Consignment→booking lookup fixed; `in_transit` recorded. |
|
||||
| `controllers/adminController.go` | Ops cancel now reaches the customer's projection. |
|
||||
| `controllers/booking_assignment_service.go` | Records `assigned` on manual assignment. |
|
||||
| `internal/assignment/crm_assignment.go` | Records `assigned` on auto-assignment. |
|
||||
| `models/booking.go` | 10 new columns on `PickupBooking`, 1 on `BookingParcel`. |
|
||||
| `constants/constants.go` | 9 stage keys, statuses, actor types. |
|
||||
| `migrations/migrate.go` | 9 tables, 2 sequences, 2 indexes. |
|
||||
| `middlewares/idempotency.go` | Anonymous-caller scoping (security fix). |
|
||||
| `middlewares/city_gate.go` | Exported `PincodeInOperatingCity`. |
|
||||
| `middlewares/logger.go` | Logs `client`, `platform`, `requestid`. |
|
||||
| `internal/storage/spaces.go` | `PresignGet` for signed parcel photos. |
|
||||
| `config/config.go` | `GEOCODER_URL`, `GEOCODER_EMAIL`. |
|
||||
| `utils/helper.go` | `GenerateTokenWithTTL`. |
|
||||
| `main.go` | Registers `RequestID()`. |
|
||||
| `dto/auth.go` | Retired PIN DTOs removed. |
|
||||
| `controllers/otpController.go` | **Deleted** — its two routes were customer-only and are superseded. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Every endpoint, with a request you can paste
|
||||
|
||||
Every URL below is complete — copy and paste it. Production host shown; for
|
||||
staging swap `api.doormile.com` for `staging-api.doormile.com`, nothing else
|
||||
changes.
|
||||
|
||||
Authenticated calls need one header, using the `accessToken` returned by
|
||||
`/customer/auth/otp/verify`:
|
||||
|
||||
```
|
||||
Authorization: Bearer <accessToken>
|
||||
```
|
||||
|
||||
### 3.1 Auth — no token required
|
||||
|
||||
**1. Request a sign-in code**
|
||||
```
|
||||
POST https://api.doormile.com/api/v1/customer/auth/otp/request
|
||||
```
|
||||
```json
|
||||
{ "identifier": "+919999900001" }
|
||||
```
|
||||
|
||||
**2. Sign up (creates the account AND sends the code)**
|
||||
```
|
||||
POST https://api.doormile.com/api/v1/customer/auth/signup
|
||||
```
|
||||
```json
|
||||
{ "name": "Joe Oommen", "phone": "+919876543210", "email": "joe@example.com" }
|
||||
```
|
||||
|
||||
**3. Verify the code → returns the session**
|
||||
```
|
||||
POST https://api.doormile.com/api/v1/customer/auth/otp/verify
|
||||
Header: Idempotency-Key: <any unique string>
|
||||
```
|
||||
```json
|
||||
{ "identifier": "+919999900001", "code": "1234" }
|
||||
```
|
||||
> On staging `code` is whatever `CX_STAGING_OTP` is set to. Copy `data.accessToken`
|
||||
> from the response — everything below needs it.
|
||||
|
||||
**4. Rotate the session**
|
||||
```
|
||||
POST https://api.doormile.com/api/v1/customer/auth/refresh
|
||||
```
|
||||
```json
|
||||
{ "refreshToken": "<refreshToken from verify>" }
|
||||
```
|
||||
|
||||
**5. Who am I** — `GET https://api.doormile.com/api/v1/customer/auth/me` (token required, no body)
|
||||
|
||||
**6. Sign out**
|
||||
```
|
||||
POST https://api.doormile.com/api/v1/customer/auth/logout
|
||||
```
|
||||
```json
|
||||
{ "refreshToken": "<token>", "deviceToken": "<fcm token>" }
|
||||
```
|
||||
|
||||
### 3.2 Catalogue — no token required
|
||||
|
||||
| # | Method | URL |
|
||||
|---|---|---|
|
||||
| 7 | GET | `https://api.doormile.com/api/v1/customer/serviceability/states` |
|
||||
| 8 | GET | `https://api.doormile.com/api/v1/customer/serviceability/states/TN/districts` |
|
||||
| 9 | GET | `https://api.doormile.com/api/v1/customer/pickup-slots?lat=11.0168&lng=76.9558` |
|
||||
| 10 | GET | `https://api.doormile.com/api/v1/customer/config/booking-limits?lat=11.0168&lng=76.9558` |
|
||||
|
||||
No bodies. Call **9** first in any booking test — you need a real `slotId` from
|
||||
it, and slot ids expire.
|
||||
|
||||
### 3.3 Places — token required
|
||||
|
||||
| # | Method | URL |
|
||||
|---|---|---|
|
||||
| 11 | GET | `https://api.doormile.com/api/v1/customer/places/reverse-geocode?lat=11.0168&lng=76.9558` |
|
||||
| 12 | GET | `https://api.doormile.com/api/v1/customer/places/search?q=brookefields&lat=11.0168&lng=76.9558` |
|
||||
|
||||
Empty `q` on **12** returns the customer's saved and recent places.
|
||||
|
||||
### 3.4 Fare estimate — token required
|
||||
|
||||
**13.**
|
||||
```
|
||||
POST https://api.doormile.com/api/v1/customer/fare/estimate
|
||||
```
|
||||
```json
|
||||
{
|
||||
"pickup": { "lat": 11.0168, "lng": 76.9558 },
|
||||
"destinations": [
|
||||
{ "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 2 },
|
||||
{ "stateCode": "KL", "districtCode": "KL-EKM", "packageCount": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Bookings — token required
|
||||
|
||||
**14. Create a pickup**
|
||||
```
|
||||
POST https://api.doormile.com/api/v1/customer/bookings
|
||||
Header: Idempotency-Key: <any unique string>
|
||||
```
|
||||
```json
|
||||
{
|
||||
"pickup": {
|
||||
"title": "12 Nehru Street",
|
||||
"sub": "Gandhipuram, Coimbatore 641012",
|
||||
"lat": 11.0168,
|
||||
"lng": 76.9558
|
||||
},
|
||||
"slotId": "PASTE_A_REAL_ID_FROM_ENDPOINT_9",
|
||||
"destinations": [
|
||||
{
|
||||
"stateCode": "TN",
|
||||
"districtCode": "TN-MAA",
|
||||
"packageCount": 2,
|
||||
"details": {
|
||||
"street": "12th Main",
|
||||
"building": "3B",
|
||||
"landmark": "Near bus stand",
|
||||
"recipientName": "Meera S",
|
||||
"recipientPhone": "+919884412210",
|
||||
"instructions": "Call before delivery",
|
||||
"codAmount": 1200,
|
||||
"pin": { "lat": 13.0827, "lng": 80.2707 }
|
||||
}
|
||||
}
|
||||
],
|
||||
"estimate": { "min": 167, "max": 267 }
|
||||
}
|
||||
```
|
||||
> ⚠️ **One destination only** while `maxdestinations = 1` (§4). Adding a second
|
||||
> returns `400 "Up to 1 destinations per pickup"`.
|
||||
> Returns `reference` (`DM-######`) and **no tracking number** — those are minted
|
||||
> when the miler completes the pickup.
|
||||
|
||||
**15. List** — `GET https://api.doormile.com/api/v1/customer/bookings?status=active&limit=20`
|
||||
`status` is `active` | `completed` | `cancelled`. Page with `&cursor=<nextCursor>`.
|
||||
|
||||
**16. Detail** — `GET https://api.doormile.com/api/v1/customer/bookings/DM-482913`
|
||||
The tracking screen and receipt both render from this. Supports `If-None-Match`.
|
||||
|
||||
**17. Cancel**
|
||||
```
|
||||
POST https://api.doormile.com/api/v1/customer/bookings/DM-482913/cancel
|
||||
```
|
||||
```json
|
||||
{ "reason": "Package not ready" }
|
||||
```
|
||||
> Allowed through `arrived`. From `picked_up` onward returns `409`.
|
||||
|
||||
**18. Fill in a destination's details after booking**
|
||||
```
|
||||
PATCH https://api.doormile.com/api/v1/customer/bookings/DM-482913/destinations/0
|
||||
```
|
||||
```json
|
||||
{
|
||||
"street": "12th Main",
|
||||
"landmark": "Near bus stand",
|
||||
"recipientName": "Meera S",
|
||||
"recipientPhone": "+919884412210",
|
||||
"instructions": "Call before delivery",
|
||||
"pin": { "lat": 13.08, "lng": 80.27 }
|
||||
}
|
||||
```
|
||||
> `0` is the destination's position. Any subset of fields; `null` clears one.
|
||||
|
||||
**19. One order by tracking number** — `GET https://api.doormile.com/api/v1/customer/orders/DMX10482913`
|
||||
|
||||
### 3.6 Devices — token required
|
||||
|
||||
**20. Register a push token**
|
||||
```
|
||||
POST https://api.doormile.com/api/v1/customer/devices
|
||||
```
|
||||
```json
|
||||
{ "token": "<fcm-token>", "platform": "android", "appVersion": "1.0.0+12" }
|
||||
```
|
||||
|
||||
**21. Unregister** — `DELETE https://api.doormile.com/api/v1/customer/devices/<fcm-token>`
|
||||
|
||||
### 3.7 Account — token required
|
||||
|
||||
**22. Profile** — `GET https://api.doormile.com/api/v1/customer/profile`
|
||||
|
||||
**23. Update profile**
|
||||
```
|
||||
PUT https://api.doormile.com/api/v1/customer/profile
|
||||
```
|
||||
```json
|
||||
{ "name": "Joe Oommen", "email": "joe@example.com", "defaultPincode": "641012" }
|
||||
```
|
||||
|
||||
**24. Saved addresses** — `GET https://api.doormile.com/api/v1/customer/locations`
|
||||
|
||||
**25. Save an address**
|
||||
```
|
||||
POST https://api.doormile.com/api/v1/customer/locations
|
||||
```
|
||||
```json
|
||||
{
|
||||
"label": "Home",
|
||||
"address": "12 Nehru Street, Gandhipuram",
|
||||
"landmark": "Near the bus stand",
|
||||
"city": "Coimbatore",
|
||||
"state": "Tamil Nadu",
|
||||
"pincode": "641012",
|
||||
"latitude": 11.0168,
|
||||
"longitude": 76.9558,
|
||||
"receivername": "Joe Oommen",
|
||||
"receiverphone": "+919876543210",
|
||||
"isdefault": true
|
||||
}
|
||||
```
|
||||
|
||||
**26. Update an address** — `PUT https://api.doormile.com/api/v1/customer/locations/1` (same body)
|
||||
**27. Delete an address** — `DELETE https://api.doormile.com/api/v1/customer/locations/1`
|
||||
|
||||
### 3.8 QA only — staging
|
||||
|
||||
**28. Force a booking to any stage**
|
||||
```
|
||||
POST https://api.doormile.com/api/v1/customer/ops/bookings/DM-482913/stage
|
||||
```
|
||||
```json
|
||||
{ "stage": "out_for_delivery", "reason": "design QA" }
|
||||
```
|
||||
> Returns `404` unless **both** `ENV != production` **and**
|
||||
> `CX_ALLOW_STAGE_OVERRIDE=true`. Valid stages: `booked`, `assigned`,
|
||||
> `on_the_way`, `arrived`, `picked_up`, `order_created`, `in_transit`,
|
||||
> `out_for_delivery`, `delivered`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Before you test — two things that will bite you
|
||||
|
||||
**`maxdestinations` is seeded at 1.** Not a bug. The fan-out works server-side,
|
||||
but the deployed rider app keys its stop list on `orderid`, which is
|
||||
booking-level — all three stops of a three-destination pickup collapse into one
|
||||
in its local store, and two parcels would have no stop and no way to be closed.
|
||||
Raise it only once a rider build keying on `consignmentid` is live:
|
||||
|
||||
```sql
|
||||
UPDATE customerbookinglimits SET maxdestinations = 5 WHERE applocationid IS NULL;
|
||||
```
|
||||
|
||||
**There is no SMS provider.** OTP codes are written to the application log and
|
||||
nowhere else. On staging set `CX_STAGING_OTP=1234` and use the seeded test
|
||||
accounts. It is refused when `ENV=production`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Suggested test order
|
||||
|
||||
1. `GET /serviceability/states` — proves the seed ran
|
||||
2. `GET /serviceability/states/TN/districts`
|
||||
3. `GET /pickup-slots?lat=&lng=` — **copy a real `slotId`**
|
||||
4. `GET /config/booking-limits` — confirm `maxDestinations: 1`
|
||||
5. `POST /auth/otp/request` with `+919999900001`
|
||||
6. `POST /auth/otp/verify` with the staging code — **copy `accessToken`**
|
||||
7. `GET /auth/me`
|
||||
8. `POST /fare/estimate`
|
||||
9. `POST /bookings` with the slot id from step 3
|
||||
10. `GET /bookings/{reference}` — the canonical object
|
||||
11. `PATCH /bookings/{reference}/destinations/0`
|
||||
12. `POST /ops/bookings/{reference}/stage` → `delivered`, then re-read step 10
|
||||
13. `POST /bookings/{reference}/cancel` on a fresh booking → expect `409` after
|
||||
`picked_up`, `200` before
|
||||
|
||||
## 6. Reading the responses
|
||||
|
||||
Success:
|
||||
```json
|
||||
{ "success": true, "data": { ... }, "message": "" }
|
||||
```
|
||||
List:
|
||||
```json
|
||||
{ "success": true, "data": [ ... ], "total": 9, "nextCursor": "1042", "message": "" }
|
||||
```
|
||||
Failure:
|
||||
```json
|
||||
{ "success": false, "message": "Add at least one destination", "error": { "code": "invalid" } }
|
||||
```
|
||||
Branch on `error.code`, never on the message text. Codes: `invalid`,
|
||||
`invalid_name`, `invalid_otp`, `unauthorized`, `forbidden`, `not_found`,
|
||||
`conflict`, `unserviceable`, `rate_limited`, `server_error`.
|
||||
291
docs/customer-app-api-crisp.md
Normal file
291
docs/customer-app-api-crisp.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# Doormile Customer App (`doormile_cx`) API — Quick Reference
|
||||
|
||||
> **Base URL:** `https://api.doormile.com/api/v1`
|
||||
> **Namespace:** `/customer/*` | **Auth Role:** `9` (Customer) | **Data Envelope:** `{ "success": true, "data": { ... } }`
|
||||
|
||||
---
|
||||
|
||||
## 1. Global Conventions
|
||||
|
||||
| Aspect | Specification | Details / Rules |
|
||||
|---|---|---|
|
||||
| **Naming** | `camelCase` | All request & response JSON fields use camelCase. |
|
||||
| **Timestamps** | Epoch milliseconds (UTC, `int64`) | Parse directly with `DateTime.fromMillisecondsSinceEpoch(ts)`. |
|
||||
| **Identifiers** | Sequence-backed Feistel permutation | **Booking Reference:** `DM-482913`<br>**Tracking Number:** `DMX10482913` |
|
||||
| **Headers** | `Authorization: Bearer <accessToken>`<br>`X-Client: doormile-cx/<version>+<build>`<br>`X-Platform: android | ios`<br>`Idempotency-Key: <uuid>` | • Required on all routes except pre-auth & catalogue.<br>• Client telemetry logged on every request.<br>• Idempotency supported on booking creation & OTP verification. |
|
||||
| **Token Lifetime** | Access: `1 hour` \| Refresh: `60 days` | Refresh tokens rotate on every use. Replaying a revoked token revokes the entire chain. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Complete Endpoint Reference (28 Routes)
|
||||
|
||||
### 🔐 Authentication (Pre-Auth & Session)
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|---|---|:---:|---|
|
||||
| `POST` | `/customer/auth/otp/request` | ❌ | Request OTP via SMS/Email (returns uniform success regardless of account existence). |
|
||||
| `POST` | `/customer/auth/otp/verify` | ❌ | Verify OTP, returns JWT tokens & customer profile. |
|
||||
| `POST` | `/customer/auth/signup` | ❌ | Register new customer or treat existing phone as sign-in. |
|
||||
| `POST` | `/customer/auth/refresh` | ❌ | Rotate refresh token to issue a new access token. |
|
||||
| `POST` | `/customer/auth/logout` | ✅ | Revoke session (omit `refreshToken` to sign out everywhere). |
|
||||
| `GET` | `/customer/auth/me` | ✅ | Fetch currently authenticated customer identity. |
|
||||
|
||||
### 📦 Serviceability & Catalogue
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|---|---|:---:|---|
|
||||
| `GET` | `/customer/serviceability/states` | ❌ | List serviceable states (`ETag` / 304 supported). |
|
||||
| `GET` | `/customer/serviceability/states/:code/districts` | ❌ | List serviceable districts in state (`ETag` / 304). |
|
||||
| `GET` | `/customer/pickup-slots` | ❌ | List today's & tomorrow's time slots with zone capacity (`ETag` / 304). |
|
||||
| `GET` | `/customer/config/booking-limits` | ❌ | Get booking limits (`maxDestinations`, `maxPackages`, `maxCodAmount`). |
|
||||
|
||||
### 📍 Places & Geocoding
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|---|---|:---:|---|
|
||||
| `GET` | `/customer/places/search?q=:query&lat=:lat&lng=:lng` | ✅ | Place search / autocomplete (empty query returns recent/saved). |
|
||||
| `GET` | `/customer/places/reverse-geocode?lat=:lat&lng=:lng` | ✅ | Reverse geocode coordinates to structured address. |
|
||||
|
||||
### 💰 Fare Estimation
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|---|---|:---:|---|
|
||||
| `POST` | `/customer/fare/estimate` | ✅ | Compute estimated fare band (`minRupees`–`maxRupees`) & route distance. |
|
||||
|
||||
### 🚚 Bookings & Tracking
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|---|---|:---:|---|
|
||||
| `POST` | `/customer/bookings` | ✅ | Create multi-destination pickup booking (`Idempotency-Key` supported). |
|
||||
| `GET` | `/customer/bookings?limit=20&cursor=:cursor&status=active` | ✅ | Keyset paginated customer bookings list. |
|
||||
| `GET` | `/customer/bookings/:reference` | ✅ | Get full booking detail & live tracking snapshot (`ETag` supported). |
|
||||
| `POST` | `/customer/bookings/:reference/cancel` | ✅ | Cancel booking (allowed strictly before rider status `arrived`). |
|
||||
| `PATCH` | `/customer/bookings/:reference/destinations/:index` | ✅ | Update recipient details on a pending destination stop. |
|
||||
| `GET` | `/customer/orders/:trackingId` | ✅ | Look up single order/parcel status by tracking number. |
|
||||
|
||||
### 👤 Profile & Saved Locations
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|---|---|:---:|---|
|
||||
| `GET` | `/customer/profile` | ✅ | Get customer profile details. |
|
||||
| `PUT` | `/customer/profile` | ✅ | Update customer name/email. |
|
||||
| `GET` | `/customer/locations` | ✅ | List up to 10 saved delivery/pickup addresses. |
|
||||
| `POST` | `/customer/locations` | ✅ | Save a new location. |
|
||||
| `PUT` | `/customer/locations/:id` | ✅ | Update an existing saved location. |
|
||||
| `DELETE` | `/customer/locations/:id` | ✅ | Delete a saved location. |
|
||||
|
||||
### 🔔 Devices & Push Notifications
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|---|---|:---:|---|
|
||||
| `POST` | `/customer/devices` | ✅ | Register FCM device token for milestone push updates. |
|
||||
| `DELETE` | `/customer/devices/:token` | ✅ | Unregister device token on logout. |
|
||||
|
||||
### 🛠️ Ops QA Testing (Non-Production Only)
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|---|---|:---:|---|
|
||||
| `POST` | `/customer/ops/bookings/:reference/stage` | ✅ | Double-gated test helper to walk a booking through stages. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Core Request & Response Payloads
|
||||
|
||||
### 1) OTP Verification (`POST /customer/auth/otp/verify`)
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"identifier": "+919876543210",
|
||||
"otp": "1234"
|
||||
}
|
||||
|
||||
// Response (200 OK)
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"accessToken": "eyJhbGciOi...",
|
||||
"refreshToken": "d8f1e2a3...",
|
||||
"expiresIn": 3600,
|
||||
"customer": {
|
||||
"id": 1042,
|
||||
"name": "Alex Kumar",
|
||||
"phone": "+919876543210",
|
||||
"email": "alex@example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2) Fare Estimate (`POST /customer/fare/estimate`)
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"pickup": {
|
||||
"latitude": 13.0827,
|
||||
"longitude": 80.2707,
|
||||
"stateCode": "TN",
|
||||
"districtCode": "CHN"
|
||||
},
|
||||
"destinations": [
|
||||
{
|
||||
"stateCode": "TN",
|
||||
"districtCode": "CHN",
|
||||
"packages": [{ "weightKg": 2.5 }]
|
||||
},
|
||||
{
|
||||
"stateCode": "KA",
|
||||
"districtCode": "BLR",
|
||||
"packages": [{ "weightKg": 1.0 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Response (200 OK)
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"minRupees": 240,
|
||||
"maxRupees": 310,
|
||||
"routeKm": 348.5,
|
||||
"breakdown": {
|
||||
"baseFare": 180,
|
||||
"additionalStopsUplift": 60,
|
||||
"estimatedTax": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3) Booking Creation (`POST /customer/bookings`)
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"slotId": "slot_20260908_t2",
|
||||
"pickup": {
|
||||
"title": "Home",
|
||||
"sub": "Flat 4B, Green Towers, Anna Nagar",
|
||||
"latitude": 13.0827,
|
||||
"longitude": 80.2707,
|
||||
"contactName": "Alex Kumar",
|
||||
"contactPhone": "+919876543210"
|
||||
},
|
||||
"destinations": [
|
||||
{
|
||||
"recipientName": "Priya S",
|
||||
"recipientPhone": "+919840123456",
|
||||
"building": "12/A",
|
||||
"street": "MG Road",
|
||||
"landmark": "Near Metro",
|
||||
"districtCode": "CHN",
|
||||
"stateCode": "TN",
|
||||
"latitude": 13.0850,
|
||||
"longitude": 80.2100,
|
||||
"packageCount": 1,
|
||||
"codAmount": 450
|
||||
}
|
||||
],
|
||||
"remarks": "Handle with care"
|
||||
}
|
||||
|
||||
// Response (201 Created)
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"reference": "DM-482913",
|
||||
"stage": "booked",
|
||||
"status": "active",
|
||||
"cancellable": true,
|
||||
"createdAt": 1788775499000,
|
||||
"slotId": "slot_20260908_t2",
|
||||
"pickup": {
|
||||
"title": "Home",
|
||||
"sub": "Flat 4B, Green Towers, Anna Nagar",
|
||||
"latitude": 13.0827,
|
||||
"longitude": 80.2707
|
||||
},
|
||||
"destinations": [
|
||||
{
|
||||
"index": 0,
|
||||
"stateName": "Tamil Nadu",
|
||||
"districtName": "Chennai",
|
||||
"packageCount": 1,
|
||||
"codAmount": 450,
|
||||
"trackingId": null,
|
||||
"stage": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Lifecycle & Stage Machine
|
||||
|
||||
### Stage Progression Sequence
|
||||
```
|
||||
[ booked ] ──► [ assigned ] ──► [ arrived ] ──► [ picked_up ] ──► [ order_created ]
|
||||
│ (cancel window closes)
|
||||
▼
|
||||
[ delivered ] ◄── [ out_for_delivery ] ◄── [ in_transit ]
|
||||
```
|
||||
|
||||
### Stage Vocabulary
|
||||
| Stage Key | Meaning / Trigger | Cancellable? | Scope |
|
||||
|---|---|:---:|---|
|
||||
| `booked` | Order submitted by customer | ✅ Yes | Booking |
|
||||
| `assigned` | Rider assigned to visit | ✅ Yes | Booking |
|
||||
| `on_the_way` | Rider accepted assignment | ✅ Yes | Booking |
|
||||
| `arrived` | Rider arrived at pickup point (**cancellation cutoff**) | ❌ No | Booking |
|
||||
| `picked_up` | Parcels collected and weighed | ❌ No | Booking |
|
||||
| `order_created` | Tracking IDs generated per destination | ❌ No | Per Destination |
|
||||
| `in_transit` | Parcels sorted / inwarded at hub | ❌ No | Per Destination |
|
||||
| `out_for_delivery` | Dispatched with delivery agent | ❌ No | Per Destination |
|
||||
| `delivered` | Successfully delivered to recipient | ❌ No | Per Destination |
|
||||
| `cancelled` | Cancelled by customer or ops before arrival | — | Terminal |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Rollup Rule:** The overall booking stage reflects the **slowest order**. If Destination 1 is `delivered` but Destination 2 is `in_transit`, the booking rollup remains `in_transit`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-App Impact & Compatibility
|
||||
|
||||
| Client / Component | Observable Change | Impact / Handling |
|
||||
|---|---|---|
|
||||
| **Miler App (Flutter)** | Multi-stop collection | Post-collection returns multiple stops sharing one `bookingid`. Keys must resolve by `consignmentid`. |
|
||||
| **Admin Console** | Booking numbers & Search | Display format is now `DM-482913`. Searches match exact substring. |
|
||||
| **Hub Console** | Tracking IDs & Inbound | Tracking numbers are now `DMX10482913`. |
|
||||
| **Safety Mitigation** | `maxDestinations` Gate | Configured in DB (`customerbookinglimits.maxdestinations = 1`) to keep fanout single-destination until mobile updates deploy. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Standard Error Codes & Envelopes
|
||||
|
||||
All errors return JSON in standard format:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "SLOT_UNAVAILABLE",
|
||||
"message": "That pickup time has passed — pick a new slot"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| HTTP Status | Error Code (`error.code`) | Meaning / Recommended Client Action |
|
||||
|---|---|---|
|
||||
| `400` | `INVALID_INPUT` / `SLOT_EXPIRED` | Validation error or expired slot date. Prompt user to re-select. |
|
||||
| `401` | `UNAUTHORIZED` | Token missing or expired. Redirect to OTP login / refresh session. |
|
||||
| `403` | `FORBIDDEN` | Caller lacks role 9 customer access. |
|
||||
| `404` | `NOT_FOUND` | Booking reference or tracking ID does not exist. |
|
||||
| `409` | `SLOT_CAPACITY_FULL` | Slot filled up during checkout race. Prompt user to choose another time. |
|
||||
| `409` | `BOOKING_NOT_CANCELLABLE` | Customer attempted cancel after rider `arrived`. Show un-cancellable alert. |
|
||||
| `422` | `UNSERVICEABLE_PINCODE` | Location is outside active operating zones. |
|
||||
| `429` | `RATE_LIMITED` | OTP requests exceeded limit (max 5/hour). Show countdown timer. |
|
||||
| `500` | `INTERNAL_ERROR` | Generic server error (`X-Request-Id` logged). |
|
||||
|
||||
---
|
||||
|
||||
## 7. Environment Variables & Deploy Flags
|
||||
|
||||
```bash
|
||||
GEOCODER_URL=https://nominatim.openstreetmap.org # Geocoding proxy
|
||||
GEOCODER_EMAIL=ops@doormile.com # Nominatim contact policy
|
||||
MILER_CALL_PROXY= # Set to proxy number in production (protects rider PII)
|
||||
CX_STAGING_OTP=1234 # Fixed OTP for staging QA (disabled if ENV=production)
|
||||
CX_ID_SCRAMBLE_KEY= # Optional custom key for Feistel sequence permutation
|
||||
CX_ALLOW_STAGE_OVERRIDE=false # Double-gated QA stage override tool
|
||||
```
|
||||
928
docs/customer-app-api.md
Normal file
928
docs/customer-app-api.md
Normal file
@@ -0,0 +1,928 @@
|
||||
# Customer App API (`doormile_cx`) — change record & integration guide
|
||||
|
||||
**Audience:** the `doormile_cx` app developer, plus anyone reviewing this before
|
||||
it goes to production.
|
||||
**Backend:** Go + Fiber, `api.doormile.com/api/v1`, namespace `/customer/*`.
|
||||
**Status:** code complete, compiler- and unit-verified, **not yet run against a
|
||||
real database**. Read §11 before deploying.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| OpenAPI 3.1 spec | [`openapi-customer.yaml`](openapi-customer.yaml) |
|
||||
| Staging seed | [`../seed_customer_app.sql`](../seed_customer_app.sql) |
|
||||
| Requirements this answers | *Doormile — Backend Requirements (Customer App v1)* |
|
||||
| Reviewed | Once, by a reviewer with the Flutter apps but not this repo — **§13** records every finding and what changed |
|
||||
|
||||
---
|
||||
|
||||
## 0. The one question first: does this affect the Miler app or the consoles?
|
||||
|
||||
# YES.
|
||||
|
||||
Not "no". Anyone telling you otherwise has not read the diff. The customer work
|
||||
could not be built without touching shared code, because the thing it changes —
|
||||
one pickup producing many orders — is a fact the rider app and the consoles both
|
||||
have to cope with.
|
||||
|
||||
Here is the complete list, with severity. Nothing is omitted.
|
||||
|
||||
### 🔴 Behaviour changes those clients WILL observe
|
||||
|
||||
| # | What changed | Who sees it | Action needed |
|
||||
|---|---|---|---|
|
||||
| 1 | **Booking references are now `DM-482913`**, not `DM-BK-A1B2C3D4-48291`. New bookings only; existing rows keep their old strings. | Admin console, hub console, Miler app — anywhere a booking number is displayed | None in code (nothing parses the format). Tell ops the format changed. |
|
||||
| 2 | **Tracking numbers are now `DMX10482913`**, not `DM-TRK-A1B2C3D4-48291`. New consignments only. | Same as above, plus any printed label or customer-facing tracking link | Same. Check label templates for a hardcoded width. |
|
||||
| 3 | **`GET /miler/bookings` can return more than one row per booking.** A customer-app pickup with 3 destinations becomes 3 stops *after* collection — each with its own `consignmentid`, `trackingno`, address and COD. Before collection it is still 1 row. Console/express bookings are still always 1 row. | Miler app | **A multi-file change, not a one-liner. See §0.6.** |
|
||||
| 4 | **`POST /miler/bookings/{id}/pickup-complete` gained a `consignments` array** listing every order the pickup produced. All previous top-level fields still describe the first order, unchanged. | Miler app | Purely additive — safe to ignore, but a multi-destination build should read it. |
|
||||
| 5 | **`POST /miler/bookings/{id}/parcel` accepts an optional `photos: []` per parcel** (storage keys from `/miler/uploads/sign`). Response shape unchanged. | Miler app | Optional. Without photos the customer receipt shows a weight and no evidence. |
|
||||
| 6 | **`PickupBooking` JSON gained 10 fields** (`slotid`, `customerstage`, `customerstatus`, `estimateminrupees`, `estimatemaxrupees`, `routekm`, `pickuptitle`, `pickupsub`, `cancelreason`, `destinations`) and `BookingParcel` gained `bookingdestinationid`. These serialise on every admin/hub endpoint that returns the model raw. | Admin + hub consoles | Additive JSON. Safe for a JS client that reads named keys. |
|
||||
| 7 | **Every response now carries an `X-Request-Id` header.** | Everyone | None. Log it — it is how a support report gets correlated. |
|
||||
| 8 | **Unknown paths under `/customer/*` answer `401`, not `404`.** So the retired `doormile_customer_app` calling `POST /customer/login` gets `401 "authorization header is required"`. | The old customer app | Expected — that app is being retired. Pre-existing Fiber group behaviour, not introduced here. |
|
||||
|
||||
### 🟡 Internal changes with no client-visible contract change
|
||||
|
||||
| # | What changed | Why it is safe |
|
||||
|---|---|---|
|
||||
| 9 | Three miler endpoints now resolve a consignment through `bookingdestinations` instead of `pickupbookings.consignmentid` | Bug fix. Identical result for a single-destination booking; the old query returned *nothing* for orders 2..N. See §5. |
|
||||
| 10 | `assignMilerTx` and `commitAssignment` record a customer stage | Guarded: returns immediately for any booking whose source is not `Customer_App`. An express booking does zero extra writes. |
|
||||
| 11 | `AcceptMilerAssignment`, `BookingReachedCustomer`, `MilerCancelAssignment`, `MilerStartDelivery`, `MilerInwardConsignmentAtHub`, `MilerDeliverConsignment` record customer stages | Same guard. Response bodies unchanged. |
|
||||
| 12 | `middlewares.Idempotency` scopes anonymous callers differently | Authenticated key format is **byte-identical** to before (`idem:<uid>:<key>`), so no in-flight rider key is orphaned at deploy. See §5.4. |
|
||||
| 13 | Request logs gained `client`, `platform`, `requestid` fields | Log-only. |
|
||||
| 14 | `utils.GenerateToken` delegates to a new `GenerateTokenWithTTL` | Still 24h for miler/admin/hub. Only the customer surface passes a different TTL. |
|
||||
| 15 | `internal/storage` gained `PresignGet` | New function; nothing existing calls it. |
|
||||
| 16 | `middlewares/city_gate.go` gained an exported `PincodeInOperatingCity` | New function; `CityGateMiddleware` itself is unchanged. |
|
||||
| 17 | 9 new tables + 11 new columns via `AutoMigrate`; 2 sequences, 2 indexes | All additive and nullable/defaulted. See §11 for the deploy note. |
|
||||
|
||||
### 🟢 Deliberately NOT changed, though I was tempted
|
||||
|
||||
Two things I changed while building, then **reverted**, because they were miler
|
||||
behaviour and out of scope:
|
||||
|
||||
- **Rider availability after pickup-complete.** I had made a rider stay
|
||||
`Picked_Up` when a hyperlocal parcel goes straight to `Out_for_Delivery`. The
|
||||
endpoint has always set them `Available` there. The existing code comment says
|
||||
the opposite of what the code does — a pre-existing inconsistency I have
|
||||
flagged rather than silently "fixed", because changing it alters live rider
|
||||
availability and the dispatch pool. **Ops decision, not mine.**
|
||||
- **`POST /miler/consignments/{id}/deliver` error status.** I had changed a
|
||||
"not yours" failure from `404` to `403` for consistency with start-delivery.
|
||||
Reverted: the deployed rider app was built against `404` and its exact
|
||||
messages. Only the broken *lookup* is fixed.
|
||||
|
||||
---
|
||||
|
||||
## 0.5 Page-by-page: exactly which screens are affected
|
||||
|
||||
Read from the client source in this workspace (`doormile_crm` = admin console,
|
||||
`doormile_hub_console` = hub console), not inferred from the API. The Miler app
|
||||
is not in this workspace, so its rows are derived from the endpoints it calls and
|
||||
must be confirmed against the Flutter source.
|
||||
|
||||
Legend: 🔴 needs a code change · 🟡 visible change, no code change · 🟢 fixed by
|
||||
this work · ⚪ no impact
|
||||
|
||||
### Miler app (Flutter — verify against the app source)
|
||||
|
||||
| Screen | Endpoint behind it | Impact |
|
||||
|---|---|---|
|
||||
| **Home / my stops** | `GET /miler/bookings` | 🔴 **A pickup can now be several rows.** After collection, a 3-destination customer booking returns 3 stops sharing one `bookingid`. **Key the list on `consignmentid`.** New fields `destinationseq` / `destinationcount` give you "Stop 2 of 3", and `trackingno`, `recipientname`, `recipientphone` are now per stop. Before collection it is still exactly 1 row, and console/express bookings are always 1 row. |
|
||||
| **Pickup — complete** | `POST /miler/bookings/{id}/pickup-complete` | 🟡 Response gained `consignments[]` (every order the visit produced). All existing top-level fields still describe the first order and are unchanged, so an old build keeps working. |
|
||||
| **Pickup — weigh/photograph parcels** | `POST /miler/bookings/{id}/parcel` | 🟡 Each parcel now accepts an optional `photos: []` of storage keys from `/miler/uploads/sign`. Response shape unchanged. Without photos the customer's receipt shows a weight with no evidence behind it. |
|
||||
| **Delivery — deliver / skip** | `POST /miler/consignments/{id}/deliver` | ⚪ Contract unchanged. The internal lookup was broken for orders 2..N and is fixed; status codes and messages are byte-identical. |
|
||||
| **Base handover** | `POST /miler/consignments/{id}/inward-at-hub` | ⚪ Contract unchanged; same lookup fix. |
|
||||
| **Earnings** | `GET /miler/earnings` | 🟢 **Fixed before it shipped.** Customer-app jobs would have recorded ₹0 — see §5.6. |
|
||||
| **Any screen showing a tracking number** | — | 🟡 `DMX10482913` instead of `DM-TRK-A1B2C3D4-48291`. Ten characters shorter, so nothing overflows. |
|
||||
| Duty, breaks, notifications, support, profile, bases | — | ⚪ Untouched. |
|
||||
|
||||
### Admin console (`doormile_crm`)
|
||||
|
||||
Only **one** of its screens reads booking shape at all. Verified by grep across
|
||||
`src/pages`.
|
||||
|
||||
| Screen | File | Impact |
|
||||
|---|---|---|
|
||||
| **Orders list** | `src/pages/bookings/Bookings.jsx` | 🟡 Four things: (1) the **Booking No** column now shows `DM-482913` — column is `proportional(1.5)`, the string is shorter, nothing overflows; (2) **search** filters `bookingno` by substring, so typing `DM-BK` finds no new bookings — tell ops; (3) the **Delivery City** column reads the flat `deliverycity`, which is **destination 0 only** — a 3-destination pickup shows "Chennai" and does not hint at the other two; (4) the **Price** column reads `serviceoptions[0].estimatedprice` and 🟢 **would have shown "N/A" for every customer booking** until §5.6 was fixed. |
|
||||
| **Cancel order** (from the Orders list) | `POST /admin/bookings/{id}/cancel`, `.../bulk-cancel` | 🟢 **Fixed before it shipped.** An ops cancel never reached the customer's app — see §5.7. No console change needed; the fix is backend-side. |
|
||||
| Dashboard, Tenants, Customers, Pricing, Riders, Reports, Invoices, Team, Settings, Survey, auth & error screens | — | ⚪ None of them read booking or consignment shape. |
|
||||
|
||||
### Hub console (`doormile_hub_console`)
|
||||
|
||||
| Screen | File | Reads | Impact |
|
||||
|---|---|---|---|
|
||||
| **Inbound** | `src/pages/operations/Inbound.jsx` | `trackingno` | 🟡 Tracking-number format change is visible in the table. Destination cell already ellipsises at `maxWidth: 200px`. |
|
||||
| **Routing** | `src/pages/operations/Routing.jsx` | `trackingno` | 🟡 Format change visible in the package list and the detail card (monospace, `0.85rem`). Search-by-tracking-number still works — it matches the value the API returns, not a pattern. |
|
||||
| **Order Assignment** | `src/pages/operations/OrderAssignment.jsx` | `bookingid`, `deliveryaddress` | 🟡 The row id uses the numeric `bookingid`, so the ID-format change does not touch it. But `drop` reads the flat `deliveryaddress`, which is **destination 0 only** — staff assigning a 3-destination pickup see one address. Not broken; incomplete. |
|
||||
| **Rider Routes** | `src/pages/operations/RiderRoutes.jsx` | `bookingid`, `consignmentid`, `parcels` | 🟡 Reads `consignmentid` off the booking, which names only the **first** order. A rider carrying three parcels from one visit may render as one stop on this screen. |
|
||||
| **Tracking Map** | `src/pages/operations/TrackingMap.jsx` | `bookingid` | ⚪ Numeric id only. |
|
||||
| **Dashboard** | `src/pages/Dashboard.jsx` | `parcels` | ⚪ Counts only. Every parcel still hangs off the booking, so the totals stay correct. |
|
||||
| **Dispatch** | `src/pages/operations/Dispatch.jsx` | `parcels` | ⚪ As above. |
|
||||
| **Riders** | `src/pages/operations/Riders.jsx` | `parcels` | ⚪ As above. |
|
||||
| Hub Settings, Login, Signup | — | ⚪ Untouched. |
|
||||
|
||||
### What nobody has to change today
|
||||
|
||||
Multi-destination pickups **only exist once the new customer app is live**. Every
|
||||
booking in the database today has zero destination rows and therefore takes the
|
||||
single-leg path everywhere — the rider queue returns one row, the consoles show
|
||||
one address, and the fan-out never runs. The 🟡 "destination 0 only" rows above
|
||||
become real on the day the first three-destination pickup is booked, not on the
|
||||
day this deploys.
|
||||
|
||||
The exceptions that are live immediately: the **ID formats** (§0 items 1–2) and
|
||||
the extra **JSON fields** (§0 item 6).
|
||||
|
||||
---
|
||||
|
||||
## 0.6 The rider-app change, scoped properly
|
||||
|
||||
An earlier draft of this document said "key the list on `consignmentid`" as
|
||||
though that were a small edit. It is not, and the correction came from a review
|
||||
with access to the Flutter source that this backend repo does not have. Recorded
|
||||
here in full because the item is assigned to the app team and was understated.
|
||||
|
||||
**The rider app's identity key is `orderid`, not `bookingid`.** The adapter sets
|
||||
it at `lib/data/api_config.dart:321`:
|
||||
|
||||
```dart
|
||||
'orderid': ref ?? id, // ref = bookingreference, id = bookingid
|
||||
```
|
||||
|
||||
Both of those are **booking-level**, and all three destination rows of one
|
||||
pickup carry the same `bookingid` and the same `bookingreference`. So `orderid`
|
||||
is identical across the three — and `orderid` is what dedupes:
|
||||
|
||||
- `lib/data/accepted_store.dart:527` — accepted stops live in a `Map` keyed on
|
||||
`orderid`. Three stops collapse to one, and because that store is
|
||||
SharedPreferences-backed, **the collapse survives an app restart.**
|
||||
- `lib/data/work_repository.dart:314,321` and `order_events.dart` — per-order
|
||||
event stamps key on `orderid`, so orders 2..N overwrite order 1's clocks.
|
||||
|
||||
`consignmentid` is available to the adapter (`api_config.dart:443`), so the
|
||||
change is feasible — but it lands across `accepted_store`, `work_repository`,
|
||||
`order_events` and `assignment_lookup`.
|
||||
|
||||
**Until it ships, a multi-destination pickup shows the rider one stop instead of
|
||||
three** — the same customer-visible failure §5.1 fixed on the server. The server
|
||||
being right is not enough here.
|
||||
|
||||
**`destinationseq` / `destinationcount` are invisible to the app today.**
|
||||
`pickupFromBooking` builds a fixed map, so a field the adapter does not name is a
|
||||
field the app can never see (the file says as much around line 583). "Stop 2 of
|
||||
3" needs those two keys added to the adapter as well.
|
||||
|
||||
### The mitigation that needs no app release
|
||||
|
||||
`maxDestinations` is **server configuration**, not a constant — the client reads
|
||||
it from `GET /customer/config/booking-limits` and adapts. Setting
|
||||
`customerbookinglimits.maxdestinations = 1` keeps every pickup single-destination
|
||||
until the rider build lands, which means:
|
||||
|
||||
- the customer app can ship and book end to end today;
|
||||
- the fan-out code stays dormant, so no order can be stranded;
|
||||
- lifting the cap later is one `UPDATE`, with no deploy on any side.
|
||||
|
||||
**Recommendation: seed it at 1 and raise it only once a rider build that keys on
|
||||
`consignmentid` is live.** This is the same discipline
|
||||
`MILER_HUB_HANDOVER_ENABLED` already applies in this codebase — do not turn on a
|
||||
server behaviour the deployed rider app cannot complete.
|
||||
|
||||
---
|
||||
|
||||
## 0.7 Fan-out row audit — field by field
|
||||
|
||||
Requested audit, run against the code and locked down by tests. `GET /miler/bookings`
|
||||
after collection, one row per order.
|
||||
|
||||
| Field | Source | Destination-specific? | Test |
|
||||
|---|---|---|---|
|
||||
| `consignmentid` | `bookingdestinations.consignmentid` | ✅ | `TestFanoutTrackingAndConsignmentIdsStayWithTheirStop` |
|
||||
| `trackingno` | `bookingdestinations.trackingno` | ✅ | same |
|
||||
| `deliveryaddress` | built from the destination's building / street / landmark / district / state | ✅ | `TestFanoutStopsHaveDistinctAddresses` |
|
||||
| `deliverylatitude` | destination pin, else district centre | ✅ | `TestFanoutStopsHaveDistinctCoordinates` |
|
||||
| `deliverylongitude` | same | ✅ | same |
|
||||
| `recipientname` | `bookingdestinations.recipientname` | ✅ | `TestFanoutRecipientsStayWithTheirStop` |
|
||||
| `recipientphone` | `bookingdestinations.recipientphone` | ✅ | same |
|
||||
| `collectionamt` | consignment `codamount − codcollected`, falling back to the destination's own `codamount` | ✅ **newly added** | `TestFanoutCodIsNotCopiedAcrossDestinations` |
|
||||
| `destinationseq` | `bookingdestinations.seq` | ✅ | `TestFanoutRouteOrderFollowsDestinationSeq` |
|
||||
| `destinationcount` | number of stops on the pickup | ✅ uniform per booking | `TestFanoutDestinationCountIsUniform` |
|
||||
|
||||
**Three findings from running this audit:**
|
||||
|
||||
1. **`collectionamt` did not exist.** The endpoint emitted `codamount` only.
|
||||
Added as an additional key rather than a rename — `codamount` is what this
|
||||
endpoint has always sent and the deployed rider build parses it, so removing
|
||||
it would break every stop on every existing device. Both are written from one
|
||||
variable and cannot drift.
|
||||
|
||||
2. **COD was not on the stop at all.** It was resolved in the handler from the
|
||||
consignment map, so it could not be unit-proven, and it fell back to **0** if
|
||||
the consignment row failed to load. A rider shown ₹0 collects nothing. The
|
||||
stop now carries the destination's own `codamount` as the fallback.
|
||||
|
||||
3. **Stop ordering was not guaranteed.** `milerStopsForBooking` iterated the
|
||||
slice as given and relied entirely on the caller's `ORDER BY seq`. It worked,
|
||||
but the ordering *is the route the rider drives*, and leaving it as an
|
||||
unstated precondition means the next caller silently reorders someone's
|
||||
afternoon. It now sorts by `seq` on a copy.
|
||||
|
||||
**Booking-level fallback is confined to one place and one case:** a booking with
|
||||
**no destination rows** — every console/express booking and every row written
|
||||
before this table existed — produces exactly one stop from the booking's flat
|
||||
columns. A booking that *has* destination rows never reads them. Destination 0's
|
||||
coordinates are used as a fallback for stop 0 only, and only when that
|
||||
destination has no pin of its own, because that is the same row mirrored.
|
||||
|
||||
---
|
||||
|
||||
## 1. v1 requirements → what actually shipped
|
||||
|
||||
The requirements doc described the target. This is the delta between it and the
|
||||
code as it now stands.
|
||||
|
||||
### 1.1 Endpoints — all 22 requirement endpoints implemented
|
||||
|
||||
Route arithmetic, since three numbers in this document have to reconcile:
|
||||
**28 registered customer routes** = 8 pre-auth (4 auth + 4 catalogue) + 20
|
||||
authenticated. The 22 below is the count of endpoints the *requirements* asked
|
||||
for; the remaining 6 are the profile and saved-address routes in the table under
|
||||
it. The OpenAPI spec now documents all 28 (24 paths / 28 operations).
|
||||
|
||||
| § | Endpoint | Status | Deviation from the requirement |
|
||||
|---|---|---|---|
|
||||
| 4.1 | `POST /customer/auth/otp/request` | ✅ | Answers identically whether or not the account exists — telling an anonymous caller "no account found" would make this a directory of who is registered. |
|
||||
| 4.2 | `POST /customer/auth/signup` | ✅ | Existing phone treated as sign-in, as specified. |
|
||||
| 4.3 | `POST /customer/auth/otp/verify` | ✅ | Idempotency-Key honoured. |
|
||||
| 4.4 | `POST /customer/auth/refresh` | ✅ | Rotation. A **revoked** token replayed revokes the whole chain. |
|
||||
| 4.4 | `POST /customer/auth/logout` | ✅ | No `refreshToken` in the body ⇒ signs out everywhere. |
|
||||
| 4.4 | `GET /customer/auth/me` | ✅ | — |
|
||||
| 5.1 | `GET /customer/serviceability/states` | ✅ | + ETag/304. |
|
||||
| 5.2 | `GET .../states/{code}/districts` | ✅ | + ETag/304. |
|
||||
| 5.3 | `GET /customer/pickup-slots` | ✅ | Slot ids encode their date (`slot_20260905_t1`) so a stale cached slot resolves to *that* day and is refused, not silently booked today. |
|
||||
| 5.4 | `GET /customer/config/booking-limits` | ✅ | Accepts `lat`/`lng` for per-city limits. |
|
||||
| 6.1 | `GET /customer/places/reverse-geocode` | ✅ | On upstream failure returns a coordinate label, **not** an error. |
|
||||
| 6.2 | `GET /customer/places/search` | ✅ | Empty `q` ⇒ saved + recent places. |
|
||||
| 7 | `POST /customer/fare/estimate` | ✅ | — |
|
||||
| 9.1 | `POST /customer/bookings` | ✅ | — |
|
||||
| 9.2 | `GET /customer/bookings` | ✅ | **Keyset** pagination, not offset. |
|
||||
| 9.3 | `GET /customer/bookings/{reference}` | ✅ | + ETag/304 for polling. |
|
||||
| 9.4 | `POST .../cancel` | ✅ | Cancelling an already-cancelled booking returns **200**, not 409. |
|
||||
| 9.5 | `PATCH .../destinations/{index}` | ✅ | — |
|
||||
| 9.6 | `GET /customer/orders/{trackingId}` | ✅ | Returns the full booking object (the requirement offered a slimmer shape; the client already parses this one). |
|
||||
| 10 | `POST /customer/devices` | ✅ | + `DELETE /customer/devices/{token}`. |
|
||||
| 10 | Push on milestone change | ✅ | `on_the_way` and `order_created` deliberately silent, per §10. |
|
||||
| 11 | `POST /customer/ops/bookings/{ref}/stage` | ✅ | Double-gated. Walks every intermediate stage rather than jumping. |
|
||||
|
||||
**Also kept** (not in the requirements, not removed): `GET/PUT
|
||||
/customer/profile`, `GET/POST/PUT/DELETE /customer/locations` — these answer §13.5
|
||||
"saved addresses".
|
||||
|
||||
### Identifier scrambling — sequence-backed, but not readable
|
||||
|
||||
Both identifiers come off a Postgres sequence, because a sequence is the only
|
||||
generator here that can promise uniqueness: the columns are `UNIQUE`, and a
|
||||
random 8-digit id collides with ~43% probability by the ten-thousandth parcel —
|
||||
a rider unable to complete a pickup.
|
||||
|
||||
But a raw sequence is readable. `DMX10000042` and `DMX10000043` are visibly
|
||||
adjacent, so anyone holding two numbers learns the throughput between them, and
|
||||
anyone holding one can guess its neighbours. So the index goes through a keyed
|
||||
4-round Feistel permutation before it is formatted
|
||||
(`controllers/cxIdentifierScramble.go`):
|
||||
|
||||
```
|
||||
seq 10004200 -> DMX35031894 seq 100000 -> DM-499505
|
||||
seq 10004201 -> DMX31070203 seq 100001 -> DM-341775
|
||||
seq 10004202 -> DMX62788115 seq 100002 -> DM-106366
|
||||
```
|
||||
|
||||
A Feistel network is a bijection for **any** round function, so uniqueness is
|
||||
untouched. `TestBookingScrambleIsCollisionFree` proves it **exhaustively across
|
||||
all 900,000 booking references**; the tracking test covers 200,000 consecutive
|
||||
values; `TestFeistelIsAPermutation` checks the primitive itself exhaustively.
|
||||
|
||||
Deliberately **not** the obvious one-liner (multiply by a coprime). That is also
|
||||
a bijection, but it is *linear* — and a multi-destination pickup hands one
|
||||
customer three consecutive sequence values, so the differences between their
|
||||
three tracking numbers would all equal the multiplier. A single booking would
|
||||
reveal the mapping and make the whole range walkable.
|
||||
`TestOneBookingDoesNotLeakTheMapping` rejects that design.
|
||||
|
||||
Past the fixed-width range (900,000 bookings / 90,000,000 orders) the identifier
|
||||
grows a digit rather than wrapping onto one already issued. Uniqueness is never
|
||||
traded for appearance.
|
||||
|
||||
**This is not a security boundary.** Every route that resolves a tracking number
|
||||
is already authenticated and owner-scoped — that is what stops a stranger reading
|
||||
someone's parcel. This removes the leak in the identifier itself, so
|
||||
authorisation is not the only thing standing between an outsider and your volume
|
||||
figures. Key: `CX_ID_SCRAMBLE_KEY`; a working default ships so the scrambling can
|
||||
never be silently off.
|
||||
|
||||
### 1.2 Contract decisions the requirements left open
|
||||
|
||||
| Question | Decision | Consequence for the app |
|
||||
|---|---|---|
|
||||
| §3.2 field naming | **camelCase**, as preferred | **No mapping layer needed.** Served from purpose-built projections, so `/miler/*` keeps its lowercase keys. |
|
||||
| §3.3 timestamps | **Epoch milliseconds, UTC, integer** | `DateTime.fromMillisecondsSinceEpoch(int)` works as-is. |
|
||||
| §3.1 envelope | Payload in `data` on **every** response, auth included | One parser for the whole surface. |
|
||||
| §9.6 order shape | Full booking object | ⚠️ **Not "reuse `Booking.fromJson`" as written earlier.** That parser exists but today reads only `reference`, `stage`, `cancellable`, `createdAt`, `pickup`, `slotId` and four destination fields. It ignores `status`, `miler`, `deliveryAgent`, `fare`, `amountPaid`, `deliveredAt`, `history`, `verification`, `details`, per-destination `stage`, `routeKm` and `expectedDelivery`. The server shape is right; the client parser has to be finished. Appendix B of the requirements said so and this document should not have glossed it. |
|
||||
| §9.3 `miler.phone` | Proxy when `MILER_CALL_PROXY` is set, real number otherwise | **Currently unset ⇒ real number.** See §10.2. |
|
||||
| Identifier format | Sequence-backed `DM-######` / `DMX########` | Matches the designs. |
|
||||
|
||||
### 1.3 Timestamps — the defect that would have shipped silently
|
||||
|
||||
This database stores **IST wall-clock digits** in its timestamp columns (the DSN
|
||||
sets `TimeZone=Asia/Kolkata`; `utils.DBNow` exists because of it). Calling
|
||||
`.UnixMilli()` on a value read back from those columns is **off by 5h30m** — the
|
||||
same class of fault as a naive local string with a `Z` on it, which already
|
||||
produced "yesterday's work shown as today" on the Miler app.
|
||||
|
||||
Every timestamp leaving `/customer/*` goes through `utils.EpochMillis`, which
|
||||
reinterprets the wall clock in IST first. It is correct for **both** shapes the
|
||||
driver can produce (UTC-tagged-with-IST-digits, and correctly `+05:30`-tagged),
|
||||
so a future column-type change cannot silently shift the tracking screen.
|
||||
Asserted in `utils/epoch_test.go`.
|
||||
|
||||
---
|
||||
|
||||
## 2. The structural change: one pickup → many orders
|
||||
|
||||
`pickupbookings` carried exactly **one** delivery address in its own columns.
|
||||
There was nowhere to put a second, so the "one visit, three orders" model in §1
|
||||
of the requirements was not expressible at all.
|
||||
|
||||
```
|
||||
Pickup booking DM-482913
|
||||
├── destination 0 : Chennai, TN · 2 packages
|
||||
├── destination 1 : Ernakulam, KL · 1 package
|
||||
└── destination 2 : Bengaluru Urban, KA· 1 package
|
||||
│ miler collects everything in ONE visit
|
||||
▼
|
||||
pickup-complete fans out → 3 consignments, 3 tracking numbers
|
||||
DMX10482913 DMX10559120 DMX10662004
|
||||
```
|
||||
|
||||
**The compatibility rule that makes this safe — do not break it:** destination 0
|
||||
is mirrored onto the booking's flat `delivery*` columns. The Miler app, the hub
|
||||
console, the routing code and the hyperlocal check all read those columns and
|
||||
**none of them changed**. A booking with *no* destination rows — every
|
||||
console-created express booking and every pre-existing row — produces exactly one
|
||||
consignment through the same loop, byte-for-byte as before.
|
||||
|
||||
Single-destination is one leg, not a special case, in either direction.
|
||||
|
||||
---
|
||||
|
||||
## 3. File-by-file: what changed and why
|
||||
|
||||
### 3.1 New files (23)
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|---|---|---|
|
||||
| `models/customer_app.go` | 241 | The 9 new tables. `BookingDestination` is the one that makes multi-destination expressible. |
|
||||
| `internal/cxstage/stage.go` | 405 | **The core of §8.3.** Records one append-only stage row per transition, in the caller's transaction. Owns the slowest-order rollup and the `Release` walk-back. |
|
||||
| `controllers/cxBookingView.go` | 744 | The canonical §9.3 booking object. Every read that returns a booking goes through `renderCxBooking`; `loadCxBundle` batches so a tracking poll is a fixed number of queries. |
|
||||
| `controllers/cxBookingController.go` | 819 | §9.1–9.6 — create, list, detail, cancel, patch, order-by-tracking-id. |
|
||||
| `controllers/cxAuthController.go` | 614 | §4 — OTP over phone/email, rotation-backed refresh, E.164 normalisation. |
|
||||
| `controllers/cxCatalogueController.go` | 516 | §5 — serviceability, capacity-aware slots, limits, ETag support. |
|
||||
| `controllers/cxPlacesController.go` | 350 | §6 — geocoder proxy + Redis cache. |
|
||||
| `controllers/cxFareController.go` | 266 | §7 — estimate range, multi-stop uplift. |
|
||||
| `controllers/cxPickupFanout.go` | 251 | Splits a booking into the journeys created at pickup-complete. Decides single-vs-fan-out; nothing downstream needs to know which. |
|
||||
| `controllers/cxOpsController.go` | 160 | §11 QA stage override, double-gated. |
|
||||
| `controllers/cxConsignmentHooks.go` | 100 | Maps a consignment status onto a customer stage for one order. |
|
||||
| `controllers/cxDeviceController.go` | 85 | §10 push registration. |
|
||||
| `controllers/cxIdentifiers.go` | 80 | Sequence-backed `DM-######` / `DMX########`. |
|
||||
| `utils/epoch.go` | 103 | IST→epoch conversion. **Read §1.3.** |
|
||||
| `utils/response_cx.go` | 97 | The customer envelope. Separate from `utils.OK/Fail` on purpose. |
|
||||
| `internal/sms/sms.go` | 105 | The seam for an SMS gateway. **No provider is wired in.** |
|
||||
| `middlewares/requestid.go` | 33 | `X-Request-Id` echo. |
|
||||
| `seed_customer_app.sql` | 153 | Staging data mirroring the client mock. |
|
||||
| `controllers/cxHttp_test.go` | 606 | HTTP status-code + envelope contract tests. |
|
||||
| `controllers/cxCustomerApp_test.go` | 473 | Pure-logic unit tests. |
|
||||
| `routes/routes_customer_test.go` | 323 | Routing + **the regression guard proving miler/console are untouched.** |
|
||||
| `internal/cxstage/stage_test.go` | 126 | Stage rollup and cancellation-window tests. |
|
||||
| `utils/epoch_test.go` | 116 | Timestamp conversion tests. |
|
||||
|
||||
### 3.2 Modified files (20)
|
||||
|
||||
| File | Δ | What changed | Why |
|
||||
|---|---|---|---|
|
||||
| `controllers/customerController.go` | +98 / −593 | PIN auth + single-destination booking handlers **deleted**; profile and locations kept, moved to the customer envelope | Replaced by the new surface; the old request shape is no longer a valid booking |
|
||||
| `controllers/milerController.go` | +593 | `BookingPickupComplete` rewritten for fan-out; `BookingParcelConfirm` accepts photos + writes transactionally; stage hooks added to accept/reached/cancel | §8.3 derivation and the §1 fan-out |
|
||||
| `controllers/milerAppController.go` | +410 | `MilerGetMyBookings` emits one stop per order after collection; `MilerDeliverConsignment` + `MilerStartDelivery` lookups fixed; stage hooks | **Without this, orders 2..N are undeliverable** — see §5.1 |
|
||||
| `routes/routes.go` | +82 | Customer routes replaced: 19 → 28 | The new contract |
|
||||
| `models/booking.go` | +60 | 10 columns on `PickupBooking`, 1 on `BookingParcel`, `Destinations` relation | Storage for the customer projection |
|
||||
| `constants/constants.go` | +71 | 9 stage keys, 3 statuses, 4 actor types, rank/cancellable helpers | The wire contract; never inline these |
|
||||
| `migrations/migrate.go` | +55 | 9 tables, 2 sequences, 2 indexes | Schema |
|
||||
| `internal/storage/spaces.go` | +74 | `PresignGet` | Signed parcel-photo URLs |
|
||||
| `dto/auth.go` | −41 | Retired customer PIN DTOs removed | Dead after the auth replacement |
|
||||
| `middlewares/logger.go` | +38 | Logs `client`, `platform`, `requestid` | §3 "accept and log" |
|
||||
| `middlewares/idempotency.go` | +34 | Anonymous-caller scoping | **Security fix** — see §5.4 |
|
||||
| `controllers/logisticsHandoverController.go` | +24 | Consignment→booking lookup fixed; `in_transit` recorded | Fan-out correctness |
|
||||
| `middlewares/city_gate.go` | +18 | Exported `PincodeInOperatingCity` | **Security fix** — see §5.3 |
|
||||
| `internal/assignment/crm_assignment.go` | +17 | `assigned` recorded on auto-assign | Most B2C bookings get a rider this way |
|
||||
| `controllers/booking_assignment_service.go` | +16 | `assigned` recorded on manual assign | Console assignment must reach the customer too |
|
||||
| `config/config.go` | +13 | `GEOCODER_URL`, `GEOCODER_EMAIL` | §6 proxy |
|
||||
| `utils/helper.go` | +13 | `GenerateTokenWithTTL` | 1h access tokens for customers only |
|
||||
| `main.go` | +7 | `RequestID()` registered | §3 |
|
||||
| `controllers/otpController.go` | −128 | **Deleted** | Its two routes were customer-only and are superseded |
|
||||
| `CLAUDE.md` | +147 | Project memory updated | Continuity |
|
||||
|
||||
---
|
||||
|
||||
## 4. Stage derivation (§8.3)
|
||||
|
||||
The operational writes already existed and were already correct. What did not
|
||||
exist was any record of *when* a parcel reached a stage in the vocabulary the
|
||||
customer sees. A consignment status says where a parcel is **now**; it cannot say
|
||||
when it got there.
|
||||
|
||||
| Existing Miler write | Customer stage produced |
|
||||
|---|---|
|
||||
| `assignMilerTx` / `commitAssignment` | `assigned` |
|
||||
| `POST /miler/assignments/{id}/accept` | `on_the_way` (re-asserts `assigned`; deduped) |
|
||||
| `POST /miler/bookings/{id}/reached` | `arrived` — **cancellation closes here**, recorded in the same transaction as the arrival fact so there is no window to cancel through |
|
||||
| `POST /miler/bookings/{id}/parcel` | populates `verification` (weight per destination + photos) |
|
||||
| `POST /miler/bookings/{id}/pickup-complete` | `picked_up`, then `order_created` **per destination** |
|
||||
| `POST /miler/consignments/{id}/inward-at-hub` | `in_transit`, per order |
|
||||
| `POST /miler/consignments/{id}/start-delivery` | `out_for_delivery`, per order |
|
||||
| `POST /miler/consignments/{id}/deliver` | `delivered`, per order |
|
||||
| `POST /miler/bookings/{id}/cancel` | **`Release`**, not cancel |
|
||||
|
||||
**Four rules worth knowing as the app developer:**
|
||||
|
||||
1. **Nothing is backfilled.** A booking that predates this work has a short
|
||||
history. A short honest history beats a long invented one — the customer
|
||||
cannot tell which entries were guessed.
|
||||
2. **A booking rolls up from its SLOWEST order.** Taking the maximum would show
|
||||
"Delivered" while one of three parcels was still at a hub.
|
||||
3. **`on_the_way` comes from accept, not the GPS stream.** Deriving it from
|
||||
location pings means re-deriving on every ping. Distance and ETA are still
|
||||
live from the rider's Redis position.
|
||||
4. **`Release` is the one place a stage moves backwards.** A miler cancelling
|
||||
returns the pickup to the pool. Without walking the stage back, the customer
|
||||
keeps seeing a rider card for someone who is not coming. The `assigned` /
|
||||
`on_the_way` history rows stay — those things happened.
|
||||
|
||||
### §8.4 gaps — all five closed
|
||||
|
||||
| Gap in the requirements | Status |
|
||||
|---|---|
|
||||
| No per-destination consignment id | ✅ `bookingdestinations.consignmentid` + `trackingno` |
|
||||
| No COD amount on the booking | ✅ `bookingdestinations.codamount` → `consignments.codamount`. **Per destination**, because COD is collected per delivery |
|
||||
| No per-stop attribution for stages 6–8 | ✅ Every per-order stage records against its destination |
|
||||
| No customer-side cancel | ✅ Server-enforced through `arrived` |
|
||||
| Parcel weight/photos not exposed | ✅ `verification` block, photos as signed URLs |
|
||||
|
||||
---
|
||||
|
||||
## 5. Bugs found and fixed (not requested — found because the fan-out forced every consignment lookup to be re-read)
|
||||
|
||||
### 5.1 🔴 The rider could never deliver orders 2..N
|
||||
|
||||
`GET /miler/bookings` emitted one row per booking, keyed on
|
||||
`pickupbookings.consignmentid` — a column that names only the **first** order.
|
||||
On a three-destination pickup, two parcels would sit in the rider's bag with no
|
||||
stop, no deliver button and no way to close them.
|
||||
|
||||
**Without this fix the entire multi-destination feature would have created orders
|
||||
nobody could deliver.** `milerStopsForBooking` now emits one stop per order after
|
||||
collection (and still exactly one visit before it — the rider goes to the door
|
||||
once). Tested in `controllers/cxCustomerApp_test.go`.
|
||||
|
||||
### 5.2 🔴 Three endpoints broken for orders 2..N (same root cause)
|
||||
|
||||
- **`MilerDeliverConsignment`** returned `404 "assigned consignment not found"` —
|
||||
the rider **could not complete the delivery at all**.
|
||||
- **`MilerStartDelivery`** sent no push and no receiver OTP.
|
||||
- **`MilerInwardConsignmentAtHub`** left the assignment open (so the rider could
|
||||
not go off duty — `MilerEndDuty` refuses on an open assignment) and recorded
|
||||
the leg's distance and earnings as **zero**.
|
||||
|
||||
All three now resolve through `cxDestinationForConsignment`.
|
||||
|
||||
### 5.3 🟠 `CityGateMiddleware` was a no-op for customer bookings
|
||||
|
||||
It sniffs the request body for `pickuppincode`. The new request shape does not
|
||||
carry one — its pickup is a `title/sub/lat/lng` from the place search — so
|
||||
**every customer booking sailed past the operating-city gate.** Now checked in
|
||||
the handler against the pincode resolved from the coordinates; a pickup outside
|
||||
an operating city returns `422 unserviceable`.
|
||||
|
||||
### 5.4 🔴 Idempotency key collision (introduced by me, caught in review)
|
||||
|
||||
Keys were namespaced by user id. On the **unauthenticated**
|
||||
`POST /customer/auth/otp/verify` that id is `0` — so two customers who happened
|
||||
to pick the same `Idempotency-Key` would collide and **the second would be handed
|
||||
the first's access token, refresh token and customer record.**
|
||||
|
||||
Fixed: anonymous callers are scoped by request path + body hash. The
|
||||
authenticated key format is left byte-identical, so no in-flight rider key is
|
||||
orphaned at deploy.
|
||||
|
||||
### 5.9 🟠 Fan-out row audit: three gaps closed
|
||||
|
||||
Found by auditing every row-level field the rider app reads, rather than
|
||||
assuming the fan-out was complete:
|
||||
|
||||
- **`collectionamt` was never emitted** — only `codamount`. Added alongside it,
|
||||
not as a rename, so the deployed build keeps working.
|
||||
- **COD fell back to 0** when a consignment row did not load, instead of to the
|
||||
destination's own figure. A rider shown ₹0 collects nothing.
|
||||
- **Stop ordering depended on the caller's `ORDER BY`.** Now guaranteed inside
|
||||
`milerStopsForBooking`, because the ordering is the route.
|
||||
|
||||
Also hardened at the same time: `CreateCxBooking` now rejects an absurd
|
||||
destination count **before** any database work (`cxAbsoluteMaxDestinations`),
|
||||
because the district lookup built a `WHERE districtcode IN (...)` from unbounded
|
||||
caller input; and `cxDefaultMaxDestinations` dropped from 5 to **1**, so a
|
||||
missing configuration row resolves to the safest cap rather than the most
|
||||
permissive — a gate that opens when its config is absent is not a gate.
|
||||
|
||||
### 5.10 🟠 Two different mistakes shared one error message
|
||||
|
||||
`POST /customer/bookings` answered an **empty** `destinations` array with
|
||||
*"Every destination needs a serviceable state and district"* — the same string
|
||||
it uses when a destination is present but missing its state or district.
|
||||
|
||||
The contract renders `message` verbatim, so a customer who had added nothing was
|
||||
told to go and fix details on destinations they did not have. The message
|
||||
described a problem they did not have and hid the one they did. Both cases also
|
||||
carried the same `error.code`, so the app could not tell them apart either.
|
||||
|
||||
Now *"Add at least one destination"*, matching what `/customer/fare/estimate`
|
||||
already said for the identical mistake. `TestEmptyDestinationsSaysAddOneNotFixTheirDetails`
|
||||
asserts the two endpoints agree and that the message names the actual fix.
|
||||
|
||||
Found by reading the messages rather than the status codes — every one of them
|
||||
is customer-facing copy, and a 400 being *correct* says nothing about whether it
|
||||
is *useful*.
|
||||
|
||||
### 5.5 🟡 Pre-existing, flagged not fixed
|
||||
|
||||
`BookingPickupComplete`'s comment says a hyperlocal parcel keeps the rider
|
||||
`Picked_Up`; the code sets them `Available`. This is the default path today.
|
||||
Changing it alters live rider availability and the dispatch pool — **ops
|
||||
decision.**
|
||||
|
||||
---
|
||||
|
||||
### 5.6 🔴 Rider earnings would have read ₹0 on every customer job (introduced by me, caught while mapping screens)
|
||||
|
||||
`CreateCxBooking` stored the estimate in its own new columns and did **not**
|
||||
create a `BookingServiceOption` row — but three places still read one:
|
||||
|
||||
- `MilerDeliverConsignment` and `MilerInwardConsignmentAtHub` copy
|
||||
`Estimatedprice` onto `BookingAssignment.ridercharges` when a leg closes.
|
||||
- `GET /miler/earnings` sums that column.
|
||||
- The admin console's Orders list renders `serviceoptions[0].estimatedprice` as
|
||||
its **Price** column.
|
||||
|
||||
So every customer-app job a rider completed would have recorded **zero earnings**
|
||||
on their Earnings screen, and the admin Orders list would have shown **"N/A"**
|
||||
for the price of every customer booking. Fixed: the booking now creates a service
|
||||
option carrying the midpoint of the band the customer was shown, which is what
|
||||
`lookupDoormilePrice` returns everywhere else in this codebase.
|
||||
|
||||
### 5.7 🔴 An ops cancellation never reached the customer
|
||||
|
||||
`AdminCancelBooking`, `AdminBulkCancelBookings` and `AdminUpdateBookingStatus`
|
||||
all cancel by writing `pickupbookings.status` directly. None of them knows the
|
||||
customer projection exists — and because `customerstatus` is written as
|
||||
`"active"` at booking time, the projection's empty-string fallback never fired.
|
||||
|
||||
**A customer whose pickup ops cancelled would have kept seeing it as active and
|
||||
cancellable, indefinitely.** They would also still have received stage
|
||||
notifications for it.
|
||||
|
||||
Fixed in two places, deliberately: the projection now treats the **operational**
|
||||
status as the authority on cancellation, which covers all three paths and any
|
||||
added later; and the two real cancel paths call `cxstage.Cancel` so the customer
|
||||
gets a reason and the audit trail records which ops user did it. Both are no-ops
|
||||
for console-created bookings.
|
||||
|
||||
### 5.8 🟠 An expired slot was reported as a full one
|
||||
|
||||
`CreateCxBooking` answered a slot whose window had already passed with
|
||||
`409 "That pickup window just filled up"`. That is untrue and points the
|
||||
customer at the wrong recovery — they need to re-fetch the slot list, not retry
|
||||
for a place in a queue. A client that caches slots for a session and is left open
|
||||
across midnight hits this on the first booking of the day.
|
||||
|
||||
Now `400 invalid` with *"That pickup time has passed — pick a new slot"*, which
|
||||
is what the contract already maps to "Pick a pickup slot". A stale **date** is
|
||||
also now caught by `CxSlotDateIsPast` before any database access, since the slot
|
||||
id carries its own date — the cheapest validation in the path, and the one that
|
||||
actually fires. `409` is reserved for the genuine capacity race.
|
||||
|
||||
Found by a reviewer noticing that §5.3 and §7.7 of this document could not both
|
||||
be comfortable at once. They were right.
|
||||
|
||||
## 6. Tests
|
||||
|
||||
`go build ./...`, `go vet ./...`, `go test ./...` all pass.
|
||||
**305 passing assertions/subtests**, all runnable with no database.
|
||||
|
||||
| File | What it proves |
|
||||
|---|---|
|
||||
| `controllers/cxHttp_test.go` | **Status codes and envelopes through a real router.** Every validation path answers 4xx with a machine-readable `error.code` and customer-safe English — never 500, never a router 404. Malformed JSON is 400. All 9 contract error codes map to the right status. `CxInternal` never leaks the cause. The QA override is invisible unless both gates are open. |
|
||||
| `routes/routes_customer_test.go` | All **20** authenticated routes exist and return **401 without a token** (not 404, not 500). All refuse roles 1/3/4/5/6 with **403**. Pre-auth routes are reachable and answer from the handler. Retired PIN routes no longer mint a credential. **`TestMilerSurfaceIsUnchanged` / `TestConsoleSurfaceIsUnchanged` are the regression guard for §0.** |
|
||||
| `controllers/cxCustomerApp_test.go` | Phone normalisation (7 spellings → one stored value, so a build change cannot create a duplicate account); the fan-out stop split; timeline dedup and per-order timestamping; `deliveredAt` waiting for the last parcel; pickup title/sub never empty. |
|
||||
| `internal/cxstage/stage_test.go` | Slowest-order rollup; stage ordering as a wire contract; the cancellation window closing exactly after `arrived`. |
|
||||
| `utils/epoch_test.go` | IST→epoch for both driver taggings; null stays null; slot window and day formatting. |
|
||||
|
||||
### What the tests do NOT prove
|
||||
|
||||
**No happy path is asserted.** A `200` from `CreateCxBooking` needs Postgres,
|
||||
Redis and NATS. Mocking them would test the mock. Everything from the handler
|
||||
inwards is still unverified — that is the integration pass in §11.
|
||||
|
||||
---
|
||||
|
||||
## 7. Answers to the requirements' §13 open questions
|
||||
|
||||
*(“§13” here and in §9 means section 13 of the requirements document. Section 13
|
||||
of THIS document, at the bottom, is the review log.)*
|
||||
|
||||
**1. Payment.** v1 is cash/UPI at the door, settled outside the app. No payment
|
||||
step exists and there is no payment contract to hand you. `amountPaid` is the sum
|
||||
of `bookingpayments` rows the miler recorded, in whole rupees, present from
|
||||
`picked_up`. Two different pots of money: the **pickup fee** is Doormile's,
|
||||
collected once per visit and recorded against the first order; **COD at the
|
||||
destination** is the customer's own collection, per destination. Doormile is the
|
||||
carrier, never the seller. ⚠️ *No settlement/remittance flow exists — money can be
|
||||
collected and recorded, nothing pays it back to the customer.*
|
||||
|
||||
**2. Masked calling.** Implemented as a switch: `MILER_CALL_PROXY` returns a proxy
|
||||
when set, the rider's real number otherwise. **Recommendation: set it before
|
||||
launch.** A customer handed a rider's personal mobile has it permanently.
|
||||
⚠️ *Currently unset.*
|
||||
|
||||
**3. Partial pickup.** Not modelled, not guessed at. Pickup-complete is
|
||||
all-or-nothing. The fan-out makes it *expressible* (legs are built per
|
||||
destination), but there is no stage key, no miler endpoint and no screen.
|
||||
⚠️ *Product decision needed.*
|
||||
|
||||
**4. Failed delivery / reattempt.** Exists operationally —
|
||||
`POST /miler/consignments/{id}/skip` increments `attemptcount` and leaves the
|
||||
parcel `Out_for_Delivery`. **Deliberately not mapped to a customer stage**: there
|
||||
is no key for it in the nine, and an unknown key renders as `booked` on the
|
||||
client. So today a failed attempt is **invisible to the customer** — their parcel
|
||||
stays "Out for delivery". ⚠️ *This is the most customer-visible gap in v1. Needs a
|
||||
tenth stage key + a client release.*
|
||||
|
||||
**5. Saved addresses / notification preferences / payment methods.** Saved
|
||||
addresses **built** (capped at 10, same `title`/`sub` shape as a place search
|
||||
result). Notification preferences **not built**. Payment methods **not built**,
|
||||
correctly, while v1 settles at the door.
|
||||
|
||||
**6. Support.** Not built for the customer. `/miler/support` exists and the shape
|
||||
would extend. ⚠️ *A phone number is the cheapest thing that is not a dead end.*
|
||||
|
||||
**7. Slot capacity semantics.** **Per zone.** A window's remaining capacity counts
|
||||
live bookings whose `preferredpickupfrom` falls inside it, within 12km of the
|
||||
pickup point, excluding cancelled and completed. The list read is advisory and
|
||||
cached 30s; capacity is **re-checked at confirm** and a lost race returns `409`.
|
||||
**The client does not need to re-fetch before confirming.**
|
||||
|
||||
**8. Pricing.** Reuses the existing `doormile_pricing` slab (zone × service type ×
|
||||
weight band → min/max), with a distance-and-weight fallback for unpriced lanes.
|
||||
Additional destinations carry a **+35% uplift each**. Weight assumed 3kg/package
|
||||
until weighed. ⚠️ *The 35% is a placeholder, and there is **no breakdown when
|
||||
`amountPaid` exceeds `fare.max`** — the receipt can show `amountPaid − fare.min`
|
||||
as a weight adjustment, but nothing explains an overage. Product owns both.*
|
||||
|
||||
**9. Cancellation fee.** **Confirmed free through `arrived`.** No fee is computed,
|
||||
charged or recorded anywhere in the cancel path. The UI copy is accurate. From
|
||||
`picked_up` onward the server returns `409`.
|
||||
|
||||
**10. Retention.** **Not implemented — no policy exists to implement.** Parcel
|
||||
photos stored indefinitely, PII stored indefinitely, `bookingstageevents` never
|
||||
pruned. The 30-minute signed-URL TTL limits *link* lifetime, not object lifetime.
|
||||
⚠️ *This is the answer most likely to matter to a regulator and it is currently
|
||||
blank.*
|
||||
|
||||
**11. Tenancy.** **Logistics only in v1.** Customer JWTs carry `tenantid: 0` and
|
||||
B2C bookings leave `pickupbookings.tenantid` null. The customer app does not
|
||||
switch mode on `tenantid` the way the Miler app does. ⚠️ *Decide before revenue
|
||||
reporting depends on it.*
|
||||
|
||||
---
|
||||
|
||||
## 8. Non-functional (§3.5)
|
||||
|
||||
- **Idempotency** — `POST /customer/bookings` and `POST /customer/auth/otp/verify`.
|
||||
24h replay.
|
||||
- **Caching** — serviceability `ETag`/`If-None-Match` → 304, `max-age=300`. Slots
|
||||
`max-age=30`. Booking detail supports 304 (it is polled).
|
||||
- **Rate limits** — OTP ≤5 per identifier per hour, ≤3 verify attempts per code,
|
||||
30s resend cooldown that does **not** consume one of the five. Plus the shared
|
||||
10/minute per-IP credential budget.
|
||||
- **Pagination** — `?limit=&cursor=`, default 20, max 50. **Keyset**, so a new
|
||||
booking landing mid-scroll cannot show the same row twice. `total` reflects the
|
||||
filtered tab.
|
||||
- **PII** — phone masked in SMS logs; parcel photos as signed URLs.
|
||||
- **Audit** — actor type, actor id, source endpoint and timestamp on every
|
||||
transition.
|
||||
- **Tenancy** — every customer read is scoped by `appcustomerid` in the query
|
||||
itself, never by a check afterwards.
|
||||
- ⚠️ **Latency is UNMEASURED.** Reads are batched (`loadCxBundle` is a fixed query
|
||||
count regardless of page size) and pricing is Redis-warmed — but p95 ≤ 400ms is
|
||||
an argument, not a measurement.
|
||||
|
||||
---
|
||||
|
||||
## 9. Deliverables (§11)
|
||||
|
||||
| Asked for | Status |
|
||||
|---|---|
|
||||
| OpenAPI 3.1 spec | ✅ `openapi-customer.yaml` — 24 paths, 28 operations, validated. Covers every registered route including profile and saved addresses. |
|
||||
| Postman collection | ❌ Not produced. The OpenAPI file imports directly into Postman. |
|
||||
| Staging seed mirroring the mock | ✅ TN/KL/KA open, PY serviceable with no open district, Madurai/Kozhikode/Mangaluru unavailable with reasons, slot `t5` at capacity 0 |
|
||||
| Test accounts + fixed staging OTP | ✅ Two accounts seeded; `CX_STAGING_OTP=1234`, refused when `ENV=production` |
|
||||
| Force a booking to any stage | ✅ Double-gated; walks every intermediate stage |
|
||||
| Documented error responses | ✅ Implemented verbatim; asserted in tests |
|
||||
| Error injection | ❌ Not built. Happy-path states are forceable; failure states are not. |
|
||||
| Written answer to the requirements' §13 | ✅ §7 above |
|
||||
|
||||
---
|
||||
|
||||
## 10. Environment variables
|
||||
|
||||
```bash
|
||||
GEOCODER_URL=https://nominatim.openstreetmap.org # place search / reverse geocode upstream
|
||||
GEOCODER_EMAIL=ops@doormile.com # Nominatim policy requires a contact
|
||||
MILER_CALL_PROXY= # ⚠️ empty exposes the rider's real number
|
||||
CX_STAGING_OTP=1234 # refused when ENV=production
|
||||
CX_ID_SCRAMBLE_KEY= # keys the identifier permutation; a working default ships
|
||||
CX_ALLOW_STAGE_OVERRIDE=false # also requires ENV != production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Before this goes to production — read this
|
||||
|
||||
**Blockers, stated plainly:**
|
||||
|
||||
1. 🔴 **No SMS provider exists.** `internal/sms` is the seam (a `Sender`
|
||||
interface, a logging sink, `sms.Register()`). Until a gateway is plugged in,
|
||||
**OTP codes go to the application log and nowhere else.** Real customer
|
||||
sign-in is blocked on this. It is the same wall the previous end-to-end test
|
||||
hit. `sms.Configured()` reports it.
|
||||
2. 🔴 **No integration test has touched any of this.** The 305 passing tests cover
|
||||
pure logic, routing, status codes and envelopes. Nothing has run against a
|
||||
real Postgres, Redis or NATS. **A clean compile says the code is well-formed,
|
||||
not that it works.**
|
||||
3. 🟠 **The migration has not run against a real database.** It is additive
|
||||
(`AutoMigrate` + `CREATE SEQUENCE`/`CREATE INDEX IF NOT EXISTS`) and Postgres
|
||||
11+ adds nullable/defaulted columns without a table rewrite — but *should be
|
||||
safe* is not *has been observed*. Run it on staging first and watch
|
||||
`pickupbookings`.
|
||||
4. 🟠 **`MILER_CALL_PROXY` is unset**, so the tracking screen will show riders'
|
||||
personal mobile numbers.
|
||||
5. 🟠 **No retention policy** for parcel photos or PII.
|
||||
6. 🟡 **Failed delivery is invisible to the customer** (§7.4).
|
||||
7. 🟡 **Latency unmeasured** (§8).
|
||||
8. 🔴 **Multi-destination pickups must stay capped at 1 destination** until a
|
||||
rider build keying on `consignmentid` ships (§0.6). The cap is a database
|
||||
value, not code — verify it, do not assume it.
|
||||
|
||||
**Suggested deploy order:**
|
||||
|
||||
1. Staging: deploy, let `AutoMigrate` run, apply `seed_customer_app.sql`, set
|
||||
`CX_STAGING_OTP=1234`.
|
||||
2. **Confirm `maxdestinations = 1`** in `customerbookinglimits` (the seed sets
|
||||
it). This is the gate that keeps the fan-out dormant until the rider app can
|
||||
handle it — see §0.6. Nothing else prevents a stranded parcel.
|
||||
|
||||
```sql
|
||||
SELECT applocationid, maxpackages, maxdestinations FROM customerbookinglimits;
|
||||
```
|
||||
3. Smoke the customer surface end to end — this is the pass that has never
|
||||
happened.
|
||||
4. **Regression-test the Miler app against staging**, specifically: pickup-complete
|
||||
on a single-destination booking, deliver, inward-at-hub, and off-duty. Those
|
||||
are the paths §0 items 3–5 and §5.2 touch.
|
||||
5. Plug in the SMS gateway before any production customer traffic.
|
||||
6. **Only after a rider build keying on `consignmentid` is live**, raise the cap
|
||||
and test a multi-destination pickup — the genuinely new path:
|
||||
|
||||
```sql
|
||||
UPDATE customerbookinglimits SET maxdestinations = 5 WHERE applocationid IS NULL;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11.5 Client-side prerequisites — none of §12 works until these land
|
||||
|
||||
§12 below is a correct set of instructions for a client that can make requests.
|
||||
Today it cannot, and these are outstanding on the app side (all four are in
|
||||
Appendix B of the requirements, and this document should have carried them
|
||||
forward rather than assuming them done):
|
||||
|
||||
1. **`pubspec.yaml` has no `http` and no `shared_preferences`.** Every rule about
|
||||
headers, idempotency keys and token rotation presumes a network layer that is
|
||||
not yet added.
|
||||
2. **`Booking.fromJson` parses roughly a third of §9.3.** See the §9.6 row above.
|
||||
3. **`PickupSlot.fromJson` drops `tag`, `milersNearby` and `caption`.** The
|
||||
server sends all three; the picker will not show any of them.
|
||||
4. **`AppState.loadSlots` caches for the whole session and `startBooking` clears
|
||||
only `districtCache`** (`lib/state/app_state.dart:89`). An app left open
|
||||
across midnight books against yesterday's slot. The server now answers that
|
||||
with `400` and *"That pickup time has passed — pick a new slot"* rather than
|
||||
the misleading "just filled up" it used to (§5.8) — but the client fix is to
|
||||
clear `slotsCache` in `startBooking` so the customer never reaches that error.
|
||||
|
||||
---
|
||||
|
||||
## 12. Quick reference for the app developer
|
||||
|
||||
- **Base:** `https://api.doormile.com/api/v1`
|
||||
- **Auth:** `Authorization: Bearer <accessToken>` on everything except
|
||||
`/customer/auth/otp/request`, `/auth/signup`, `/auth/otp/verify`,
|
||||
`/auth/refresh`, and the four catalogue reads.
|
||||
- **Access token 1h, refresh 60 days, rotated on every use.** A revoked refresh
|
||||
token replayed kills the whole chain — always store the newest one.
|
||||
- **Send `X-Client: doormile-cx/<version>+<build>` and `X-Platform: android|ios`.**
|
||||
They are logged and are the only way to tell one build's failures from another's.
|
||||
- **Send `Idempotency-Key` on booking create and OTP verify.**
|
||||
- **Every timestamp is epoch milliseconds, UTC, integer.**
|
||||
- **Every string in `message` is safe to render verbatim.** Branch on
|
||||
`error.code`, never on the message text.
|
||||
- **`pickup`, `slotId`, `destinations[].stateName` and `districtName` are never
|
||||
null.** The rest of `details` may be absent — that is the "Not added" state, not
|
||||
an error.
|
||||
- **`trackingId` and `stage` on a destination are null until `order_created`.**
|
||||
There is no order to track before the parcels are collected.
|
||||
- **An unknown stage key renders as `booked`.** If you see that on a moving
|
||||
parcel, the backend sent a key this build does not know — tell us; it means a
|
||||
new stage shipped without a client release.
|
||||
|
||||
---
|
||||
|
||||
## 13. Review log
|
||||
|
||||
This document has been reviewed once, by a reviewer with access to the two
|
||||
Flutter apps but **not** to this Go backend. That split matters and is why the
|
||||
review was useful: every claim it made about the apps was checkable by them and
|
||||
not by me, and every claim about this repo was checkable by me and not by them.
|
||||
The findings below are recorded verbatim in substance, with what was done about
|
||||
each.
|
||||
|
||||
**Standing caveat the reviewer stated and this document repeats:** the 305 tests,
|
||||
the fan-out, the idempotency fix and the epoch conversion are unverified from
|
||||
their side. Nothing in §11 has changed — this code has still never run against a
|
||||
real database.
|
||||
|
||||
### 13.1 Findings — all upheld
|
||||
|
||||
| # | Finding | Status | What changed |
|
||||
|---|---|---|---|
|
||||
| 1 | **The rider-app action item is materially understated.** The app's identity key is `orderid` (`api_config.dart:321`, `'orderid': ref ?? id`), not `bookingid`. Both are booking-level, so all three fan-out rows collapse — in `accepted_store.dart:527` (SharedPreferences-backed, survives restart) and in `work_repository.dart:314,321` / `order_events.dart`. Keying on `consignmentid` touches four files. | **Upheld.** Consistent with what this server sends: the three rows share `bookingid` *and* `bookingreference`. | New **§0.6** replaces the one-line framing. §0 item 3 now points at it. Mitigation added: `maxdestinations` seeded at **1**, and a deploy-checklist step to verify it. |
|
||||
| 2 | `destinationseq` / `destinationcount` are invisible — `pickupFromBooking` builds a fixed map, so a field the adapter does not name cannot reach the app. | **Upheld.** | Recorded in §0.6. Adding fields server-side does not help until the adapter names them. |
|
||||
| 3 | **"Reuse `Booking.fromJson`" is optimistic.** It parses only `reference`, `stage`, `cancellable`, `createdAt`, `pickup`, `slotId` and four destination fields — ignoring `status`, `miler`, `deliveryAgent`, `fare`, `amountPaid`, `deliveredAt`, `history`, `verification`, `details`, per-destination `stage`, `routeKm`, `expectedDelivery`. | **Upheld, and it was avoidable** — Appendix B of the requirements said exactly this and this document glossed it. | §1.2's §9.6 row rewritten to state the gap instead of assuming the parser. |
|
||||
| 4 | **Slot staleness is a trap handed to the client.** §5.3 and §7.7 could not both be comfortable: `AppState.loadSlots` caches for a session, `startBooking` clears only `districtCache` (`app_state.dart:89`), so an app open across midnight books yesterday's slot. | **Upheld — and it exposed a server defect.** The server answered `409 "That pickup window just filled up"`, which is untrue and points at the wrong recovery. | **Code fixed** (§5.8): now `400` with *"That pickup time has passed — pick a new slot"*, plus a new DB-free `CxSlotDateIsPast` guard that runs before any query. Test added. Client-side fix (clear `slotsCache`) recorded in §11.5. |
|
||||
| 5 | §12's header/idempotency/rotation rules assume a client that can make requests; `pubspec.yaml` has no `http`, no `shared_preferences`. | **Upheld.** | New **§11.5** lists the client prerequisites ahead of §12. |
|
||||
| 6 | `PickupSlot.fromJson` drops `tag`, `milersNearby`, `caption` — the server now sends all three. | **Upheld.** | §11.5 item 3. |
|
||||
| 7 | §3.2 contains a literal unfilled placeholder: `customerController.go │ −691/+?`. | **Upheld.** | Corrected to the real churn, **+98 / −593**. |
|
||||
| 8 | The counts do not reconcile: 19→28 routes, 8 exempt from auth ⇒ 20 authenticated, but §6 asserts 22. | **Upheld.** Verified: 28 registered = 8 pre-auth + **20** authenticated; the routing test has exactly 20 entries. | §6 corrected to 20. §1.1 now states the arithmetic explicitly so the three numbers reconcile on the page. |
|
||||
| 9 | The OpenAPI file's 22 operations imply `/customer/profile` and `/customer/locations` are absent, though §1.1 relies on them to answer §13.5. | **Upheld, and the worst of the three** — the app developer works from the spec and would not have found saved addresses. | **6 endpoints added to the spec** with `SavedAddress` / `SavedAddressInput` schemas. Now **24 paths / 28 operations**, matching the 28 registered routes exactly. |
|
||||
|
||||
### 13.2 What the review confirmed as correct
|
||||
|
||||
Recorded because it is the half that did not change, and it is the half the app
|
||||
is built against:
|
||||
|
||||
- camelCase throughout — no mapping layer needed on the client.
|
||||
- Epoch-millis UTC — `DateTime.fromMillisecondsSinceEpoch` works as-is.
|
||||
- Payload always in `data`, auth included.
|
||||
- `pickup`, `slotId`, `stateName`, `districtName` non-null — the client types
|
||||
these non-nullable and would throw otherwise.
|
||||
- "An unknown stage key renders as `booked`" is accurate against `models.dart`
|
||||
(`JourneyStage.fromKey` falls back to `booked`).
|
||||
- The §1.3 IST timestamp defect is real and is the bug that already bit the
|
||||
Miler app.
|
||||
- §8.4's five gaps match independently.
|
||||
- The two reverts — rider availability, and the 404→403 change — were the right
|
||||
call.
|
||||
- §11's honesty about what is unverified was called the most useful section here.
|
||||
It has not been softened.
|
||||
|
||||
### 13.3 Reviewer's verdict, unedited in substance
|
||||
|
||||
> The backend contract is sound and answers the requirements; the impact
|
||||
> assessment is accurate in direction but under-scopes the rider-app work, and
|
||||
> its "reuse the existing parser" claims describe the client we intend to have,
|
||||
> not the one in the repo. Nothing here contradicts the requirements doc.
|
||||
|
||||
Both criticisms are now addressed in the text: §0.6 for the rider-app scope,
|
||||
§11.5 and the §9.6 row for the parser claims.
|
||||
|
||||
### 13.4 What still cannot be checked from either side
|
||||
|
||||
- Nobody has run this against a real Postgres, Redis or NATS.
|
||||
- The reviewer could not verify the Go changes; this document's author could not
|
||||
open the Flutter apps. **Nothing in §0.6 or §11.5 has been executed** — those
|
||||
file and line references come from the review, not from this repo.
|
||||
- The single integration pass in §11 remains the thing that would close both
|
||||
gaps at once.
|
||||
@@ -251,10 +251,25 @@ delivered, riderkms, ridercharges, dutyminutes }`.
|
||||
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| GET | `/admin/bookings` | tenant-scoped list |
|
||||
| GET | `/admin/bookings` | tenant-scoped list, **newest first** (`bookingid DESC`) |
|
||||
| POST | `/admin/expressbooking` | create one — passes CityGate |
|
||||
| POST | `/admin/expressbooking/bulk` | `{ "bookings": [ ... ] }`, max 200, per-row results |
|
||||
| GET | `/admin/bookings/:id` | 404 if outside your tenant |
|
||||
|
||||
`GET /admin/bookings` is ordered `bookingid DESC` — page 1 is always the newest
|
||||
page. The order is guaranteed, not incidental: `bookingid` is the primary key
|
||||
and unique, so `OFFSET` paging over it is stable and two requests for the same
|
||||
page return the same rows. Before this was explicit the result order was
|
||||
unspecified (Postgres heap order, in practice oldest first), which put the
|
||||
newest booking on the LAST page — a client draining a bounded number of pages
|
||||
could never reach a just-created booking.
|
||||
|
||||
It also accepts `?status=` as an EXACT, case-sensitive, single-value match
|
||||
against the stored enum. The stored values are capitalised
|
||||
(`Pending_Pickup`, `Converted_To_Consignment`); `?status=pending_pickup`
|
||||
matches nothing and returns an empty list rather than an error. There is no
|
||||
multi-status or date filter, so a client grouping several statuses into one tab
|
||||
still has to filter its own rows.
|
||||
| GET | `/admin/bookings/:id/track` | **the tracking screen** — see below |
|
||||
| POST | `/admin/bookings/:id/assign-miler` | |
|
||||
| POST | `/admin/bookings/:id/assign-vehicle` | |
|
||||
@@ -393,7 +408,12 @@ rather than making the console reconcile two stores.
|
||||
- **Envelope**: `{ "success": true, "data": ... }` on success,
|
||||
`{ "success": false, "message": "..." }` on failure. Lists add `total`,
|
||||
paginated lists add `page`.
|
||||
- **Pagination**: `?pageno=1&pagesize=100`. Default 500, cap 1000.
|
||||
- **Pagination**: `?pageno=1&pagesize=100`. Default 20, cap 100. Both numbers
|
||||
are enforced server-side (`min(100, max(1, pagesize))`); a request for a
|
||||
larger page silently receives 100 rows and the response's own `pagesize`
|
||||
field reports 100. This entry previously read "default 500, cap 1000", which
|
||||
was wrong on both counts — clients sizing a page budget off it fetched a
|
||||
twelfth of what they expected.
|
||||
- **Rate limits**: 300/min per IP globally, 10/min shared across all credential
|
||||
endpoints. Behind the ingress this keys on the proxy IP unless
|
||||
`TRUSTED_PROXIES` is set.
|
||||
|
||||
360
docs/logistics-base-handover.md
Normal file
360
docs/logistics-base-handover.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Logistics pickup-source and base-handover flow
|
||||
|
||||
The backend contract for requests 25–31 on the Miler logistics line. Written as
|
||||
the answer to that register: what shipped, what the wire values are, and the
|
||||
state transitions for each of the three journeys.
|
||||
|
||||
**Vocabulary.** The wire says *hub* — `inward_at_hub`, `Inwarded_at_Hub`,
|
||||
`next_hub`, `pickup_source_type: "hub"`. The rider app renders that as *Base*.
|
||||
Nothing here changes a wire value to match the app's wording, and the app's
|
||||
wording never leaks back into this API. Console and backend keep saying hub.
|
||||
|
||||
---
|
||||
|
||||
## The flag
|
||||
|
||||
`MILER_HUB_HANDOVER_ENABLED` (env, read per request, **default off**).
|
||||
|
||||
| | off (today) | on |
|
||||
|---|---|---|
|
||||
| A hub-routed parcel at pickup-complete | `Inwarded_at_Hub` immediately | `Created` — collected, in the rider's hands |
|
||||
| `next_action` returned | `handed_to_hub` | `inward_at_hub` |
|
||||
| Rider's assignment | closed at pickup-complete | closed at the handover |
|
||||
| Rider availability after pickup | `Available` | `Picked_Up` (still carrying) |
|
||||
| Base sees it on `/hub/inbound/expected` | no — it is already received | yes |
|
||||
|
||||
Off is not a placeholder: it is what the currently deployed rider app expects. A
|
||||
build that cannot call the handover endpoint would, with the flag on, collect an
|
||||
intercity parcel and have no way to advance it — the parcel would sit on
|
||||
`Created` in the rider's queue and appear in no base's received list. Turn it on
|
||||
when a rider build that calls `inward-at-hub` is live:
|
||||
|
||||
```bash
|
||||
kubectl -n doormile set env statefulset/doormile MILER_HUB_HANDOVER_ENABLED=true
|
||||
```
|
||||
|
||||
Write it into `/opt/kubernetes/manifests/doormile/miletruth.yaml` at the same
|
||||
time, or the next `kubectl apply` reverts it (see `DEV_ONBOARDING.md` §2.3).
|
||||
|
||||
**Everything else below is ungated** and live regardless of the flag: `next_hub`,
|
||||
the handover endpoint, `next_action`/`next_hub` on the queue read,
|
||||
`pickup_source_type`, base master data, inbound visibility, reconciliation and
|
||||
the routing block.
|
||||
|
||||
---
|
||||
|
||||
## State transitions — the three journeys
|
||||
|
||||
`consignmentstatus` is the consignment's own state; `booking.status` moves to
|
||||
`Converted_To_Consignment` at pickup-complete in all three and stops there.
|
||||
|
||||
### Base/Hub H1 → Customer
|
||||
|
||||
Pickup source is a base; the parcel then goes to a person. Routing is decided by
|
||||
pincode, exactly as for any other pickup — a base-origin booking delivering into
|
||||
the same postal area is hyperlocal.
|
||||
|
||||
| Step | Call | `consignmentstatus` | `next_action` |
|
||||
|---|---|---|---|
|
||||
| assigned | — | (no consignment yet) | `pickup` |
|
||||
| collected at the base | `POST /miler/bookings/:id/pickup-complete` | `Collected_By_Miler` | `start_delivery` |
|
||||
| heading out | `POST /miler/consignments/:id/start-delivery` | `Out_for_Delivery` | `deliver` |
|
||||
| delivered | `POST /miler/consignments/:id/deliver` | `Delivered` | `none` |
|
||||
|
||||
The booking row carries `pickup_source_type: "hub"` and `sourceid` /
|
||||
`pickuplocationid` = the base id, so Home names the base as the pickup source
|
||||
rather than the rider's own office.
|
||||
|
||||
With `MILER_COLLECTED_STATE_ENABLED` off, pickup-complete goes straight to
|
||||
`Out_for_Delivery` / `deliver` and there is no start-delivery step. That flag is
|
||||
already `true` in production.
|
||||
|
||||
### Customer → Customer (hyperlocal)
|
||||
|
||||
Identical to the table above from pickup-complete onward; the only difference is
|
||||
`pickup_source_type: "customer"` and `sourceid: null`, with the sender's own name
|
||||
and address on the row.
|
||||
|
||||
### Customer → Base (intercity / interstate)
|
||||
|
||||
| Step | Call | `consignmentstatus` | `next_action` | `next_hub` |
|
||||
|---|---|---|---|---|
|
||||
| assigned | — | (no consignment yet) | `pickup` | null |
|
||||
| collected | `POST /miler/bookings/:id/pickup-complete` | `Created` | `inward_at_hub` | the base, six fields |
|
||||
| handed over at the base | `POST /miler/consignments/:id/inward-at-hub` | `Inwarded_at_Hub` | `handed_to_hub` | null |
|
||||
|
||||
After `Inwarded_at_Hub` the parcel is the network's problem, not the rider's —
|
||||
tripsheet, transit, and a final-mile rider at the other end.
|
||||
|
||||
**With the flag off**, the middle row does not exist: pickup-complete returns
|
||||
`Inwarded_at_Hub` / `handed_to_hub` directly, still with `next_hub` populated so
|
||||
the app can name the base. `inward-at-hub` called against such a parcel answers
|
||||
200 with the state that stands and `already_inwarded: true`, rather than failing.
|
||||
|
||||
---
|
||||
|
||||
## What changed, request by request
|
||||
|
||||
### 25 — `next_hub` on pickup-complete
|
||||
|
||||
`POST /miler/bookings/:bookingid/pickup-complete` now returns `next_hub` whenever
|
||||
the parcel's next leg is a base, with all six fields:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tracking_no": "DM...",
|
||||
"consignment_id": 4821, // always present
|
||||
"consignmentstatus": "Created",
|
||||
"status": "Created", // alias, same value
|
||||
"booking_no": "BK...",
|
||||
"booking_status": "Converted_To_Consignment",
|
||||
"next_action": "inward_at_hub",
|
||||
"next_hub": {
|
||||
"id": 1,
|
||||
"name": "Coimbatore Hub",
|
||||
"address": "14 Avinashi Road, Peelamedu, Coimbatore",
|
||||
"pincode": "641004",
|
||||
"latitude": 11.0272,
|
||||
"longitude": 76.9905
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`next_hub` is absent for a hyperlocal parcel — there is no base leg.
|
||||
|
||||
**Which base.** `resolveHandoverHub`, in order: the base the booking was routed
|
||||
to (`nearesthubid`, nothing populates this column today — it is checked first so
|
||||
that it wins the moment something does), then the collecting rider's own base
|
||||
(the operational default), then the nearest **active** base to the pickup point,
|
||||
then any base at all. The app never chooses; it navigates to what it is given.
|
||||
|
||||
The nearest-active-base step replaced a fallback that took whichever hub row came
|
||||
back first from an unordered query.
|
||||
|
||||
### 26 — the handover mutation
|
||||
|
||||
```
|
||||
POST /miler/consignments/:id/inward-at-hub
|
||||
Idempotency-Key: <optional, same middleware as pickup-complete>
|
||||
|
||||
{ "hub_id": 1, "latitude": 11.0272, "longitude": 76.9905 }
|
||||
```
|
||||
|
||||
`hubid` is accepted as an alias for `hub_id`; `lat`/`lon` for
|
||||
`latitude`/`longitude`. The whole body is optional — with nothing sent, the parcel
|
||||
is handed into the base it was already routed to.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"consignmentid": 4821,
|
||||
"trackingno": "DM...",
|
||||
"consignmentstatus": "Inwarded_at_Hub",
|
||||
"inwardedat": "2026-09-02T14:22:10Z",
|
||||
"hub": { "id": 1, "name": "...", "address": "...", "pincode": "...",
|
||||
"latitude": 11.0272, "longitude": 76.9905 },
|
||||
"next_action": "handed_to_hub",
|
||||
"already_inwarded": false
|
||||
}
|
||||
```
|
||||
|
||||
It names the resulting state, per the rule request 15 exists for. Idempotent
|
||||
twice over: the route carries the shared `Idempotency-Key` middleware, and a
|
||||
parcel already inwarded answers 200 with `already_inwarded: true` rather than a
|
||||
4xx — a retry after a dropped response confirms instead of erroring.
|
||||
|
||||
Side effects, all in one transaction: status and `currenthubid` set, `inwardedat`
|
||||
stamped, a `consignmenthistory` row written with the rider's coordinates, the
|
||||
`BookingAssignment` closed as `Completed` with `riderkms` (pickup → base gate) and
|
||||
`ridercharges`, and the rider returned to `Available`. Before this, an intercity
|
||||
rider's every job reported zero distance and zero value on `/miler/earnings`.
|
||||
|
||||
Errors: `CONSIGNMENT_NOT_FOUND` (404), `CONSIGNMENT_NOT_ASSIGNED` (403),
|
||||
`HUB_REQUIRED` / `HUB_NOT_FOUND` (400/404), `INVALID_STATE` (400) for a parcel
|
||||
already out for delivery or past this leg.
|
||||
|
||||
### 27 — `next_action` and `next_hub` on the queue read
|
||||
|
||||
Every row of `GET /miler/bookings` now carries both, derived from server state on
|
||||
each read by the same helper pickup-complete uses — the pivot's answer and the
|
||||
poll's answer cannot drift.
|
||||
|
||||
| consignment state | `next_action` | `next_hub` |
|
||||
|---|---|---|
|
||||
| no consignment yet | `pickup` | null |
|
||||
| `Created` | `inward_at_hub` | the base |
|
||||
| `Collected_By_Miler` | `start_delivery` | null |
|
||||
| `Out_for_Delivery` | `deliver` | null |
|
||||
| `Inwarded_at_Hub` | `handed_to_hub` | null |
|
||||
| anything terminal | `none` | null |
|
||||
|
||||
`GET /miler/consignments/:consignmentid` carries the same pair, plus
|
||||
`can_inward_at_hub` and `inwardedat`, so a single-parcel refresh is as
|
||||
authoritative as a full poll.
|
||||
|
||||
### 28 — `pickup_source_type` on the booking row
|
||||
|
||||
On the row, never on a location master — a customer-door pickup has no location
|
||||
id at all, so a type held against locations could never classify one.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"bookingid": 4821,
|
||||
"pickup_source_type": "hub", // hub | customer | merchant | store
|
||||
"sourceid": 1, // null for a customer door
|
||||
"pickuplocationid": 1, // alias, same value
|
||||
"pickup_source_name": "Coimbatore Hub",
|
||||
"pickupaddress": "14 Avinashi Road, Peelamedu, Coimbatore",
|
||||
"pickuppincode": "641004",
|
||||
"deliverypincode": "600001"
|
||||
}
|
||||
```
|
||||
|
||||
Sent on every booking, with `"customer"` as a value rather than an omission.
|
||||
`pickuplocationid` on this row is the source id — not the `pickuplocationid`
|
||||
column on `pickupbookings`, which foreign-keys to `appcustomerlocations` and is a
|
||||
different concept. The booking row never carried either spelling before, so
|
||||
nothing is being redefined out from under a reader.
|
||||
|
||||
Storage is the new `pickupbookings.pickupsourcetype` column, written at creation.
|
||||
Rows created before it existed are classified on read: names a base → `hub`,
|
||||
names a client site → `merchant`, otherwise → `customer`. A stored value always
|
||||
wins. An unrecognised type is dropped at write rather than stored, so the column
|
||||
never holds a word the app has no meaning for.
|
||||
|
||||
### 29 — base master data
|
||||
|
||||
`Hub` already carried all six fields; what was missing was a route a rider token
|
||||
could read. `/admin/tenants/:id/locations` is a different dataset — a client's own
|
||||
sites, not bases — and `/admin/*` requires roles 1/3/4 while a rider is role 5, so
|
||||
that 401 is by design, not an oversight.
|
||||
|
||||
```
|
||||
GET /miler/bases ?status=Active (default) &applocationid=
|
||||
```
|
||||
|
||||
Returns `{id, name, address, pincode, latitude, longitude}` per base, plus
|
||||
`distance_km` and nearest-first ordering when the rider has reported a position.
|
||||
|
||||
`GET /admin/hubs` (console) already returns full hub rows and is unchanged.
|
||||
|
||||
### 30 — inbound visibility and receiving
|
||||
|
||||
```
|
||||
GET /hub/inbound/expected on the way in, not yet handed over
|
||||
POST /hub/inbound/:id/reconcile { "received": true|false, "remarks": "..." }
|
||||
```
|
||||
|
||||
`expected` lists consignments on `Created` whose current base is this one —
|
||||
rider, source and source type, customer, pickup and destination address,
|
||||
destination pincode, current state, `inbound_status: "expected"`. Tenant-scoped:
|
||||
partner-tenant staff see only their own client's parcels
|
||||
(`scopeConsignmentsToOwnTenant`, the consignment counterpart of the existing
|
||||
booking scoping).
|
||||
|
||||
`reconcile` is the receiving side. `received: true` inwards the parcel and is
|
||||
idempotent — staff working through a pile will hit rows twice. `received: false`
|
||||
is the dispute path: it does **not** quietly move the parcel backwards, it raises
|
||||
an open `ConsignmentException` naming the discrepancy, so a parcel a rider swears
|
||||
was handed over and staff never saw becomes a tracked item rather than an
|
||||
argument nobody owns.
|
||||
|
||||
The exception type is `Lost` — the closest value the `consignmentexceptions`
|
||||
CHECK constraint already permits. A dedicated `Handover_Not_Received` type would
|
||||
need that constraint widened first (see `DEV_ONBOARDING.md` §2.5).
|
||||
|
||||
The pre-existing console inwarding path (`POST /hub/bookings/:id/inbound`) still
|
||||
works and now stamps `inwardedat` too.
|
||||
|
||||
### 31 — the routing decision on booking detail
|
||||
|
||||
`GET /admin/bookings/:id` keeps every field it returned and adds `routing`
|
||||
alongside them:
|
||||
|
||||
```jsonc
|
||||
"routing": {
|
||||
"pickup_source_type": "customer",
|
||||
"pickup_source_id": null,
|
||||
"pickup_source_name": "Anitha R",
|
||||
"from_address": "12 Race Course Road, Coimbatore",
|
||||
"from_pincode": "641018",
|
||||
"to_address": "44 Mount Road, Chennai",
|
||||
"destination_pincode": "600002",
|
||||
"is_hyperlocal": false,
|
||||
"consignment_state": "Created",
|
||||
"next_action": "inward_at_hub",
|
||||
"next_hub": { "id": 1, "name": "Coimbatore Hub", ... },
|
||||
"inwardedat": null,
|
||||
"decided": true
|
||||
}
|
||||
```
|
||||
|
||||
`decided` is false before pickup, when the routing result is a projection from
|
||||
the captured from/to rather than a decision that has been taken. `is_hyperlocal`
|
||||
is computed by the same helper pickup-complete uses, so the shown reason cannot
|
||||
disagree with the actual routing.
|
||||
|
||||
---
|
||||
|
||||
## Route sequencing knows about the base
|
||||
|
||||
`internal/routing` orders a rider's active stops via the Route Optimization API.
|
||||
It read `pickupbookings.deliverylatitude` for every assignment, with no idea
|
||||
whether the parcel was hub-routed — so a Coimbatore → Chennai booking told the
|
||||
optimizer the rider was riding 430 km to the receiver, when the real next stop is
|
||||
a base a few kilometres away. One such destination in a rider's set also drags
|
||||
the ordering of every genuine local stop beside it, because the solver is
|
||||
optimising a journey nobody is going to make.
|
||||
|
||||
`dropForLeg` now decides where THIS rider's leg ends: the receiver for a
|
||||
hyperlocal parcel, the base for a hub-routed one. The base comes from the same
|
||||
order of preference as `resolveHandoverHub` — the booking's `nearesthubid` if
|
||||
anything set it, otherwise the rider's own base — joined in by the stop query. A
|
||||
hub-routed stop with no usable base coordinates is left unsequenced rather than
|
||||
pointed at the receiver: one missing stop is better than a skewed route.
|
||||
|
||||
The final destination is not lost. It is simply not this leg — it belongs to
|
||||
whoever carries the parcel out of the base.
|
||||
|
||||
**`internal/legs`** exists for this. The hyperlocal rule is needed by
|
||||
`controllers` (which state a consignment lands in) and by `internal/routing`
|
||||
(where the leg ends), and `controllers` already imports `internal/routing`, so
|
||||
routing cannot import back. Rather than keep a second copy of the rule — the
|
||||
shape that has bitten this codebase before — it lives in a package both import.
|
||||
`controllers.haversineKM`, `isHyperlocal` and `isHyperlocalBooking` are now thin
|
||||
delegates, so their existing call sites and tests are unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
|
||||
Three additive, nullable columns, applied by `AutoMigrate` on the next deploy. No
|
||||
CHECK constraint needed widening — `Created` was already permitted on
|
||||
`consignments`.
|
||||
|
||||
| Table | Column | Why |
|
||||
|---|---|---|
|
||||
| `pickupbookings` | `pickupsourcetype varchar(20)` | request 28 |
|
||||
| `pickupbookings` | `pickuphubid int` | base-origin pickups; distinct from `nearesthubid`, which is the base a parcel is routed **to** |
|
||||
| `consignments` | `inwardedat timestamp` | the physical-receipt fact, distinct from `updatedat`, which moves on every write |
|
||||
|
||||
---
|
||||
|
||||
## Still open on this line
|
||||
|
||||
- **15** — `reached` persists the arrival fact (`arrivedat`, returned as
|
||||
`reachedat` on the booking row) but the booking status does not move to
|
||||
`Arrived_At_Pickup`. Half done; not touched by this work.
|
||||
- **16** — console rendering for `Arrived_At_Pickup` and `Collected_By_Miler`.
|
||||
- **14** — a failed-delivery outcome; `skip` still leaves the consignment
|
||||
`Out_for_Delivery`.
|
||||
- **`At_Customer` — answered.** It means **arrived at the pickup**. It is a
|
||||
`milerprofiles.availabilitystatus` value, not a booking or consignment state,
|
||||
so it says where the *rider* is rather than where the *parcel* is, and the only
|
||||
thing that writes it is `POST /miler/bookings/:bookingid/reached`
|
||||
(`BookingReachedCustomer`, `milerController.go`) — the pickup-arrival action.
|
||||
Nothing sets it on a delivery leg; a rider heading to a receiver goes
|
||||
`On_Delivery`. The name is misleading and predates the current lifecycle.
|
||||
- **`reject` — answered.** `RejectMilerAssignment` accepts the reason **either
|
||||
way**: it parses the JSON body first and falls back to `?reason=`, defaulting to
|
||||
"Rejected by rider" if neither is present. The doc/deployed disagreement was
|
||||
settled by accepting both, so the app can keep sending both.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Doormile Miler App — API reference
|
||||
|
||||
The rider-app surface only (`/miler/*`). 38 routes: 3 auth + 35 authenticated.
|
||||
The rider-app surface only (`/miler/*`). 45 routes: 3 auth + 42 authenticated.
|
||||
Base URL `https://api.doormile.com/api/v1`.
|
||||
|
||||
This supersedes "Miler App API Contract v1.0" where the two disagree — several
|
||||
@@ -48,13 +48,14 @@ Credential endpoints share a **10/min** rate limit.
|
||||
|
||||
---
|
||||
|
||||
## Profile & device
|
||||
## Profile, device & uploads
|
||||
|
||||
| Method | Path | Body |
|
||||
|---|---|---|
|
||||
| GET | `/miler/profile` | |
|
||||
| GET | `/miler/profile` | Exposes `tenantname`, profile details, vehicle info |
|
||||
| PUT | `/miler/profile` | `{ displayname, profilephotourl, defaultvehicletype, phone }` |
|
||||
| PUT | `/miler/device-token` | `{ "device_token": "..." }` — snake_case |
|
||||
| POST | `/miler/uploads/sign` | `{ "content_type": "image/jpeg", "kind": "pod" }` → `{ "upload_url": "...", "key": "..." }` |
|
||||
|
||||
## Location & availability
|
||||
|
||||
@@ -126,6 +127,44 @@ prefix it's hyperlocal and the consignment goes straight to `Out_for_Delivery`
|
||||
in the rider's hands. Otherwise it routes via the hub. The consignment inherits
|
||||
the **booking's** tenant, not the rider's.
|
||||
|
||||
It returns `next_action` and, when the next leg is a base, `next_hub` with all
|
||||
six fields (`id, name, address, pincode, latitude, longitude`) — the app never
|
||||
picks a base itself. `consignment_id` is always present.
|
||||
|
||||
```jsonc
|
||||
{ "consignment_id": 4821, "consignmentstatus": "Created",
|
||||
"next_action": "inward_at_hub",
|
||||
"next_hub": { "id": 1, "name": "Coimbatore Hub",
|
||||
"address": "14 Avinashi Road, Peelamedu, Coimbatore",
|
||||
"pincode": "641004", "latitude": 11.0272, "longitude": 76.9905 } }
|
||||
```
|
||||
|
||||
## The base handover
|
||||
|
||||
```
|
||||
POST /miler/consignments/:id/inward-at-hub Idempotency-Key supported
|
||||
{ "hub_id": 1, "latitude": 11.0272, "longitude": 76.9905 }
|
||||
→ { consignmentstatus: "Inwarded_at_Hub", inwardedat, hub, next_action,
|
||||
already_inwarded }
|
||||
```
|
||||
|
||||
The authoritative record that a rider handed a parcel in at a base. The body is
|
||||
optional (it defaults to the base the parcel is routed to); `hubid`, `lat` and
|
||||
`lon` are accepted as aliases. Answers with the resulting state, never a bare
|
||||
200. A parcel already inwarded answers 200 with `already_inwarded: true`.
|
||||
|
||||
```
|
||||
GET /miler/bases ?status=Active &applocationid=
|
||||
```
|
||||
|
||||
Base master data on a rider token — the six fields per base, plus `distance_km`
|
||||
and nearest-first ordering once the rider has reported a position.
|
||||
`/admin/tenants/:id/locations` is a different dataset (a client's own sites) and
|
||||
is closed to role 5 by design.
|
||||
|
||||
**Wording:** the wire says hub, the rider app says Base. Full contract and state
|
||||
transitions in [`logistics-base-handover.md`](logistics-base-handover.md).
|
||||
|
||||
## Delivery
|
||||
|
||||
| Method | Path | Body |
|
||||
@@ -151,6 +190,20 @@ the **booking's** tenant, not the rider's.
|
||||
| GET | `/miler/bookings` | `?status=&date=YYYY-MM-DD` |
|
||||
| GET | `/miler/earnings` | `?period=daily\|weekly\|monthly&date=YYYY-MM-DD` |
|
||||
|
||||
Every `/miler/bookings` row carries the leg and the pickup source, rebuilt from
|
||||
server state on each read, so a poll or a cold restart needs no local cache:
|
||||
|
||||
| Field | Values |
|
||||
|---|---|
|
||||
| `next_action` | `pickup`, `inward_at_hub`, `start_delivery`, `deliver`, `handed_to_hub`, `none` |
|
||||
| `next_hub` | the six base fields, or null when the next leg isn't a base |
|
||||
| `pickup_source_type` | `hub`, `customer`, `merchant`, `store` — always sent, `customer` is a value not an omission |
|
||||
| `sourceid` / `pickuplocationid` | the base or client-site id; null for a customer door |
|
||||
| `pickup_source_name` | the base/site name, or the sender's name for a door pickup |
|
||||
|
||||
An unrecognised `pickup_source_type` should be treated as a generic pickup — new
|
||||
values may be added.
|
||||
|
||||
`bonuspoints` stays zero — nothing writes it yet. That's known and deliberate.
|
||||
|
||||
## Telemetry (Redis-backed, high frequency)
|
||||
@@ -235,4 +288,9 @@ build a UI that depends on it.
|
||||
decided.
|
||||
2. `bonuspoints` is never written.
|
||||
3. `assignments/:id/reject` and `bookings/:id/vehicle-required` have never had a
|
||||
real request against them.
|
||||
real request against them. (`reject` accepts its reason in the body *or* as
|
||||
`?reason=`, preferring the body — both spellings are honoured.)
|
||||
4. `At_Customer` on `milerprofiles.availabilitystatus` means **arrived at the
|
||||
pickup** — it is written only by `POST /miler/bookings/:bookingid/reached`.
|
||||
The name predates the current lifecycle; a rider heading to a receiver is
|
||||
`On_Delivery`.
|
||||
|
||||
1495
docs/openapi-customer.yaml
Normal file
1495
docs/openapi-customer.yaml
Normal file
File diff suppressed because it is too large
Load Diff
41
dto/auth.go
41
dto/auth.go
@@ -1,41 +1,10 @@
|
||||
package dto
|
||||
|
||||
type CustomerRegisterRequest struct {
|
||||
Firstname string `json:"firstname" xml:"firstname" form:"firstname"`
|
||||
Lastname string `json:"lastname" xml:"lastname" form:"lastname"`
|
||||
Phone string `json:"phone" xml:"phone" form:"phone"`
|
||||
Email string `json:"email" xml:"email" form:"email"`
|
||||
Pin string `json:"pin" xml:"pin" form:"pin"` // 4 or 6 digit PIN
|
||||
Configid int `json:"configid"`
|
||||
}
|
||||
|
||||
type CustomerLoginRequest struct {
|
||||
Phone string `json:"phone" xml:"phone" form:"phone"`
|
||||
Configid int `json:"configid"`
|
||||
}
|
||||
|
||||
type CustomerPinVerifyRequest struct {
|
||||
Phone string `json:"phone" xml:"phone" form:"phone"`
|
||||
Pin string `json:"pin" xml:"pin" form:"pin"`
|
||||
Configid int `json:"configid"`
|
||||
DeviceToken string `json:"device_token"`
|
||||
}
|
||||
|
||||
type CustomerResetPinRequest struct {
|
||||
Phone string `json:"phone" xml:"phone" form:"phone"`
|
||||
NewPin string `json:"new_pin" xml:"new_pin" form:"new_pin"`
|
||||
Configid int `json:"configid"`
|
||||
}
|
||||
|
||||
type SendEmailOtpRequest struct {
|
||||
Email string `json:"email" xml:"email" form:"email"`
|
||||
Phone string `json:"phone" xml:"phone" form:"phone"`
|
||||
}
|
||||
|
||||
type VerifyEmailOtpRequest struct {
|
||||
Email string `json:"email" xml:"email" form:"email"`
|
||||
Otp string `json:"otp" xml:"otp" form:"otp"`
|
||||
}
|
||||
// The customer app has no password and no PIN: it authenticates on a 4-digit
|
||||
// code sent to a phone or an email address, and its request shapes are declared
|
||||
// inline in controllers/cxAuthController.go alongside the handlers that read
|
||||
// them. The PIN register/login/verify/reset request types that used to live
|
||||
// here went with that flow.
|
||||
|
||||
type MilerLoginRequest struct {
|
||||
Phone string `json:"phone" xml:"phone" form:"phone"`
|
||||
|
||||
8
go.mod
8
go.mod
@@ -3,13 +3,17 @@ module doormile
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
firebase.google.com/go/v4 v4.20.0
|
||||
github.com/gofiber/fiber/v2 v2.52.10
|
||||
github.com/gofiber/websocket/v2 v2.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/nats-io/nats.go v1.31.0
|
||||
github.com/redis/go-redis/v9 v9.16.0
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/crypto v0.51.0
|
||||
google.golang.org/api v0.279.0
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
@@ -25,7 +29,6 @@ require (
|
||||
cloud.google.com/go/longrunning v1.0.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.29.0 // indirect
|
||||
cloud.google.com/go/storage v1.62.1 // indirect
|
||||
firebase.google.com/go/v4 v4.20.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect
|
||||
@@ -41,7 +44,6 @@ require (
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gofiber/websocket/v2 v2.2.1 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
@@ -58,7 +60,6 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/nats-io/nats.go v1.31.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.5 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
@@ -86,7 +87,6 @@ require (
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.279.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/internal/cxstage"
|
||||
"doormile/internal/routing"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
@@ -227,8 +228,24 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate,
|
||||
return fmt.Errorf("update MilerProfile availability: %w", err)
|
||||
}
|
||||
|
||||
// The customer's "Miler assigned" milestone, in the same transaction as the
|
||||
// assignment it describes. The auto-assignment path is how most B2C
|
||||
// bookings get a rider, so without this the customer app's timeline would
|
||||
// only ever advance for manually assigned pickups.
|
||||
if err := cxstage.Record(tx, cxstage.Event{
|
||||
BookingID: booking.Bookingid,
|
||||
Stage: constants.CxStageAssigned,
|
||||
ActorType: constants.CxActorSystem,
|
||||
Source: "internal/assignment.commitAssignment",
|
||||
}); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("record assigned stage: %w", err)
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
go cxstage.Notify(booking.Bookingid, nil, constants.CxStageAssigned)
|
||||
|
||||
utils.Info("CRMAssignment: assigned",
|
||||
"booking_id", booking.Bookingid,
|
||||
"miler_id", milerUserID,
|
||||
|
||||
425
internal/cxstage/stage.go
Normal file
425
internal/cxstage/stage.go
Normal file
@@ -0,0 +1,425 @@
|
||||
// Package cxstage derives the customer app's view of a pickup from the writes
|
||||
// the miler app already makes.
|
||||
//
|
||||
// This is the part of the customer API that is not a new endpoint. Every
|
||||
// operational write it reacts to — accept, reached, parcel, pickup-complete,
|
||||
// inward-at-hub, start-delivery, deliver, cancel — already existed and was
|
||||
// already correct. What did not exist was any record of when a parcel reached a
|
||||
// stage, in the vocabulary the customer is shown. A consignment status says
|
||||
// where a parcel is now; it cannot say when it got there, and a timeline
|
||||
// assembled from "now" plus guesses is the thing this package exists to avoid.
|
||||
//
|
||||
// So: one append-only row per stage actually reached, with the real time and
|
||||
// the real actor, and the booking's current stage kept alongside it so a
|
||||
// tracking poll is one row rather than a replay. Nothing here is backfilled.
|
||||
// A booking that predates this package has a short history, and a short honest
|
||||
// history beats a long invented one — the customer cannot tell which entries
|
||||
// were guessed.
|
||||
package cxstage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/internal/notify"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Event is one stage transition to record.
|
||||
type Event struct {
|
||||
BookingID int
|
||||
// DestinationID is set for the per-order stages (in_transit onward), which
|
||||
// may differ between destinations of the same booking, and nil for the
|
||||
// booking-level stages that apply to the whole pickup.
|
||||
DestinationID *int
|
||||
Stage string
|
||||
ActorType string // constants.CxActor*
|
||||
ActorID *int
|
||||
// Source names the write that caused this — the endpoint path or the job.
|
||||
// It is what makes an audit trail answerable a year later.
|
||||
Source string
|
||||
Remarks string
|
||||
// At is when it actually happened. Zero means now.
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Record appends the event and advances the stored stage, inside the caller's
|
||||
// transaction.
|
||||
//
|
||||
// Pass the same *gorm.DB the operational write is using. A stage event that
|
||||
// commits while the write it describes rolls back is a customer being told
|
||||
// their parcel was collected when it was not — so the two share a transaction
|
||||
// or neither happens.
|
||||
//
|
||||
// Recording is idempotent per (booking, destination, stage): a rider tapping
|
||||
// "arrived" twice on bad signal produces one timeline entry, not two.
|
||||
func Record(tx *gorm.DB, in Event) error {
|
||||
if tx == nil || in.BookingID == 0 || in.Stage == "" {
|
||||
return fmt.Errorf("cxstage: incomplete event")
|
||||
}
|
||||
if constants.CxStageRank(in.Stage) < 0 {
|
||||
return fmt.Errorf("cxstage: unknown stage %q", in.Stage)
|
||||
}
|
||||
at := in.At
|
||||
if at.IsZero() {
|
||||
at = utils.DBNow()
|
||||
}
|
||||
if in.ActorType == "" {
|
||||
in.ActorType = constants.CxActorSystem
|
||||
}
|
||||
|
||||
// Only a booking the customer app is watching gets a projection.
|
||||
//
|
||||
// This guard is about blast radius, not tidiness. Record runs inside the
|
||||
// caller's transaction and returns its error, so a failure here fails the
|
||||
// operational write — and the operational writes it hooks into
|
||||
// (assignMilerTx, commitAssignment, pickup-complete) also serve
|
||||
// console-created express bookings, which no customer app has ever seen.
|
||||
// Without this, a problem in the customer projection could refuse a hub
|
||||
// staff member's manual assignment of a booking that has no customer
|
||||
// attached to it at all.
|
||||
var booking models.PickupBooking
|
||||
if err := tx.Select("bookingid, bookingsource, customerstage, customerstatus, status").
|
||||
First(&booking, in.BookingID).Error; err != nil {
|
||||
return fmt.Errorf("cxstage: load booking: %w", err)
|
||||
}
|
||||
if booking.Bookingsource != constants.BookingSourceCustomerApp {
|
||||
return nil
|
||||
}
|
||||
|
||||
var existing int64
|
||||
q := tx.Model(&models.BookingStageEvent{}).
|
||||
Where("bookingid = ? AND stage = ?", in.BookingID, in.Stage)
|
||||
if in.DestinationID != nil {
|
||||
q = q.Where("bookingdestinationid = ?", *in.DestinationID)
|
||||
} else {
|
||||
q = q.Where("bookingdestinationid IS NULL")
|
||||
}
|
||||
if err := q.Count(&existing).Error; err != nil {
|
||||
return fmt.Errorf("cxstage: count existing: %w", err)
|
||||
}
|
||||
if existing == 0 {
|
||||
event := models.BookingStageEvent{
|
||||
Bookingid: in.BookingID,
|
||||
Bookingdestinationid: in.DestinationID,
|
||||
Stage: in.Stage,
|
||||
Actortype: in.ActorType,
|
||||
Actorid: in.ActorID,
|
||||
Source: in.Source,
|
||||
Remarks: in.Remarks,
|
||||
Occurredat: at,
|
||||
}
|
||||
if err := tx.Create(&event).Error; err != nil {
|
||||
return fmt.Errorf("cxstage: append event: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if in.DestinationID != nil {
|
||||
if err := advanceDestination(tx, *in.DestinationID, in.Stage, at); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return advanceBooking(tx, &booking, in.Stage)
|
||||
}
|
||||
|
||||
// advanceDestination moves one order forward. Never backwards: a re-delivery
|
||||
// attempt that re-issues out_for_delivery must not un-deliver a sibling, and a
|
||||
// late-arriving event must not rewind a parcel the customer has already been
|
||||
// told arrived.
|
||||
func advanceDestination(tx *gorm.DB, destinationID int, stage string, at time.Time) error {
|
||||
var dest models.BookingDestination
|
||||
if err := tx.First(&dest, destinationID).Error; err != nil {
|
||||
return fmt.Errorf("cxstage: load destination: %w", err)
|
||||
}
|
||||
if constants.CxStageRank(stage) <= constants.CxStageRank(dest.Stage) {
|
||||
return nil
|
||||
}
|
||||
updates := map[string]interface{}{"stage": stage, "updatedat": utils.DBNow()}
|
||||
if stage == constants.CxStageDelivered {
|
||||
updates["deliveredat"] = at
|
||||
}
|
||||
return tx.Model(&models.BookingDestination{}).
|
||||
Where("bookingdestinationid = ?", destinationID).Updates(updates).Error
|
||||
}
|
||||
|
||||
// advanceBooking recomputes the booking's own stage and status.
|
||||
//
|
||||
// For stages 0-5 that is simply "the furthest stage reached". For 6-8 it is the
|
||||
// LEAST-advanced destination, because a booking is only out for delivery when
|
||||
// everything in it is, and only delivered when the last parcel lands. Taking
|
||||
// the maximum instead would show a customer "Delivered" while one of their
|
||||
// three parcels was still at a hub.
|
||||
func advanceBooking(tx *gorm.DB, booking *models.PickupBooking, stage string) error {
|
||||
bookingID := booking.Bookingid
|
||||
if booking.Customerstatus == constants.CxStatusCancelled {
|
||||
// A cancelled pickup is terminal. Late telemetry from a rider who was
|
||||
// already stood down must not resurrect it.
|
||||
return nil
|
||||
}
|
||||
|
||||
next := stage
|
||||
if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStageInTransit] {
|
||||
var destinations []models.BookingDestination
|
||||
if err := tx.Select("bookingdestinationid, stage").
|
||||
Where("bookingid = ?", bookingID).Find(&destinations).Error; err != nil {
|
||||
return fmt.Errorf("cxstage: load destinations: %w", err)
|
||||
}
|
||||
next = slowestDestinationStage(destinations, stage)
|
||||
}
|
||||
|
||||
if constants.CxStageRank(next) <= constants.CxStageRank(booking.Customerstage) {
|
||||
return nil
|
||||
}
|
||||
|
||||
status := constants.CxStatusActive
|
||||
if next == constants.CxStageDelivered {
|
||||
status = constants.CxStatusCompleted
|
||||
}
|
||||
|
||||
return tx.Model(&models.PickupBooking{}).
|
||||
Where("bookingid = ?", bookingID).
|
||||
Updates(map[string]interface{}{
|
||||
"customerstage": next,
|
||||
"customerstatus": status,
|
||||
"updatedat": utils.DBNow(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// slowestDestinationStage returns the least-advanced stage across a booking's
|
||||
// orders, which is the booking's real progress.
|
||||
func slowestDestinationStage(destinations []models.BookingDestination, fallback string) string {
|
||||
if len(destinations) == 0 {
|
||||
return fallback
|
||||
}
|
||||
slowest := ""
|
||||
for _, d := range destinations {
|
||||
s := d.Stage
|
||||
if s == "" {
|
||||
// An order that has not reported anything yet holds the booking at
|
||||
// order_created — it exists, it just has not moved.
|
||||
s = constants.CxStageOrderCreated
|
||||
}
|
||||
if slowest == "" || constants.CxStageRank(s) < constants.CxStageRank(slowest) {
|
||||
slowest = s
|
||||
}
|
||||
}
|
||||
if slowest == "" {
|
||||
return fallback
|
||||
}
|
||||
return slowest
|
||||
}
|
||||
|
||||
// Release walks a booking BACK to booked because its rider let it go.
|
||||
//
|
||||
// A miler cancelling or skipping does not cancel the pickup — it returns it to
|
||||
// the pool for reassignment. Without this the customer keeps seeing "Miler
|
||||
// assigned" against a booking that has no miler, and the screen shows a rider
|
||||
// card for someone who is no longer coming. Stage is the only thing that moves
|
||||
// backwards: the history entries for assigned and on_the_way stay, because
|
||||
// those things did happen, and when a new rider accepts, Record's dedupe means
|
||||
// no second copy is appended.
|
||||
//
|
||||
// This is the one exception to "stages only advance", and it is deliberate:
|
||||
// the alternative is showing the customer a rider who is not on their way.
|
||||
func Release(tx *gorm.DB, bookingID int, reason, actorType string, actorID *int, source string) error {
|
||||
if !isCustomerBooking(tx, bookingID) {
|
||||
return nil
|
||||
}
|
||||
now := utils.DBNow()
|
||||
|
||||
if err := tx.Model(&models.PickupBooking{}).
|
||||
Where("bookingid = ?", bookingID).
|
||||
Updates(map[string]interface{}{
|
||||
"customerstage": constants.CxStageBooked,
|
||||
"customerstatus": constants.CxStatusActive,
|
||||
"updatedat": now,
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("cxstage: release booking: %w", err)
|
||||
}
|
||||
|
||||
event := models.BookingStageEvent{
|
||||
Bookingid: bookingID,
|
||||
Stage: constants.CxStageBooked,
|
||||
Actortype: actorType,
|
||||
Actorid: actorID,
|
||||
Source: source,
|
||||
Remarks: "released: " + reason,
|
||||
Occurredat: now,
|
||||
}
|
||||
if err := tx.Create(&event).Error; err != nil {
|
||||
return fmt.Errorf("cxstage: record release: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel marks a whole pickup cancelled and records who did it.
|
||||
//
|
||||
// Cancellation is whole-pickup: there is no partial cancel in v1, so every
|
||||
// destination goes with it. The reason is stored because a customer who did not
|
||||
// press the button — a miler skip, an ops stand-down — is otherwise told their
|
||||
// pickup vanished with no explanation.
|
||||
func Cancel(tx *gorm.DB, bookingID int, reason, actorType string, actorID *int, source string) error {
|
||||
if !isCustomerBooking(tx, bookingID) {
|
||||
return nil
|
||||
}
|
||||
now := utils.DBNow()
|
||||
if err := tx.Model(&models.PickupBooking{}).
|
||||
Where("bookingid = ?", bookingID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": constants.BookingCancelled,
|
||||
"customerstatus": constants.CxStatusCancelled,
|
||||
"cancelreason": reason,
|
||||
"updatedat": now,
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("cxstage: cancel booking: %w", err)
|
||||
}
|
||||
|
||||
// Recorded on the timeline as an event with a real actor, not as a silent
|
||||
// status flip. "cancelled" is not one of the nine stages the client parses,
|
||||
// so it rides the event log for the audit trail and the client reads
|
||||
// status/cancelReason for the display.
|
||||
event := models.BookingStageEvent{
|
||||
Bookingid: bookingID,
|
||||
Stage: constants.CxStageBooked,
|
||||
Actortype: actorType,
|
||||
Actorid: actorID,
|
||||
Source: source,
|
||||
Remarks: "cancelled: " + reason,
|
||||
Occurredat: now,
|
||||
}
|
||||
// Deliberately not deduped against the booked event: this row is the
|
||||
// cancellation record, distinguished by its remarks, and losing it would
|
||||
// leave the cancellation unattributed.
|
||||
if err := tx.Create(&event).Error; err != nil {
|
||||
return fmt.Errorf("cxstage: record cancellation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Notification ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Notify pushes a stage change to the customer's devices. Call it AFTER the
|
||||
// transaction commits — a notification for a rolled-back write cannot be
|
||||
// recalled.
|
||||
//
|
||||
// Only customer-visible milestones are sent. on_the_way and order_created roll
|
||||
// up on the timeline rather than earning their own notification: a customer who
|
||||
// gets a buzz for every operational transition stops reading them, and then
|
||||
// misses the one that mattered.
|
||||
func Notify(bookingID int, destinationID *int, stage string) {
|
||||
title, body, send := milestoneCopy(stage)
|
||||
if !send {
|
||||
return
|
||||
}
|
||||
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.Select("bookingid, bookingno, appcustomerid").
|
||||
First(&booking, bookingID).Error; err != nil {
|
||||
utils.Warn("cxstage: could not load booking for notification", "booking_id", bookingID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
trackingID := ""
|
||||
if destinationID != nil {
|
||||
var dest models.BookingDestination
|
||||
if err := db.DB.Select("trackingno").First(&dest, *destinationID).Error; err == nil {
|
||||
trackingID = dest.Trackingno
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]string{
|
||||
"type": "stage_change",
|
||||
"reference": booking.Bookingno,
|
||||
"stage": stage,
|
||||
"title": title,
|
||||
"body": body,
|
||||
// Sent from day one even though the client has no intent filter yet —
|
||||
// the app starts honouring it in a later build, and a notification
|
||||
// already in someone's tray should still open the right screen then.
|
||||
"deepLink": "doormile://track/" + booking.Bookingno,
|
||||
}
|
||||
if trackingID != "" {
|
||||
payload["trackingId"] = trackingID
|
||||
payload["deepLink"] = "doormile://track/" + trackingID
|
||||
}
|
||||
payload["booking_id"] = strconv.Itoa(bookingID)
|
||||
|
||||
for _, token := range deviceTokens(booking.Appcustomerid) {
|
||||
if err := notify.SendToDevice(token, title, body, payload); err != nil {
|
||||
utils.Warn("cxstage: push failed", "booking_id", bookingID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// milestoneCopy maps a stage to the notification the customer reads, and says
|
||||
// whether it earns one at all.
|
||||
func milestoneCopy(stage string) (title, body string, send bool) {
|
||||
switch stage {
|
||||
case constants.CxStageAssigned:
|
||||
return "Miler assigned", "Your Miler is on the way to collect your packages.", true
|
||||
case constants.CxStageArrived:
|
||||
return "Your Miler has arrived", "They are at your pickup address now.", true
|
||||
case constants.CxStagePickedUp:
|
||||
return "Packages collected", "Your Miler has collected and weighed your packages.", true
|
||||
case constants.CxStageInTransit:
|
||||
return "In transit", "Your package is on its way to the destination.", true
|
||||
case constants.CxStageOutForDelivery:
|
||||
return "Out for delivery", "Arriving today at the delivery address.", true
|
||||
case constants.CxStageDelivered:
|
||||
return "Delivered", "Your package has been handed over.", true
|
||||
default:
|
||||
// booked (the customer just made it), on_the_way and order_created all
|
||||
// roll up on the timeline.
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
// deviceTokens returns every device the customer is signed in on. One row per
|
||||
// device, not one column on the customer, so a phone and a tablet both get the
|
||||
// update instead of only whichever registered last.
|
||||
func deviceTokens(customerID int) []string {
|
||||
var devices []models.CustomerDevice
|
||||
if err := db.DB.Select("token").Where("appcustomerid = ?", customerID).
|
||||
Find(&devices).Error; err != nil {
|
||||
utils.Warn("cxstage: device lookup failed", "customer_id", customerID, "error", err)
|
||||
return nil
|
||||
}
|
||||
tokens := make([]string, 0, len(devices))
|
||||
for _, d := range devices {
|
||||
if d.Token != "" {
|
||||
tokens = append(tokens, d.Token)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// RecordAndNotify is the convenience the operational handlers use: record
|
||||
// inside the transaction, then push once it has committed. The caller is
|
||||
// responsible for calling the returned function only after a successful commit.
|
||||
func RecordAndNotify(tx *gorm.DB, in Event) (afterCommit func(), err error) {
|
||||
if err := Record(tx, in); err != nil {
|
||||
return func() {}, err
|
||||
}
|
||||
bookingID, destinationID, stage := in.BookingID, in.DestinationID, in.Stage
|
||||
return func() { go Notify(bookingID, destinationID, stage) }, nil
|
||||
}
|
||||
|
||||
// isCustomerBooking reports whether a booking is one the customer app is
|
||||
// watching. Cancel and Release are called from paths that also serve
|
||||
// console-created express bookings, and those have no customer projection to
|
||||
// keep in step — writing one would put stage rows against bookings nobody will
|
||||
// ever read them for, and would fail an ops action if that write failed.
|
||||
func isCustomerBooking(tx *gorm.DB, bookingID int) bool {
|
||||
var booking models.PickupBooking
|
||||
if err := tx.Select("bookingid, bookingsource").First(&booking, bookingID).Error; err != nil {
|
||||
utils.Warn("cxstage: could not read booking source", "booking_id", bookingID, "error", err)
|
||||
return false
|
||||
}
|
||||
return booking.Bookingsource == constants.BookingSourceCustomerApp
|
||||
}
|
||||
126
internal/cxstage/stage_test.go
Normal file
126
internal/cxstage/stage_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package cxstage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/models"
|
||||
)
|
||||
|
||||
// A booking's own stage, once its parcels have split into separate orders, is
|
||||
// the LEAST-advanced of them. Taking the maximum instead would tell a customer
|
||||
// "Delivered" while one of their three parcels was still sitting at a hub —
|
||||
// which is the single most damaging thing this projection could get wrong.
|
||||
|
||||
func TestSlowestDestinationStageHoldsAtTheLaggingOrder(t *testing.T) {
|
||||
destinations := []models.BookingDestination{
|
||||
{Bookingdestinationid: 1, Stage: constants.CxStageDelivered},
|
||||
{Bookingdestinationid: 2, Stage: constants.CxStageInTransit},
|
||||
{Bookingdestinationid: 3, Stage: constants.CxStageOutForDelivery},
|
||||
}
|
||||
|
||||
got := slowestDestinationStage(destinations, constants.CxStageDelivered)
|
||||
if got != constants.CxStageInTransit {
|
||||
t.Errorf("slowestDestinationStage = %q, want %q — a booking is only as far along as its slowest parcel",
|
||||
got, constants.CxStageInTransit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlowestDestinationStageCompletesOnlyWhenAllLand(t *testing.T) {
|
||||
destinations := []models.BookingDestination{
|
||||
{Bookingdestinationid: 1, Stage: constants.CxStageDelivered},
|
||||
{Bookingdestinationid: 2, Stage: constants.CxStageDelivered},
|
||||
}
|
||||
|
||||
if got := slowestDestinationStage(destinations, constants.CxStageDelivered); got != constants.CxStageDelivered {
|
||||
t.Errorf("slowestDestinationStage = %q, want %q", got, constants.CxStageDelivered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlowestDestinationStageTreatsSilentOrderAsOrderCreated(t *testing.T) {
|
||||
// An order that exists but has reported nothing yet holds the booking at
|
||||
// order_created. An empty stage must never sort as "furthest along".
|
||||
destinations := []models.BookingDestination{
|
||||
{Bookingdestinationid: 1, Stage: constants.CxStageOutForDelivery},
|
||||
{Bookingdestinationid: 2, Stage: ""},
|
||||
}
|
||||
|
||||
got := slowestDestinationStage(destinations, constants.CxStageOutForDelivery)
|
||||
if got != constants.CxStageOrderCreated {
|
||||
t.Errorf("slowestDestinationStage with a silent order = %q, want %q", got, constants.CxStageOrderCreated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlowestDestinationStageFallsBackWithNoDestinations(t *testing.T) {
|
||||
// A console-created booking has no destination rows at all. It must keep
|
||||
// the stage the caller was recording, not collapse to order_created.
|
||||
got := slowestDestinationStage(nil, constants.CxStageOutForDelivery)
|
||||
if got != constants.CxStageOutForDelivery {
|
||||
t.Errorf("slowestDestinationStage(nil) = %q, want the fallback %q", got, constants.CxStageOutForDelivery)
|
||||
}
|
||||
}
|
||||
|
||||
// The nine stage keys are a wire contract: the client parses them verbatim and
|
||||
// silently falls back to `booked` on anything it does not recognise, so a
|
||||
// renamed or reordered key makes a moving parcel look un-started.
|
||||
func TestStageRankOrderingIsTheContract(t *testing.T) {
|
||||
ordered := []string{
|
||||
constants.CxStageBooked,
|
||||
constants.CxStageAssigned,
|
||||
constants.CxStageOnTheWay,
|
||||
constants.CxStageArrived,
|
||||
constants.CxStagePickedUp,
|
||||
constants.CxStageOrderCreated,
|
||||
constants.CxStageInTransit,
|
||||
constants.CxStageOutForDelivery,
|
||||
constants.CxStageDelivered,
|
||||
}
|
||||
|
||||
for i := 1; i < len(ordered); i++ {
|
||||
if constants.CxStageRank(ordered[i]) <= constants.CxStageRank(ordered[i-1]) {
|
||||
t.Errorf("stage %q does not rank after %q", ordered[i], ordered[i-1])
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown stage must sort strictly BEHIND booked, not tie with it — a
|
||||
// booking written before this surface existed has no stage, and a tie would
|
||||
// let advanceBooking refuse to move it off nothing.
|
||||
if constants.CxStageRank("") >= constants.CxStageRank(constants.CxStageBooked) {
|
||||
t.Errorf("an empty stage ranks %d, which is not behind booked (%d)",
|
||||
constants.CxStageRank(""), constants.CxStageRank(constants.CxStageBooked))
|
||||
}
|
||||
if constants.CxStageRank("teleported") != -1 {
|
||||
t.Errorf("an unknown stage ranks %d, want -1", constants.CxStageRank("teleported"))
|
||||
}
|
||||
}
|
||||
|
||||
// Cancellation is allowed up to and including arrived, and refused from
|
||||
// picked_up onward. The UI mirrors this to hide the button; the server is the
|
||||
// authority, so the boundary is asserted here rather than trusted to a comment.
|
||||
func TestCancellationClosesAfterArrived(t *testing.T) {
|
||||
cancellable := []string{
|
||||
"", // a pre-surface booking is still cancellable
|
||||
constants.CxStageBooked,
|
||||
constants.CxStageAssigned,
|
||||
constants.CxStageOnTheWay,
|
||||
constants.CxStageArrived,
|
||||
}
|
||||
for _, stage := range cancellable {
|
||||
if !constants.CxCancellable(stage) {
|
||||
t.Errorf("stage %q should still be cancellable", stage)
|
||||
}
|
||||
}
|
||||
|
||||
closed := []string{
|
||||
constants.CxStagePickedUp,
|
||||
constants.CxStageOrderCreated,
|
||||
constants.CxStageInTransit,
|
||||
constants.CxStageOutForDelivery,
|
||||
constants.CxStageDelivered,
|
||||
}
|
||||
for _, stage := range closed {
|
||||
if constants.CxCancellable(stage) {
|
||||
t.Errorf("stage %q must not be cancellable — the parcel is already collected", stage)
|
||||
}
|
||||
}
|
||||
}
|
||||
71
internal/legs/legs.go
Normal file
71
internal/legs/legs.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Package legs answers one question, in one place: does a parcel go straight to
|
||||
// its receiver, or does it go via a base first?
|
||||
//
|
||||
// It exists because two packages need that answer and neither can import the
|
||||
// other. controllers decides it at pickup-complete (which state the consignment
|
||||
// lands in, and which base the rider is sent to); internal/routing needs it when
|
||||
// it sequences a rider's stops, because a hub-routed parcel's next stop is the
|
||||
// BASE, not the address on the booking. controllers already imports
|
||||
// internal/routing, so routing cannot import back — and a second copy of the
|
||||
// rule in routing is exactly the kind of duplication this codebase has been
|
||||
// bitten by before (the rival status table in dispatchShared, the batch windows
|
||||
// that drifted between two pages).
|
||||
//
|
||||
// So the rule lives here and both call it. Neither owns it.
|
||||
package legs
|
||||
|
||||
import "math"
|
||||
|
||||
// HaversineKM is the straight-line distance between two points, in kilometres.
|
||||
// One definition for the whole codebase — controllers.haversineKM delegates to
|
||||
// it rather than keeping a second copy.
|
||||
func HaversineKM(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const earthRadiusKM = 6371.0
|
||||
toRad := func(deg float64) float64 { return deg * math.Pi / 180 }
|
||||
dLat := toRad(lat2 - lat1)
|
||||
dLon := toRad(lon2 - lon1)
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
}
|
||||
|
||||
// MaxHyperlocalKM bounds the straight-line pickup→delivery distance under which
|
||||
// a booking with a missing or unusable pincode is still treated as hyperlocal.
|
||||
// It is ONLY consulted when the pincode rule cannot decide: console-created
|
||||
// kitchen→customer bookings frequently carry accurate coordinates and no
|
||||
// delivery pincode, and must not be routed through a base because of it.
|
||||
const MaxHyperlocalKM = 30.0
|
||||
|
||||
// SamePostalArea reports whether two pincodes fall in the same 3-digit postal
|
||||
// area. Pincodes shorter than 3 characters are treated as unknown rather than
|
||||
// matching, so bad data falls back to the safe hub route instead of quietly
|
||||
// claiming two parcels belong together.
|
||||
func SamePostalArea(pickupPincode, deliveryPincode string) bool {
|
||||
if len(pickupPincode) < 3 || len(deliveryPincode) < 3 {
|
||||
return false
|
||||
}
|
||||
return pickupPincode[:3] == deliveryPincode[:3]
|
||||
}
|
||||
|
||||
// IsHyperlocal decides whether a parcel can skip the base and be carried
|
||||
// straight to the receiver by the collecting rider.
|
||||
//
|
||||
// The pincode-prefix rule decides outright when both pincodes are present — a
|
||||
// matching prefix is hyperlocal, a differing one is genuinely inter-area, and
|
||||
// distance does not get a vote. Pollachi is 40km from Coimbatore, closer than
|
||||
// plenty of runs treated as local, and it is still a different postal area.
|
||||
//
|
||||
// Only when a pincode is missing or too short does the straight-line distance
|
||||
// decide instead. With neither pincodes nor coordinates the answer is false,
|
||||
// which routes via a base — the safe direction to be wrong in, because a parcel
|
||||
// that reaches a base can still be forwarded, while one handed to a rider who
|
||||
// cannot reach the receiver is stuck.
|
||||
func IsHyperlocal(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng float64) bool {
|
||||
if len(pickupPincode) >= 3 && len(deliveryPincode) >= 3 {
|
||||
return SamePostalArea(pickupPincode, deliveryPincode)
|
||||
}
|
||||
if pLat != 0 && pLng != 0 && dLat != 0 && dLng != 0 {
|
||||
return HaversineKM(pLat, pLng, dLat, dLng) <= MaxHyperlocalKM
|
||||
}
|
||||
return false
|
||||
}
|
||||
117
internal/legs/legs_test.go
Normal file
117
internal/legs/legs_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package legs
|
||||
|
||||
import "testing"
|
||||
|
||||
// The one definition of "does this parcel go via a base?".
|
||||
//
|
||||
// It lives in its own package because two callers need it and neither can
|
||||
// import the other: controllers decides it at pickup-complete, internal/routing
|
||||
// needs it to know where a rider's leg ends. A second copy in routing was the
|
||||
// alternative, and this codebase has been bitten by that shape before — a rival
|
||||
// status table that drifted until the map and the list showed the same parcel
|
||||
// two different colours.
|
||||
//
|
||||
// So the rule is tested here, once, and both callers inherit it.
|
||||
|
||||
func TestSamePostalArea(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pickup string
|
||||
delivery string
|
||||
want bool
|
||||
}{
|
||||
{"same 3-digit area is one zone", "641012", "641004", true},
|
||||
{"an identical pincode is trivially one zone", "641012", "641012", true},
|
||||
{"Coimbatore to Chennai is not", "641012", "600001", false},
|
||||
{"Coimbatore to Pollachi is not, though it is close", "641012", "642001", false},
|
||||
{"a short pincode proves nothing", "64", "641004", false},
|
||||
{"an empty pincode proves nothing", "", "641004", false},
|
||||
{"two empty pincodes do not match each other", "", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := SamePostalArea(tc.pickup, tc.delivery); got != tc.want {
|
||||
t.Errorf("SamePostalArea(%q, %q) = %v, want %v", tc.pickup, tc.delivery, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHyperlocalPrefersThePincodeRule(t *testing.T) {
|
||||
// Coimbatore pickup, Pollachi delivery: ~40 km apart, which is inside the
|
||||
// distance fallback — but they are different postal areas, and when both
|
||||
// pincodes are present the prefix rule decides outright. Distance does not
|
||||
// get a vote, or a parcel would be routed one way on Tuesday and another on
|
||||
// Wednesday because a geocoder moved a pin.
|
||||
if IsHyperlocal("641025", "642001", 11.0168, 76.9558, 10.658, 77.008) {
|
||||
t.Error("a different postal area must route via a base even when it is close")
|
||||
}
|
||||
|
||||
// The mirror: same area, far apart. A 3-digit area can be large, and the
|
||||
// prefix still decides.
|
||||
if !IsHyperlocal("641025", "641999", 11.0168, 76.9558, 11.6, 77.5) {
|
||||
t.Error("the same postal area must stay hyperlocal even when it is a long way across")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHyperlocalFallsBackToDistance(t *testing.T) {
|
||||
// Console-created bookings frequently carry accurate coordinates and no
|
||||
// delivery pincode. Routing those through a base because of a missing field
|
||||
// would send a kitchen-to-doorstep lunch order on a tour of the network.
|
||||
if !IsHyperlocal("641025", "", 11.0168, 76.9558, 11.05, 77.01) {
|
||||
t.Error("a short hop with no pincode should stay hyperlocal on the distance fallback")
|
||||
}
|
||||
if IsHyperlocal("641025", "", 11.0168, 76.9558, 13.0827, 80.2707) {
|
||||
t.Error("a 430km haul with no pincode is not hyperlocal whatever the fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHyperlocalIsFalseWhenNothingCanBeProven(t *testing.T) {
|
||||
// No usable pincodes and no usable coordinates. False routes via a base,
|
||||
// which is the safe direction to be wrong in: a parcel that reaches a base
|
||||
// can still be forwarded, while one handed to a rider who cannot reach the
|
||||
// receiver is simply stuck.
|
||||
if IsHyperlocal("", "", 0, 0, 0, 0) {
|
||||
t.Error("with nothing to decide on, the answer must be the safe one")
|
||||
}
|
||||
if IsHyperlocal("64", "60", 0, 0, 0, 0) {
|
||||
t.Error("unusable pincodes and no coordinates must not resolve to hyperlocal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHyperlocalTreatsAMissingCoordinateAsMissing(t *testing.T) {
|
||||
// 0,0 is in the Gulf of Guinea. Treating it as a real position would make
|
||||
// every un-geocoded booking look like a 7,000 km haul — or, worse, let two
|
||||
// of them look like neighbours.
|
||||
if IsHyperlocal("641025", "", 11.0168, 76.9558, 0, 0) {
|
||||
t.Error("a 0,0 delivery point is an unset value, not a location near anything")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHaversineKM(t *testing.T) {
|
||||
// A known pair: Coimbatore to Chennai is roughly 430 km great-circle.
|
||||
km := HaversineKM(11.0168, 76.9558, 13.0827, 80.2707)
|
||||
if km < 400 || km > 460 {
|
||||
t.Errorf("Coimbatore→Chennai = %.0f km, expected roughly 430", km)
|
||||
}
|
||||
|
||||
if d := HaversineKM(11.0168, 76.9558, 11.0168, 76.9558); d != 0 {
|
||||
t.Errorf("a point is %v km from itself, want 0", d)
|
||||
}
|
||||
|
||||
// Symmetric, or distance-based decisions would depend on argument order.
|
||||
a := HaversineKM(11.0168, 76.9558, 12.9716, 77.5946)
|
||||
b := HaversineKM(12.9716, 77.5946, 11.0168, 76.9558)
|
||||
if a != b {
|
||||
t.Errorf("distance is not symmetric: %v vs %v", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHyperlocalKMBoundary(t *testing.T) {
|
||||
// The fallback threshold is a real operational number, not a magic
|
||||
// constant — a rider is expected to carry a parcel this far, and not
|
||||
// further. Guarding it stops a silent widening.
|
||||
if MaxHyperlocalKM != 30.0 {
|
||||
t.Errorf("MaxHyperlocalKM = %v; changing it changes which parcels riders carry end to end", MaxHyperlocalKM)
|
||||
}
|
||||
}
|
||||
141
internal/routing/dropforleg_test.go
Normal file
141
internal/routing/dropforleg_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"doormile/internal/legs"
|
||||
)
|
||||
|
||||
// Where a rider's leg actually ends.
|
||||
//
|
||||
// The bug these tests exist to prevent, found while testing the base-handover
|
||||
// flow with a bulk sheet of intercity orders: the sequencer read
|
||||
// pickupbookings.deliverylatitude for every active assignment, with no idea
|
||||
// whether the parcel was hub-routed. For a Coimbatore → Chennai booking that
|
||||
// told the route optimizer the rider was riding 430 km to the receiver, when
|
||||
// the rider's real next stop is a base a few kilometres away.
|
||||
//
|
||||
// Wrong on its own — a 430 km "stop" is not a stop anyone makes — and worse in
|
||||
// company: one such destination in a rider's set drags the ordering of every
|
||||
// genuine local stop beside it, because the solver is optimising a journey
|
||||
// nobody is going to make.
|
||||
|
||||
// Coimbatore pickup, and the local base a rider hands parcels to.
|
||||
const (
|
||||
pickPin = "641025"
|
||||
pickLat, pickLng = 11.0168, 76.9558
|
||||
baseLat, baseLng = 11.0272, 76.9905
|
||||
localPin = "641004"
|
||||
localLat, localLng = 11.029, 76.993
|
||||
chennaiPin = "600001"
|
||||
chennaiLat, chennaiLng = 13.091, 80.285
|
||||
)
|
||||
|
||||
func TestDropForLeg(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
deliveryPin string
|
||||
dLat, dLng float64
|
||||
baseLat, baseLng float64
|
||||
wantLat, wantLng float64
|
||||
wantOK bool
|
||||
why string
|
||||
}{
|
||||
{
|
||||
name: "a hyperlocal parcel ends at the receiver",
|
||||
deliveryPin: localPin, dLat: localLat, dLng: localLng,
|
||||
baseLat: baseLat, baseLng: baseLng,
|
||||
wantLat: localLat, wantLng: localLng, wantOK: true,
|
||||
why: "same postal area — the collecting rider carries it all the way",
|
||||
},
|
||||
{
|
||||
name: "an intercity parcel ends at the base, not in Chennai",
|
||||
deliveryPin: chennaiPin, dLat: chennaiLat, dLng: chennaiLng,
|
||||
baseLat: baseLat, baseLng: baseLng,
|
||||
wantLat: baseLat, wantLng: baseLng, wantOK: true,
|
||||
why: "this is the whole fix — the rider rides to the base",
|
||||
},
|
||||
{
|
||||
name: "a nearby but different postal area still ends at the base",
|
||||
deliveryPin: "642001", dLat: 10.658, dLng: 77.008,
|
||||
baseLat: baseLat, baseLng: baseLng,
|
||||
wantLat: baseLat, wantLng: baseLng, wantOK: true,
|
||||
why: "Pollachi is 40km away; the prefix rule decides, not the distance",
|
||||
},
|
||||
{
|
||||
name: "no pincode and a short hop still ends at the receiver",
|
||||
deliveryPin: "", dLat: 11.05, dLng: 77.01,
|
||||
baseLat: baseLat, baseLng: baseLng,
|
||||
wantLat: 11.05, wantLng: 77.01, wantOK: true,
|
||||
why: "the distance fallback puts it inside 30km",
|
||||
},
|
||||
{
|
||||
name: "no pincode and a long haul ends at the base",
|
||||
deliveryPin: "", dLat: chennaiLat, dLng: chennaiLng,
|
||||
baseLat: baseLat, baseLng: baseLng,
|
||||
wantLat: baseLat, wantLng: baseLng, wantOK: true,
|
||||
why: "the distance fallback puts it far outside 30km",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
lat, lng, ok := dropForLeg(pickPin, tc.deliveryPin,
|
||||
pickLat, pickLng, tc.dLat, tc.dLng, tc.baseLat, tc.baseLng)
|
||||
if ok != tc.wantOK {
|
||||
t.Fatalf("ok = %v, want %v — %s", ok, tc.wantOK, tc.why)
|
||||
}
|
||||
if lat != tc.wantLat || lng != tc.wantLng {
|
||||
t.Errorf("drop = (%v, %v), want (%v, %v) — %s", lat, lng, tc.wantLat, tc.wantLng, tc.why)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropForLegNeverSendsARiderInterstate(t *testing.T) {
|
||||
// The regression guard, stated as the thing that actually matters rather
|
||||
// than as a coordinate comparison: whatever the destination, the drop the
|
||||
// sequencer is given must be somewhere a rider can plausibly ride to.
|
||||
for _, d := range []struct {
|
||||
name string
|
||||
pin string
|
||||
lat, lng float64
|
||||
}{
|
||||
{"Chennai", "600001", 13.091, 80.285},
|
||||
{"Hyderabad", "500081", 17.44, 78.3489},
|
||||
{"Bengaluru", "560066", 12.9698, 77.75},
|
||||
{"Madurai", "625001", 9.9195, 78.119},
|
||||
} {
|
||||
t.Run(d.name, func(t *testing.T) {
|
||||
lat, lng, ok := dropForLeg(pickPin, d.pin, pickLat, pickLng, d.lat, d.lng, baseLat, baseLng)
|
||||
if !ok {
|
||||
t.Fatal("a stop with a usable base must still be sequenced")
|
||||
}
|
||||
if lat == d.lat && lng == d.lng {
|
||||
t.Fatalf("%s was passed to the optimizer as the rider's own drop", d.name)
|
||||
}
|
||||
if km := legs.HaversineKM(pickLat, pickLng, lat, lng); km > 50 {
|
||||
t.Errorf("drop is %.0f km from the pickup — no rider is making that leg", km)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropForLegSkipsAHubRoutedStopWithNoBase(t *testing.T) {
|
||||
// Falling back to the receiver here would be the original bug wearing a
|
||||
// different hat: better to leave one stop unsequenced than to skew the
|
||||
// ordering of every other stop the rider is carrying.
|
||||
_, _, ok := dropForLeg(pickPin, chennaiPin, pickLat, pickLng, chennaiLat, chennaiLng, 0, 0)
|
||||
if ok {
|
||||
t.Error("a hub-routed stop with no base coordinates must be left out, not pointed at the receiver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropForLegKeepsAHyperlocalStopWithNoBase(t *testing.T) {
|
||||
// A hyperlocal parcel never needed a base, so a missing one is irrelevant
|
||||
// to it and must not cost the rider a stop.
|
||||
lat, lng, ok := dropForLeg(pickPin, localPin, pickLat, pickLng, localLat, localLng, 0, 0)
|
||||
if !ok || lat != localLat || lng != localLng {
|
||||
t.Errorf("hyperlocal stop = (%v, %v, %v), want the receiver and ok", lat, lng, ok)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/internal/legs"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
)
|
||||
@@ -183,13 +184,31 @@ func loadActiveStops(milerUserID int) ([]stop, error) {
|
||||
Pickuplongitude float64
|
||||
Deliverylatitude float64
|
||||
Deliverylongitude float64
|
||||
Pickuppincode string
|
||||
Deliverypincode string
|
||||
// The base this rider would hand a hub-routed parcel to: the one the
|
||||
// booking was routed to if anything set it, otherwise the rider's own.
|
||||
// Same order of preference as controllers.resolveHandoverHub.
|
||||
Baselatitude float64
|
||||
Baselongitude float64
|
||||
}
|
||||
|
||||
// A rider's leg does NOT always end at the address on the booking. For an
|
||||
// intercity parcel the rider carries it to a base and hands it over there;
|
||||
// the receiver is somebody else's problem, on another vehicle, days later.
|
||||
// The base coordinates are joined in here so the sequencer can use them as
|
||||
// the real end of the leg — see the substitution below.
|
||||
if err := db.DB.Table("bookingassignments AS ba").
|
||||
Select(`ba.bookingassignmentid, ba.bookingid, b.bookingno,
|
||||
b.pickuplatitude, b.pickuplongitude,
|
||||
b.deliverylatitude, b.deliverylongitude`).
|
||||
b.deliverylatitude, b.deliverylongitude,
|
||||
b.pickuppincode, b.deliverypincode,
|
||||
COALESCE(bh.latitude, rh.latitude, 0) AS baselatitude,
|
||||
COALESCE(bh.longitude, rh.longitude, 0) AS baselongitude`).
|
||||
Joins("JOIN pickupbookings AS b ON b.bookingid = ba.bookingid").
|
||||
Joins("LEFT JOIN hubs AS bh ON bh.hubid = b.nearesthubid AND bh.deletedat IS NULL").
|
||||
Joins("LEFT JOIN milerprofiles AS mp ON mp.userid = ba.mileruserid").
|
||||
Joins("LEFT JOIN hubs AS rh ON rh.hubid = mp.hubid AND rh.deletedat IS NULL").
|
||||
Where("ba.mileruserid = ? AND ba.assignmentstatus IN ?",
|
||||
milerUserID,
|
||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||
@@ -209,19 +228,60 @@ func loadActiveStops(milerUserID int) ([]stop, error) {
|
||||
"assignment_id", r.Bookingassignmentid, "booking_id", r.Bookingid)
|
||||
continue
|
||||
}
|
||||
dropLat, dropLng, ok := dropForLeg(
|
||||
r.Pickuppincode, r.Deliverypincode,
|
||||
r.Pickuplatitude, r.Pickuplongitude,
|
||||
r.Deliverylatitude, r.Deliverylongitude,
|
||||
r.Baselatitude, r.Baselongitude)
|
||||
if !ok {
|
||||
// Routed to a base, but no base has usable coordinates. Ordering it
|
||||
// against the far-away receiver would distort every other stop, so it
|
||||
// is left out of the route rather than allowed to skew it.
|
||||
utils.Warn("routing: hub-routed stop has no base coordinates, leaving it unsequenced",
|
||||
"assignment_id", r.Bookingassignmentid, "booking_id", r.Bookingid)
|
||||
continue
|
||||
}
|
||||
|
||||
stops = append(stops, stop{
|
||||
AssignmentID: r.Bookingassignmentid,
|
||||
BookingID: r.Bookingid,
|
||||
BookingNo: r.Bookingno,
|
||||
PickupLat: r.Pickuplatitude,
|
||||
PickupLng: r.Pickuplongitude,
|
||||
DeliveryLat: r.Deliverylatitude,
|
||||
DeliveryLng: r.Deliverylongitude,
|
||||
DeliveryLat: dropLat,
|
||||
DeliveryLng: dropLng,
|
||||
})
|
||||
}
|
||||
return stops, nil
|
||||
}
|
||||
|
||||
// dropForLeg is where THIS rider's leg ends — which is not always the address
|
||||
// on the booking.
|
||||
//
|
||||
// A hyperlocal parcel ends at the receiver. A hub-routed one ends at a base: the
|
||||
// rider hands it over there and the receiver is somebody else's leg, on another
|
||||
// vehicle, possibly days later. Sequencing a hub-routed parcel against the
|
||||
// receiver's coordinates asks the optimizer to plan a ride to another state —
|
||||
// wrong on its own terms, since a 430km "stop" is not a stop a rider makes, and
|
||||
// worse in company: one intercity destination in the set drags the ordering of
|
||||
// every genuine local stop beside it, because the solver is optimising a journey
|
||||
// nobody is going to make.
|
||||
//
|
||||
// The final destination is not lost. It is simply not this leg.
|
||||
//
|
||||
// ok is false when the parcel is hub-routed and no base has usable coordinates.
|
||||
// The caller drops the stop rather than falling back to the receiver, because a
|
||||
// stop in the wrong country is more damaging to the route than a missing one.
|
||||
func dropForLeg(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng, baseLat, baseLng float64) (lat, lng float64, ok bool) {
|
||||
if legs.IsHyperlocal(pickupPincode, deliveryPincode, pLat, pLng, dLat, dLng) {
|
||||
return dLat, dLng, true
|
||||
}
|
||||
if baseLat != 0 || baseLng != 0 {
|
||||
return baseLat, baseLng, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// optimize calls the Route Optimization API and maps its answer back onto our
|
||||
// assignment ids.
|
||||
func optimize(stops []stop) ([]Result, error) {
|
||||
|
||||
191
internal/sms/http_sender.go
Normal file
191
internal/sms/http_sender.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/utils"
|
||||
)
|
||||
|
||||
// A provider-agnostic HTTP gateway, and the wiring that installs it.
|
||||
//
|
||||
// This package called itself "the seam, not the integration", with a logging
|
||||
// sink standing in until a gateway was plugged in. The sink was never replaced:
|
||||
// sms.Register had no callers anywhere in the tree, so every customer
|
||||
// verification code since this surface shipped has gone to the application log
|
||||
// and nowhere else. Two consequences, both live in production:
|
||||
//
|
||||
// 1. No customer can complete sign-in without somebody reading the server log
|
||||
// to them. That is the single blocker on the customer app, and it is why
|
||||
// the app's offline dev mode became the only practical way in — which in
|
||||
// turn is why bookings "made" in it never reached the admin console.
|
||||
// 2. Every OTP ever issued is sitting in log storage as plaintext. A
|
||||
// credential in a log file is a credential in the wrong place.
|
||||
//
|
||||
// Rather than hard-coding one vendor, this posts to whatever gateway the
|
||||
// deployment names. The Indian providers this would plausibly use — MSG91,
|
||||
// Gupshup, Textlocal, Fast2SMS — all accept an authenticated POST carrying a
|
||||
// destination and a body, so one templated request covers them and swapping
|
||||
// vendors is configuration rather than a release.
|
||||
//
|
||||
// Configuration, all read once at startup. An absent SMS_GATEWAY_URL leaves the
|
||||
// log sink exactly where it is, so this change cannot break a deployment that
|
||||
// has not been configured yet:
|
||||
//
|
||||
// SMS_GATEWAY_URL endpoint to POST to; absent means "stay on the log sink"
|
||||
// SMS_GATEWAY_METHOD HTTP method, default POST
|
||||
// SMS_GATEWAY_AUTH Authorization header value, for vendors that use one
|
||||
// SMS_GATEWAY_HEADER one extra "Name: value" header, for vendors with their own key header
|
||||
// SMS_GATEWAY_BODY body template; {{phone}}, {{message}} and {{sender}} are substituted
|
||||
// SMS_GATEWAY_TYPE content type, default application/json
|
||||
// SMS_SENDER_ID the registered sender id, substituted as {{sender}}
|
||||
|
||||
const gatewayTimeout = 10 * time.Second
|
||||
|
||||
type httpSender struct {
|
||||
url string
|
||||
method string
|
||||
auth string
|
||||
headerName string
|
||||
headerValue string
|
||||
bodyTmpl string
|
||||
contentType string
|
||||
senderID string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (httpSender) Name() string { return "http-gateway" }
|
||||
|
||||
func (s httpSender) Send(phone, message string) error {
|
||||
body := s.bodyTmpl
|
||||
body = strings.ReplaceAll(body, "{{phone}}", phone)
|
||||
body = strings.ReplaceAll(body, "{{message}}", jsonEscape(message))
|
||||
body = strings.ReplaceAll(body, "{{sender}}", s.senderID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), gatewayTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, s.method, s.url, bytes.NewReader([]byte(body)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("sms: build gateway request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", s.contentType)
|
||||
if s.auth != "" {
|
||||
req.Header.Set("Authorization", s.auth)
|
||||
}
|
||||
if s.headerName != "" {
|
||||
req.Header.Set(s.headerName, s.headerValue)
|
||||
}
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sms: gateway unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read a bounded slice of the response for the log. The gateway's reason
|
||||
// for refusing — "insufficient balance", "DLT template not approved" — is
|
||||
// the entire diagnosis, and it only ever appears in the body.
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// The number is masked and the message is NOT logged: the message
|
||||
// contains the code, which is the thing this whole file exists to keep
|
||||
// out of the log.
|
||||
utils.Error("sms: gateway rejected the send",
|
||||
"status", resp.StatusCode,
|
||||
"phone", maskPhone(phone),
|
||||
"response", strings.TrimSpace(string(snippet)))
|
||||
return fmt.Errorf("sms: gateway returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
utils.Info("sms: code delivered", "phone", maskPhone(phone))
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsonEscape makes a message safe to interpolate into a JSON body template.
|
||||
// The OTP text carries no quotes today, but a template is a template and the
|
||||
// next message to go through here will not be this one.
|
||||
func jsonEscape(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Configure installs a real gateway when one is configured, and says plainly
|
||||
// which transport the process ended up with.
|
||||
//
|
||||
// Called once from main() after config load. Deliberately loud in both
|
||||
// directions: a deployment that believes it can send texts and cannot is the
|
||||
// exact failure that has been live in this service since the customer surface
|
||||
// shipped, so it must not be possible to start without the answer appearing in
|
||||
// the boot log.
|
||||
func Configure() {
|
||||
url := strings.TrimSpace(os.Getenv("SMS_GATEWAY_URL"))
|
||||
if url == "" {
|
||||
utils.Warn("SMS: no gateway configured (SMS_GATEWAY_URL is unset). " +
|
||||
"Verification codes are written to THIS LOG and no text is sent. " +
|
||||
"Customer sign-in cannot complete unless somebody reads the code out " +
|
||||
"of here, or CX_STAGING_OTP is set on a non-production deployment.")
|
||||
return
|
||||
}
|
||||
|
||||
method := strings.ToUpper(strings.TrimSpace(os.Getenv("SMS_GATEWAY_METHOD")))
|
||||
if method == "" {
|
||||
method = http.MethodPost
|
||||
}
|
||||
|
||||
bodyTmpl := os.Getenv("SMS_GATEWAY_BODY")
|
||||
if strings.TrimSpace(bodyTmpl) == "" {
|
||||
bodyTmpl = `{"to":"{{phone}}","message":"{{message}}","sender":"{{sender}}"}`
|
||||
}
|
||||
|
||||
contentType := strings.TrimSpace(os.Getenv("SMS_GATEWAY_TYPE"))
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
var headerName, headerValue string
|
||||
if raw := strings.TrimSpace(os.Getenv("SMS_GATEWAY_HEADER")); raw != "" {
|
||||
if name, value, ok := strings.Cut(raw, ":"); ok {
|
||||
headerName = strings.TrimSpace(name)
|
||||
headerValue = strings.TrimSpace(value)
|
||||
} else {
|
||||
utils.Warn("SMS: SMS_GATEWAY_HEADER is not in 'Name: value' form and was ignored",
|
||||
"value", raw)
|
||||
}
|
||||
}
|
||||
|
||||
Register(httpSender{
|
||||
url: url,
|
||||
method: method,
|
||||
auth: strings.TrimSpace(os.Getenv("SMS_GATEWAY_AUTH")),
|
||||
headerName: headerName,
|
||||
headerValue: headerValue,
|
||||
bodyTmpl: bodyTmpl,
|
||||
contentType: contentType,
|
||||
senderID: strings.TrimSpace(os.Getenv("SMS_SENDER_ID")),
|
||||
client: &http.Client{Timeout: gatewayTimeout},
|
||||
})
|
||||
}
|
||||
186
internal/sms/http_sender_test.go
Normal file
186
internal/sms/http_sender_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The gateway that closes the sign-in blocker.
|
||||
//
|
||||
// sms.Register had no callers anywhere in the tree, so logSender was never
|
||||
// replaced and every customer verification code went to the application log
|
||||
// instead of to a phone. That is why nobody could sign in, why the app's
|
||||
// offline dev mode became the only practical way in, and why bookings "made"
|
||||
// in that mode never reached the admin console.
|
||||
|
||||
func restoreSender(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := active
|
||||
t.Cleanup(func() { active = previous })
|
||||
}
|
||||
|
||||
func testSender(url, bodyTmpl string) httpSender {
|
||||
if bodyTmpl == "" {
|
||||
bodyTmpl = `{"to":"{{phone}}","message":"{{message}}","sender":"{{sender}}"}`
|
||||
}
|
||||
return httpSender{
|
||||
url: url,
|
||||
method: http.MethodPost,
|
||||
bodyTmpl: bodyTmpl,
|
||||
contentType: "application/json",
|
||||
senderID: "DRMILE",
|
||||
client: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// The destination and the code have to actually reach the gateway.
|
||||
func TestGatewaySendsPhoneAndCode(t *testing.T) {
|
||||
var got string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
got = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
restoreSender(t)
|
||||
Register(testSender(srv.URL, ""))
|
||||
|
||||
if err := SendOTP("+919876543210", "4821"); err != nil {
|
||||
t.Fatalf("SendOTP: %v", err)
|
||||
}
|
||||
for _, want := range []string{"+919876543210", "4821", "DRMILE"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("gateway body %q is missing %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A refused send — no balance, unapproved DLT template — must surface as an
|
||||
// error. Swallowing it tells the customer a code is on its way when it is not,
|
||||
// which is precisely the failure logSender has been producing all along.
|
||||
func TestGatewayRefusalIsReported(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusPaymentRequired)
|
||||
_, _ = w.Write([]byte(`{"error":"insufficient balance"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
restoreSender(t)
|
||||
Register(testSender(srv.URL, ""))
|
||||
|
||||
if err := SendOTP("+919876543210", "4821"); err == nil {
|
||||
t.Error("a rejected send reported success — the customer would wait for a " +
|
||||
"text that is never coming")
|
||||
}
|
||||
}
|
||||
|
||||
// An unreachable gateway is an error, not a silent no-op.
|
||||
func TestUnreachableGatewayIsReported(t *testing.T) {
|
||||
restoreSender(t)
|
||||
Register(testSender("http://127.0.0.1:1/unreachable", ""))
|
||||
|
||||
if err := SendOTP("+919876543210", "4821"); err == nil {
|
||||
t.Error("an unreachable gateway reported success")
|
||||
}
|
||||
}
|
||||
|
||||
// A quote in the message must not break a JSON body template.
|
||||
func TestMessageIsEscapedForJSON(t *testing.T) {
|
||||
var got string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
got = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
restoreSender(t)
|
||||
Register(testSender(srv.URL, ""))
|
||||
|
||||
if err := active.Send("+919876543210", `say "hello"`); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if strings.Contains(got, `say "hello"`) {
|
||||
t.Errorf("an unescaped quote reached the JSON body: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `say \"hello\"`) {
|
||||
t.Errorf("the message was not escaped as expected: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Vendors differ; the template is what makes one sender cover all of them.
|
||||
func TestBodyTemplateIsVendorAgnostic(t *testing.T) {
|
||||
var got string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
got = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
restoreSender(t)
|
||||
Register(testSender(srv.URL, "mobiles={{phone}}&message={{message}}&sender={{sender}}"))
|
||||
|
||||
if err := SendOTP("+919876543210", "4821"); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(got, "mobiles=+919876543210&message=") {
|
||||
t.Errorf("form-encoded template not honoured: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Configure is what gives sms.Register its first caller. With no URL it must
|
||||
// leave the log sink exactly where it is, so this change cannot break a
|
||||
// deployment that has not been configured yet.
|
||||
func TestConfigureLeavesTheLogSinkWhenUnset(t *testing.T) {
|
||||
restoreSender(t)
|
||||
active = logSender{}
|
||||
setEnv(t, "SMS_GATEWAY_URL", "")
|
||||
|
||||
Configure()
|
||||
|
||||
if Configured() {
|
||||
t.Error("Configure installed a gateway with no SMS_GATEWAY_URL set")
|
||||
}
|
||||
if Transport() != "log" {
|
||||
t.Errorf("Transport() = %q, want \"log\"", Transport())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureInstallsTheGatewayWhenSet(t *testing.T) {
|
||||
restoreSender(t)
|
||||
active = logSender{}
|
||||
setEnv(t, "SMS_GATEWAY_URL", "https://sms.example.invalid/send")
|
||||
|
||||
Configure()
|
||||
|
||||
if !Configured() {
|
||||
t.Fatal("Configure did not install a gateway despite SMS_GATEWAY_URL being set")
|
||||
}
|
||||
if Transport() != "http-gateway" {
|
||||
t.Errorf("Transport() = %q, want \"http-gateway\"", Transport())
|
||||
}
|
||||
}
|
||||
|
||||
// In production the log sink must refuse rather than write a live credential
|
||||
// to the log and report success.
|
||||
func TestLogSenderRefusesInProduction(t *testing.T) {
|
||||
restoreSender(t)
|
||||
active = logSender{}
|
||||
|
||||
setEnv(t, "ENV", "production")
|
||||
if err := SendOTP("+919876543210", "4821"); err == nil {
|
||||
t.Error("with no gateway in production, SendOTP reported success — the code " +
|
||||
"went to the log and the customer was told it was sent")
|
||||
}
|
||||
|
||||
setEnv(t, "ENV", "development")
|
||||
if err := SendOTP("+919876543210", "4821"); err != nil {
|
||||
t.Errorf("outside production the log sink must still work for QA: %v", err)
|
||||
}
|
||||
}
|
||||
116
internal/sms/sms.go
Normal file
116
internal/sms/sms.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Package sms delivers one-time codes to a phone number.
|
||||
//
|
||||
// There is no SMS provider wired into this backend yet — the miler app
|
||||
// authenticates on a PIN and the console on a password, so nothing has ever
|
||||
// needed to send a text. The customer app's only credential is a code sent to a
|
||||
// phone, which makes this the one piece of the auth flow that cannot be
|
||||
// finished from inside this repository.
|
||||
//
|
||||
// So this package is the seam, not the integration: a small interface, a
|
||||
// logging sink that lets the whole flow be exercised end to end without a
|
||||
// provider, and a fixed-code mode for staging. Plugging in a real gateway
|
||||
// (MSG91, Gupshup, Twilio) means adding one Sender and selecting it here —
|
||||
// nothing above this package changes.
|
||||
package sms
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"doormile/utils"
|
||||
)
|
||||
|
||||
// Sender delivers a message to an E.164 phone number.
|
||||
type Sender interface {
|
||||
Send(phone, message string) error
|
||||
// Name identifies the transport in logs and in the readiness probe, so
|
||||
// "OTP not arriving" can be answered without reading code.
|
||||
Name() string
|
||||
}
|
||||
|
||||
// logSender writes the code to the application log instead of sending it.
|
||||
//
|
||||
// This is what runs until a gateway is configured. It is deliberately loud and
|
||||
// deliberately marked: an OTP in a log file is a credential in a log file, and
|
||||
// nobody should be able to reach production with this active and not know.
|
||||
type logSender struct{}
|
||||
|
||||
func (logSender) Name() string { return "log" }
|
||||
|
||||
func (logSender) Send(phone, message string) error {
|
||||
// In production this is a failure, not a fallback. A code that only
|
||||
// reaches the application log has not been delivered, and returning nil
|
||||
// reports a send that did not happen: the customer waits for a text that
|
||||
// is never coming, and the endpoint cheerfully answers sent:true. An
|
||||
// error at least surfaces as a clear failure on the sign-in screen.
|
||||
if strings.EqualFold(strings.TrimSpace(os.Getenv("ENV")), "production") {
|
||||
utils.Error("SMS NOT CONFIGURED in production — refusing to write a live "+
|
||||
"verification code to the log. Set SMS_GATEWAY_URL.", "phone", maskPhone(phone))
|
||||
return fmt.Errorf("sms: no gateway configured")
|
||||
}
|
||||
|
||||
utils.Warn("SMS NOT CONFIGURED — code written to the log instead of being sent",
|
||||
"phone", maskPhone(phone), "message", message)
|
||||
return nil
|
||||
}
|
||||
|
||||
var active Sender = logSender{}
|
||||
|
||||
// Register installs the real gateway. Call it from main() once a provider is
|
||||
// configured; until then the log sink stays in place.
|
||||
func Register(s Sender) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
active = s
|
||||
utils.Info("SMS sender registered", "transport", s.Name())
|
||||
}
|
||||
|
||||
// Transport reports which sender is active, for the readiness probe.
|
||||
func Transport() string { return active.Name() }
|
||||
|
||||
// Configured reports whether a real gateway is in place. False means codes are
|
||||
// only reaching the log.
|
||||
func Configured() bool { return active.Name() != "log" }
|
||||
|
||||
// SendOTP delivers a login code.
|
||||
func SendOTP(phone, code string) error {
|
||||
if strings.TrimSpace(phone) == "" {
|
||||
return fmt.Errorf("sms: empty phone number")
|
||||
}
|
||||
msg := fmt.Sprintf("%s is your Doormile verification code. It expires in 5 minutes. Do not share it with anyone.", code)
|
||||
return active.Send(phone, msg)
|
||||
}
|
||||
|
||||
// maskPhone keeps the country code and the last two digits so a log line can be
|
||||
// matched to a support call without recording the number itself.
|
||||
func maskPhone(phone string) string {
|
||||
if len(phone) < 5 {
|
||||
return "***"
|
||||
}
|
||||
return phone[:3] + strings.Repeat("*", len(phone)-5) + phone[len(phone)-2:]
|
||||
}
|
||||
|
||||
// StagingCode returns the fixed verification code for non-production
|
||||
// environments, or "" when none is set.
|
||||
//
|
||||
// Automated tests and design QA cannot receive a real text, and the previous
|
||||
// end-to-end attempt on this system stalled for exactly that reason: customer
|
||||
// login needed an OTP on a real handset and could not be scripted. CX_STAGING_OTP
|
||||
// closes that.
|
||||
//
|
||||
// It is refused outright when ENV is production, because a fixed code is a
|
||||
// permanent skeleton key for every account on the platform.
|
||||
func StagingCode() string {
|
||||
code := strings.TrimSpace(os.Getenv("CX_STAGING_OTP"))
|
||||
if code == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(os.Getenv("ENV")), "production") {
|
||||
utils.Error("CX_STAGING_OTP is set in a production environment and has been ignored — " +
|
||||
"a fixed verification code would accept a login for every account on the platform")
|
||||
return ""
|
||||
}
|
||||
return code
|
||||
}
|
||||
140
internal/sms/sms_test.go
Normal file
140
internal/sms/sms_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// StagingCode is a permanent skeleton key for every account on the platform if
|
||||
// it ever reaches production. The guard against that is the only thing standing
|
||||
// between a convenience for QA and a total auth bypass, so it is tested rather
|
||||
// than trusted.
|
||||
|
||||
func setEnv(t *testing.T, key, value string) {
|
||||
t.Helper()
|
||||
previous, had := os.LookupEnv(key)
|
||||
if value == "" {
|
||||
_ = os.Unsetenv(key)
|
||||
} else {
|
||||
_ = os.Setenv(key, value)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if had {
|
||||
_ = os.Setenv(key, previous)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A fixed OTP is refused in production however the environment is spelt.
|
||||
func TestStagingCodeIsRefusedInProduction(t *testing.T) {
|
||||
for _, env := range []string{"production", "PRODUCTION", "Production", " production "} {
|
||||
setEnv(t, "CX_STAGING_OTP", "1234")
|
||||
setEnv(t, "ENV", env)
|
||||
|
||||
if got := StagingCode(); got != "" {
|
||||
t.Errorf("ENV=%q returned the fixed code %q — that is a skeleton key "+
|
||||
"for every account on the platform", env, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And is available everywhere else, which is what unblocks automated sign-in.
|
||||
func TestStagingCodeIsAvailableOutsideProduction(t *testing.T) {
|
||||
for _, env := range []string{"development", "staging", ""} {
|
||||
setEnv(t, "CX_STAGING_OTP", "1234")
|
||||
setEnv(t, "ENV", env)
|
||||
|
||||
if got := StagingCode(); got != "1234" {
|
||||
t.Errorf("ENV=%q returned %q, want the configured staging code", env, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unset means unset — no accidental default.
|
||||
func TestStagingCodeIsEmptyWhenNotConfigured(t *testing.T) {
|
||||
setEnv(t, "ENV", "development")
|
||||
setEnv(t, "CX_STAGING_OTP", "")
|
||||
|
||||
if got := StagingCode(); got != "" {
|
||||
t.Errorf("StagingCode() = %q with nothing configured, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Until a gateway is registered, Configured() must report false. Shipping while
|
||||
// this quietly said true would mean nobody noticed OTP codes were only reaching
|
||||
// the application log.
|
||||
func TestTransportReportsThatNoGatewayIsWired(t *testing.T) {
|
||||
if Configured() {
|
||||
t.Error("Configured() = true with no gateway registered — " +
|
||||
"the log sink must never claim to be a real transport")
|
||||
}
|
||||
if Transport() != "log" {
|
||||
t.Errorf("Transport() = %q, want \"log\"", Transport())
|
||||
}
|
||||
}
|
||||
|
||||
// Registering a gateway flips both, and Register(nil) is ignored rather than
|
||||
// silently disabling delivery.
|
||||
func TestRegisterInstallsAGatewayAndIgnoresNil(t *testing.T) {
|
||||
original := active
|
||||
t.Cleanup(func() { active = original })
|
||||
|
||||
Register(nil)
|
||||
if Transport() != "log" {
|
||||
t.Errorf("Register(nil) changed the transport to %q", Transport())
|
||||
}
|
||||
|
||||
fake := &recordingSender{}
|
||||
Register(fake)
|
||||
if !Configured() || Transport() != "test" {
|
||||
t.Fatalf("after Register: configured=%v transport=%q", Configured(), Transport())
|
||||
}
|
||||
|
||||
if err := SendOTP("+919876543210", "4821"); err != nil {
|
||||
t.Fatalf("SendOTP: %v", err)
|
||||
}
|
||||
if fake.phone != "+919876543210" {
|
||||
t.Errorf("phone = %q, want the number passed in", fake.phone)
|
||||
}
|
||||
if !strings.Contains(fake.message, "4821") {
|
||||
t.Errorf("message %q does not carry the code", fake.message)
|
||||
}
|
||||
if !strings.Contains(fake.message, "Do not share") {
|
||||
t.Errorf("message %q is missing the do-not-share warning", fake.message)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty number is refused rather than handed to a gateway that will bill for
|
||||
// it and fail.
|
||||
func TestSendOTPRefusesAnEmptyNumber(t *testing.T) {
|
||||
if err := SendOTP(" ", "4821"); err == nil {
|
||||
t.Error("SendOTP accepted an empty phone number")
|
||||
}
|
||||
}
|
||||
|
||||
// The log sink masks the number. An OTP in a log file is already bad enough
|
||||
// without the number it belongs to sitting beside it.
|
||||
func TestMaskPhoneHidesTheSubscriberDigits(t *testing.T) {
|
||||
got := maskPhone("+919876543210")
|
||||
if strings.Contains(got, "9876543") {
|
||||
t.Errorf("maskPhone = %q, still exposes the subscriber digits", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "10") {
|
||||
t.Errorf("maskPhone = %q, should keep the last two digits so a support "+
|
||||
"call can be matched", got)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingSender struct {
|
||||
phone string
|
||||
message string
|
||||
}
|
||||
|
||||
func (r *recordingSender) Name() string { return "test" }
|
||||
func (r *recordingSender) Send(phone, message string) error {
|
||||
r.phone, r.message = phone, message
|
||||
return nil
|
||||
}
|
||||
@@ -223,3 +223,77 @@ func awsEncode(s string, encodeSlash bool) string {
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// PresignGet issues a short-lived, signed GET URL for one object.
|
||||
//
|
||||
// Parcel photographs are shown to the customer on the receipt, and a parcel
|
||||
// photograph frames the inside of someone's doorway. A permanent CDN link to
|
||||
// one is a permanent link anybody who ever saw it can keep, so the customer
|
||||
// surface serves these through a signature that expires instead.
|
||||
//
|
||||
// Falls back to the plain CDN URL when the bucket credentials are not
|
||||
// configured: an unsigned photo the customer can see beats a receipt with a
|
||||
// missing image, and the objects are currently written public-read anyway.
|
||||
// Once parcel photos are switched to a private ACL this becomes the only way
|
||||
// to read one — which is the point of routing them through here now.
|
||||
func PresignGet(objectKey string, expiry time.Duration) (string, error) {
|
||||
cfg := loadSpacesConfig()
|
||||
if cfg.accessKey == "" || cfg.secretKey == "" || cfg.bucket == "" {
|
||||
if cfg.cdnBase != "" {
|
||||
return cfg.cdnBase + "/" + objectKey, nil
|
||||
}
|
||||
return "", fmt.Errorf("object storage not configured")
|
||||
}
|
||||
|
||||
const (
|
||||
service = "s3"
|
||||
algorithm = "AWS4-HMAC-SHA256"
|
||||
)
|
||||
|
||||
host := cfg.bucket + "." + cfg.endpoint
|
||||
|
||||
now := time.Now().UTC()
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
dateStamp := now.Format("20060102")
|
||||
expSecs := int(expiry.Seconds())
|
||||
if expSecs <= 0 {
|
||||
expSecs = 900
|
||||
}
|
||||
|
||||
canonicalURI := "/" + encodePath(objectKey)
|
||||
credentialScope := dateStamp + "/" + cfg.region + "/" + service + "/aws4_request"
|
||||
credential := cfg.accessKey + "/" + credentialScope
|
||||
signedHeaders := "host"
|
||||
|
||||
q := [][2]string{
|
||||
{"X-Amz-Algorithm", algorithm},
|
||||
{"X-Amz-Credential", credential},
|
||||
{"X-Amz-Date", amzDate},
|
||||
{"X-Amz-Expires", fmt.Sprintf("%d", expSecs)},
|
||||
{"X-Amz-SignedHeaders", signedHeaders},
|
||||
}
|
||||
canonicalQuery := canonicalizeQuery(q)
|
||||
canonicalHeaders := "host:" + host + "\n"
|
||||
|
||||
canonicalRequest := strings.Join([]string{
|
||||
"GET",
|
||||
canonicalURI,
|
||||
canonicalQuery,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
"UNSIGNED-PAYLOAD",
|
||||
}, "\n")
|
||||
|
||||
stringToSign := strings.Join([]string{
|
||||
algorithm,
|
||||
amzDate,
|
||||
credentialScope,
|
||||
hexSHA256(canonicalRequest),
|
||||
}, "\n")
|
||||
|
||||
signingKey := deriveSigningKey(cfg.secretKey, dateStamp, cfg.region, service)
|
||||
signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign))
|
||||
|
||||
return "https://" + host + canonicalURI + "?" + canonicalQuery +
|
||||
"&X-Amz-Signature=" + signature, nil
|
||||
}
|
||||
|
||||
182
internal/storage/spaces_test.go
Normal file
182
internal/storage/spaces_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PresignGet is what serves a customer their parcel photographs. A parcel photo
|
||||
// frames the inside of someone's doorway, so the properties tested here are
|
||||
// privacy properties, not formatting ones: the link must expire, and it must
|
||||
// never carry the bucket's secret key.
|
||||
|
||||
func withSpaces(t *testing.T, access, secret string) {
|
||||
t.Helper()
|
||||
for _, kv := range [][2]string{
|
||||
{"DO_SPACES_ACCESS_KEY", access},
|
||||
{"DO_SPACES_SECRET_KEY", secret},
|
||||
{"DO_SPACES_REGION", "sgp1"},
|
||||
{"DO_SPACES_ENDPOINT", "sgp1.digitaloceanspaces.com"},
|
||||
{"DO_SPACES_BUCKET", "nearle"},
|
||||
{"DO_SPACES_CDN_BASE", "https://images.nearle.app"},
|
||||
} {
|
||||
key, value := kv[0], kv[1]
|
||||
previous, had := os.LookupEnv(key)
|
||||
if value == "" {
|
||||
_ = os.Unsetenv(key)
|
||||
} else {
|
||||
_ = os.Setenv(key, value)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if had {
|
||||
_ = os.Setenv(key, previous)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The signed URL must never contain the secret key. A leaked secret is the
|
||||
// whole bucket, not one photo — and this is exactly the mistake the legacy
|
||||
// rider app made by shipping the key inside the binary.
|
||||
func TestPresignGetNeverLeaksTheSecretKey(t *testing.T) {
|
||||
const secret = "s3cr3t-do-not-emit-this-anywhere"
|
||||
withSpaces(t, "AKIAEXAMPLE", secret)
|
||||
|
||||
signed, err := PresignGet("pv/booking-70/parcel-1.jpg", 30*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("PresignGet: %v", err)
|
||||
}
|
||||
if strings.Contains(signed, secret) {
|
||||
t.Fatal("the signed URL contains the secret key")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(signed), "secret") {
|
||||
t.Errorf("suspicious content in the signed URL: %s", signed)
|
||||
}
|
||||
// The ACCESS key is expected — it identifies the caller, it is not a
|
||||
// credential on its own.
|
||||
if !strings.Contains(signed, "AKIAEXAMPLE") {
|
||||
t.Error("the signed URL carries no credential scope, so it cannot authenticate")
|
||||
}
|
||||
}
|
||||
|
||||
// A link that does not expire is a permanent link, which defeats the point of
|
||||
// signing it at all.
|
||||
func TestPresignGetCarriesAnExpiry(t *testing.T) {
|
||||
withSpaces(t, "AKIAEXAMPLE", "shhh")
|
||||
|
||||
signed, err := PresignGet("pv/abc.jpg", 30*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("PresignGet: %v", err)
|
||||
}
|
||||
parsed, err := url.Parse(signed)
|
||||
if err != nil {
|
||||
t.Fatalf("the signed URL does not parse: %v", err)
|
||||
}
|
||||
q := parsed.Query()
|
||||
|
||||
if got := q.Get("X-Amz-Expires"); got != "1800" {
|
||||
t.Errorf("X-Amz-Expires = %q, want 1800 (30 minutes)", got)
|
||||
}
|
||||
for _, param := range []string{"X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-SignedHeaders", "X-Amz-Signature"} {
|
||||
if q.Get(param) == "" {
|
||||
t.Errorf("missing %s — the URL would be rejected by the object store", param)
|
||||
}
|
||||
}
|
||||
if q.Get("X-Amz-Algorithm") != "AWS4-HMAC-SHA256" {
|
||||
t.Errorf("unexpected algorithm %q", q.Get("X-Amz-Algorithm"))
|
||||
}
|
||||
}
|
||||
|
||||
// A non-positive expiry must fall back to a real one rather than minting a link
|
||||
// that is already dead, or worse, one the store treats as unbounded.
|
||||
func TestPresignGetDefaultsAZeroExpiry(t *testing.T) {
|
||||
withSpaces(t, "AKIAEXAMPLE", "shhh")
|
||||
|
||||
signed, err := PresignGet("pv/abc.jpg", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("PresignGet: %v", err)
|
||||
}
|
||||
parsed, _ := url.Parse(signed)
|
||||
if got := parsed.Query().Get("X-Amz-Expires"); got != "900" {
|
||||
t.Errorf("X-Amz-Expires = %q on a zero expiry, want the 900s default", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Two different objects must produce different signatures. A signature that
|
||||
// does not cover the key would let one link fetch any file in the bucket.
|
||||
func TestPresignGetSignatureCoversTheObjectKey(t *testing.T) {
|
||||
withSpaces(t, "AKIAEXAMPLE", "shhh")
|
||||
|
||||
a, err := PresignGet("pv/booking-70/parcel-1.jpg", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("PresignGet: %v", err)
|
||||
}
|
||||
b, err := PresignGet("pv/booking-99/parcel-4.jpg", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("PresignGet: %v", err)
|
||||
}
|
||||
|
||||
sigA, _ := url.Parse(a)
|
||||
sigB, _ := url.Parse(b)
|
||||
if sigA.Query().Get("X-Amz-Signature") == sigB.Query().Get("X-Amz-Signature") {
|
||||
t.Fatal("two different objects produced the same signature — the key is not signed")
|
||||
}
|
||||
if !strings.Contains(a, "booking-70") || !strings.Contains(b, "booking-99") {
|
||||
t.Error("the object key is missing from the URL path")
|
||||
}
|
||||
}
|
||||
|
||||
// With no credentials configured it degrades to the plain CDN link rather than
|
||||
// failing. An unsigned photo the customer can see beats a receipt with a broken
|
||||
// image — and the objects are currently written public-read anyway.
|
||||
func TestPresignGetFallsBackToTheCdnWhenUnconfigured(t *testing.T) {
|
||||
withSpaces(t, "", "")
|
||||
|
||||
got, err := PresignGet("pv/abc.jpg", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("PresignGet should degrade, not fail: %v", err)
|
||||
}
|
||||
if got != "https://images.nearle.app/pv/abc.jpg" {
|
||||
t.Errorf("fallback URL = %q, want the plain CDN link", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Configured() gates the presign path; it must not claim to be configured on a
|
||||
// half-set environment.
|
||||
func TestConfiguredRequiresBothKeys(t *testing.T) {
|
||||
withSpaces(t, "AKIAEXAMPLE", "")
|
||||
if Configured() {
|
||||
t.Error("Configured() = true with no secret key")
|
||||
}
|
||||
|
||||
withSpaces(t, "", "shhh")
|
||||
if Configured() {
|
||||
t.Error("Configured() = true with no access key")
|
||||
}
|
||||
|
||||
withSpaces(t, "AKIAEXAMPLE", "shhh")
|
||||
if !Configured() {
|
||||
t.Error("Configured() = false with both keys present")
|
||||
}
|
||||
}
|
||||
|
||||
// Object keys reach this from user-influenced paths, so the encoder has to
|
||||
// survive spaces and reserved characters without breaking the signature.
|
||||
func TestEncodePathHandlesAwkwardKeys(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"pv/abc.jpg", "pv/abc.jpg"},
|
||||
{"pv/a b.jpg", "pv/a%20b.jpg"},
|
||||
{"pv/a+b.jpg", "pv/a%2Bb.jpg"},
|
||||
{"pv/sub dir/x.jpg", "pv/sub%20dir/x.jpg"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := encodePath(tc.in); got != tc.want {
|
||||
t.Errorf("encodePath(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
73
main.go
73
main.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"doormile/internal/assignment"
|
||||
"doormile/internal/notify"
|
||||
"doormile/internal/routing"
|
||||
"doormile/internal/sms"
|
||||
"doormile/internal/worker"
|
||||
"doormile/middlewares"
|
||||
"doormile/migrations"
|
||||
@@ -31,6 +33,30 @@ import (
|
||||
// turned into an error by the recover middleware — into the same
|
||||
// {success, message} envelope the utils helpers emit, so clients never receive
|
||||
// Fiber's default plain-text error body.
|
||||
// isLoopbackOrigin reports whether an Origin header names this machine, on any
|
||||
// port. It exists for Flutter Web, whose dev server picks a fresh random port
|
||||
// on every launch — no fixed allowlist can name it in advance.
|
||||
//
|
||||
// Deliberately strict about what counts as loopback: the host must be exactly
|
||||
// localhost, 127.0.0.1 or [::1]. A prefix match would admit
|
||||
// http://localhost.attacker.com, which is a different machine entirely and is
|
||||
// precisely the mistake this kind of check usually makes.
|
||||
func isLoopbackOrigin(origin string) bool {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return false
|
||||
}
|
||||
switch u.Hostname() {
|
||||
case "localhost", "127.0.0.1", "::1":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func errorHandler(c *fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
msg := "internal server error"
|
||||
@@ -67,6 +93,13 @@ func main() {
|
||||
db.InitNATS(cfg)
|
||||
notify.InitFCM()
|
||||
|
||||
// Install the SMS gateway. Until this call existed, sms.Register had no
|
||||
// callers anywhere in the tree, so every customer verification code was
|
||||
// written to this log and no text was ever sent — the single blocker on
|
||||
// customer sign-in, and the reason bookings were being made in the app's
|
||||
// offline mode and never reaching the console.
|
||||
sms.Configure()
|
||||
|
||||
// 3. Run GORM migrations for logistics tables
|
||||
if db.DB != nil {
|
||||
err := migrations.Migrate(db.DB)
|
||||
@@ -107,14 +140,48 @@ func main() {
|
||||
// nil-pointer dereference in any handler takes the whole process down.
|
||||
app.Use(recover.New(recover.Config{EnableStackTrace: true}))
|
||||
|
||||
// CORS policy
|
||||
// CORS policy.
|
||||
//
|
||||
// The named list is production and the well-known dev-server ports. It cannot
|
||||
// cover local development on its own: `flutter run -d chrome` binds a RANDOM
|
||||
// high port on every launch (65256 one run, something else the next), so a
|
||||
// fixed allowlist misses it every time and the browser rejects the request
|
||||
// with PreflightMissingAllowOriginHeader before the handler is ever reached.
|
||||
//
|
||||
// AllowOriginsFunc is consulted only when the static list has already missed,
|
||||
// so it widens nothing in production — it just admits loopback origins on any
|
||||
// port while developing. It is NOT enabled when ENV=production: a live API
|
||||
// that accepts credentialed requests from any localhost page is a real, if
|
||||
// modest, hole — a developer visiting a hostile page served from their own
|
||||
// machine would have that page able to call this API as them.
|
||||
//
|
||||
// A wildcard is not an option regardless: AllowCredentials with
|
||||
// AllowOrigins "*" is rejected by the CORS spec, and Fiber panics on it.
|
||||
allowLoopbackOrigins := !strings.EqualFold(cfg.Env, "production")
|
||||
if allowLoopbackOrigins {
|
||||
utils.Info("CORS: loopback origins on any port are allowed (non-production)", "env", cfg.Env)
|
||||
}
|
||||
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowHeaders: "Origin,Content-Type,Accept,Authorization",
|
||||
AllowOrigins: "http://localhost:5173,http://localhost:5174,http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:8080,http://localhost:8081,https://doormile.com,https://www.doormile.com,https://admin.doormile.com,https://api.doormile.com,https://crm.doormile.com,https://console.doormile.com,https://app.doormile.com,https://hub.doormile.com",
|
||||
// Idempotency-Key is sent by the rider app on pickup-complete, payment
|
||||
// and the base handover. A browser client that could not send it would
|
||||
// lose retry safety on exactly the calls that most need it.
|
||||
AllowHeaders: "Origin,Content-Type,Accept,Authorization,Idempotency-Key",
|
||||
AllowOrigins: "http://localhost:5173,http://localhost:5174,http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:8080,http://localhost:8081,https://doormile.com,https://www.doormile.com,https://admin.doormile.com,https://api.doormile.com,https://crm.doormile.com,https://console.doormile.com,https://app.doormile.com,https://hub.doormile.com",
|
||||
AllowOriginsFunc: func(origin string) bool {
|
||||
return allowLoopbackOrigins && isLoopbackOrigin(origin)
|
||||
},
|
||||
AllowCredentials: true,
|
||||
AllowMethods: "GET,POST,PUT,DELETE,PATCH,OPTIONS",
|
||||
}))
|
||||
|
||||
// Echo X-Request-Id on every response, errors included, minting one when the
|
||||
// caller did not send it. The customer app funnels every failure into a
|
||||
// single retryable error state, so without this a support conversation
|
||||
// about "it failed" has nothing to correlate against the logs. Registered
|
||||
// before the logger so the id is available to it.
|
||||
app.Use(middlewares.RequestID())
|
||||
|
||||
// Structured Zap logger middleware
|
||||
app.Use(middlewares.ZapLogger())
|
||||
|
||||
|
||||
@@ -39,3 +39,21 @@ func CityGateMiddleware(c *fiber.Ctx) error {
|
||||
"code": "CITY_NOT_SUPPORTED",
|
||||
})
|
||||
}
|
||||
|
||||
// PincodeInOperatingCity reports whether a pincode falls in a city Doormile
|
||||
// runs in, and names it.
|
||||
//
|
||||
// Exported because the customer app's booking request does not carry a
|
||||
// pickuppincode at all — its pickup point is a title/sub/lat/lng from the place
|
||||
// search, so CityGateMiddleware's body sniff finds nothing and waves it
|
||||
// through. A middleware that silently no-ops on the one caller it matters most
|
||||
// for is worse than no middleware, so the customer handler resolves the pickup
|
||||
// point to a pincode itself and asks this directly.
|
||||
func PincodeInOperatingCity(pincode string) (string, bool) {
|
||||
pincode = strings.TrimSpace(pincode)
|
||||
if len(pincode) < 3 {
|
||||
return "", false
|
||||
}
|
||||
city, ok := operatingCityPrefixes[pincode[:3]]
|
||||
return city, ok
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package middlewares
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -37,8 +39,7 @@ func Idempotency() fiber.Handler {
|
||||
if key == "" || db.Rdb == nil {
|
||||
return c.Next()
|
||||
}
|
||||
uid, _ := c.Locals("userid").(int)
|
||||
base := fmt.Sprintf("idem:%d:%s", uid, key)
|
||||
base := "idem:" + idempotencyScope(c) + ":" + key
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
@@ -69,10 +70,27 @@ func Idempotency() fiber.Handler {
|
||||
return err
|
||||
}
|
||||
|
||||
// Cache only deterministic outcomes (2xx/4xx). A 5xx is transient — the
|
||||
// retry should get a genuine second attempt, not a cached failure.
|
||||
// Cache SUCCESS only.
|
||||
//
|
||||
// This used to store any status below 500, on the reasoning that a 4xx
|
||||
// is deterministic. A 4xx is not deterministic — it is a refusal made
|
||||
// against state that moves. POST /customer/auth/otp/verify returns 401
|
||||
// when the submitted code does not match the one in Redis, and the whole
|
||||
// point of that screen is that the customer then gets the code right.
|
||||
// With the refusal cached for 24 hours, the retry that should have
|
||||
// worked replayed the old 401 instead — confirmed live against
|
||||
// api.doormile.com, where the second attempt came back carrying
|
||||
// Idempotent-Replay: true. One typo locked a customer out for a day.
|
||||
// The same shape applies to 403 after a permission is granted, 404 after
|
||||
// a record is created, and 429 after a window rolls over.
|
||||
//
|
||||
// Nothing is lost by narrowing it. This middleware exists to stop a retry
|
||||
// repeating a SIDE EFFECT — a second pickup, a second COD collection, a
|
||||
// second session. A request that ended 4xx performed no side effect, so
|
||||
// re-executing it is exactly as safe as the first attempt was, and
|
||||
// strictly more correct than replaying a stale no.
|
||||
status := c.Response().StatusCode()
|
||||
if status < 500 {
|
||||
if isCacheableStatus(status) {
|
||||
body := string(c.Response().Body())
|
||||
db.Rdb.Set(context.Background(), base, strconv.Itoa(status)+sep+body, ttl)
|
||||
}
|
||||
@@ -80,3 +98,38 @@ func Idempotency() fiber.Handler {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// isCacheableStatus reports whether a response may be stored and replayed to
|
||||
// a later request carrying the same key. Only a 2xx may — see above.
|
||||
func isCacheableStatus(status int) bool {
|
||||
return status >= 200 && status < 300
|
||||
}
|
||||
|
||||
// idempotencyScope namespaces a key so one caller's stored response can never
|
||||
// be replayed to another.
|
||||
//
|
||||
// For an authenticated request the caller's own user id is the scope, which is
|
||||
// what this middleware has always used.
|
||||
//
|
||||
// An UNAUTHENTICATED request has no user id, and defaulting to 0 would put
|
||||
// every anonymous caller in one namespace: two customers who happened to pick
|
||||
// the same Idempotency-Key on POST /customer/auth/otp/verify would collide, and
|
||||
// the second would be handed the first's access token, refresh token and
|
||||
// customer record. So an anonymous request is scoped by the request path and a
|
||||
// hash of its body instead — the same body replays, a different body does not,
|
||||
// and one person's session can never be served to another.
|
||||
// The authenticated form is byte-identical to what this middleware has always
|
||||
// produced ("idem:<uid>:<key>"). Changing it would orphan every in-flight key
|
||||
// in Redis at deploy time, and a rider retrying a pickup-complete across that
|
||||
// boundary would execute it a second time instead of replaying — the exact
|
||||
// double-collection this middleware exists to prevent.
|
||||
func idempotencyScope(c *fiber.Ctx) string {
|
||||
if uid, ok := c.Locals("userid").(int); ok && uid != 0 {
|
||||
return fmt.Sprintf("%d", uid)
|
||||
}
|
||||
// Anonymous callers get their own namespace shape, which no previous key
|
||||
// can collide with: every key written before this change had a numeric
|
||||
// scope, and this one never is.
|
||||
sum := sha256.Sum256(append([]byte(c.Path()+"\x00"), c.Body()...))
|
||||
return "anon-" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
172
middlewares/idempotency_test.go
Normal file
172
middlewares/idempotency_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// The idempotency key namespace is a security boundary, not bookkeeping.
|
||||
//
|
||||
// POST /customer/auth/otp/verify is UNAUTHENTICATED, so c.Locals("userid") is
|
||||
// absent there. Scoping on it anyway put every anonymous caller in one
|
||||
// namespace: two customers picking the same Idempotency-Key would collide and
|
||||
// the second would be handed the first's access token, refresh token and
|
||||
// customer record. These tests pin the fix.
|
||||
|
||||
// scopeFor runs idempotencyScope inside a real request and returns what it
|
||||
// produced.
|
||||
func scopeFor(t *testing.T, authedUserID int, path, body string) string {
|
||||
t.Helper()
|
||||
|
||||
app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
||||
app.Post("/*", func(c *fiber.Ctx) error {
|
||||
if authedUserID != 0 {
|
||||
c.Locals("userid", authedUserID)
|
||||
}
|
||||
return c.SendString(idempotencyScope(c))
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req, 5000)
|
||||
if err != nil {
|
||||
t.Fatalf("test request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
buf := make([]byte, 256)
|
||||
n, _ := resp.Body.Read(buf)
|
||||
return string(buf[:n])
|
||||
}
|
||||
|
||||
// Two anonymous callers sending DIFFERENT bodies must never share a namespace,
|
||||
// even with an identical Idempotency-Key. This is the session-handover bug.
|
||||
func TestAnonymousCallersNeverShareAnIdempotencyNamespace(t *testing.T) {
|
||||
alice := scopeFor(t, 0, "/customer/auth/otp/verify",
|
||||
`{"identifier":"+919876543210","code":"1111"}`)
|
||||
bob := scopeFor(t, 0, "/customer/auth/otp/verify",
|
||||
`{"identifier":"+919000000001","code":"2222"}`)
|
||||
|
||||
if alice == bob {
|
||||
t.Fatalf("two different anonymous requests share the scope %q — "+
|
||||
"one customer's session could be replayed to another", alice)
|
||||
}
|
||||
if alice == "" || bob == "" {
|
||||
t.Fatal("empty scope: every request must land in some namespace")
|
||||
}
|
||||
}
|
||||
|
||||
// The same anonymous caller repeating the SAME request must replay — that is
|
||||
// the whole point of idempotency.
|
||||
func TestIdenticalAnonymousRequestReplays(t *testing.T) {
|
||||
body := `{"identifier":"+919876543210","code":"4821"}`
|
||||
first := scopeFor(t, 0, "/customer/auth/otp/verify", body)
|
||||
again := scopeFor(t, 0, "/customer/auth/otp/verify", body)
|
||||
|
||||
if first != again {
|
||||
t.Errorf("the same request produced two scopes (%q, %q) — a retry would "+
|
||||
"re-execute instead of replaying", first, again)
|
||||
}
|
||||
}
|
||||
|
||||
// The authenticated format is byte-identical to what this middleware has always
|
||||
// produced. Changing it would orphan every in-flight key in Redis at deploy
|
||||
// time, and a rider retrying a pickup-complete across that boundary would
|
||||
// collect COD twice instead of replaying.
|
||||
func TestAuthenticatedScopeFormatIsUnchanged(t *testing.T) {
|
||||
got := scopeFor(t, 42, "/miler/bookings/7/pickup-complete", `{}`)
|
||||
if got != "42" {
|
||||
t.Errorf("authenticated scope = %q, want %q — the stored key format must "+
|
||||
"not change across a deploy", got, "42")
|
||||
}
|
||||
}
|
||||
|
||||
// An anonymous scope can never collide with an authenticated one: the old
|
||||
// format is always numeric, the new one never is.
|
||||
func TestAnonymousScopeCannotCollideWithAnAuthenticatedOne(t *testing.T) {
|
||||
anon := scopeFor(t, 0, "/customer/auth/otp/verify", `{"code":"1"}`)
|
||||
if !strings.HasPrefix(anon, "anon-") {
|
||||
t.Errorf("anonymous scope = %q, want an 'anon-' prefix so it cannot look "+
|
||||
"like a user id", anon)
|
||||
}
|
||||
}
|
||||
|
||||
// The operating-city gate, exported because the customer booking shape carries
|
||||
// no pincode for CityGateMiddleware to sniff.
|
||||
func TestPincodeInOperatingCity(t *testing.T) {
|
||||
cases := []struct {
|
||||
pincode string
|
||||
wantCity string
|
||||
wantOK bool
|
||||
}{
|
||||
{"641012", "Coimbatore", true},
|
||||
{"600001", "Chennai", true},
|
||||
{"560034", "Bengaluru", true},
|
||||
{"500081", "Hyderabad", true},
|
||||
{"629001", "Nagercoil", true},
|
||||
{"110001", "", false}, // Delhi — not an operating city
|
||||
{"12", "", false}, // too short to classify
|
||||
{"", "", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
city, ok := PincodeInOperatingCity(tc.pincode)
|
||||
if ok != tc.wantOK || city != tc.wantCity {
|
||||
t.Errorf("PincodeInOperatingCity(%q) = (%q, %v), want (%q, %v)",
|
||||
tc.pincode, city, ok, tc.wantCity, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What may be replayed from the idempotency cache.
|
||||
//
|
||||
// The middleware exists to stop a retry repeating a SIDE EFFECT — a second
|
||||
// pickup, a second COD collection, a second session. It used to cache every
|
||||
// status below 500, which quietly extended that to refusals.
|
||||
//
|
||||
// POST /customer/auth/otp/verify is where it bit: a wrong code returns 401, and
|
||||
// the whole purpose of the screen is that the customer then gets it right. With
|
||||
// the 401 cached for 24 hours, the retry that should have worked replayed the
|
||||
// old refusal. Confirmed live against api.doormile.com — the second attempt
|
||||
// came back carrying `Idempotent-Replay: true`.
|
||||
|
||||
func TestOnlySuccessfulResponsesAreCacheable(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
want bool
|
||||
why string
|
||||
}{
|
||||
{200, true, "a completed mutation is exactly what must not run twice"},
|
||||
{201, true, "a created booking must not be created again"},
|
||||
{204, true, "a completed no-content mutation still ran"},
|
||||
|
||||
{400, false, "a malformed body performed no side effect; re-running is free"},
|
||||
{401, false, "the code was wrong; the retry is meant to be right"},
|
||||
{403, false, "a permission can be granted between attempts"},
|
||||
{404, false, "the record can exist by the time of the retry"},
|
||||
{409, false, "a conflict can clear"},
|
||||
{422, false, "a district can reopen"},
|
||||
{429, false, "the rate-limit window rolls over"},
|
||||
|
||||
{500, false, "transient; the retry deserves a genuine second attempt"},
|
||||
{503, false, "the dependency can come back"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := isCacheableStatus(tc.status); got != tc.want {
|
||||
t.Errorf("status %d cacheable = %v, want %v — %s", tc.status, got, tc.want, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The specific regression, stated as itself: a failed sign-in must never be
|
||||
// replayed to a customer who has since typed the right code.
|
||||
func TestAFailedOtpVerifyIsNotCached(t *testing.T) {
|
||||
if isCacheableStatus(fiber.StatusUnauthorized) {
|
||||
t.Fatal("a 401 from /customer/auth/otp/verify would be cached for 24 hours, " +
|
||||
"so the retry with the correct code replays the refusal instead of running")
|
||||
}
|
||||
}
|
||||
@@ -19,21 +19,33 @@ func ZapLogger() fiber.Handler {
|
||||
method := c.Method()
|
||||
path := c.Path()
|
||||
|
||||
// Client identity and the request id travel with every log line.
|
||||
//
|
||||
// X-Client / X-Platform are what the customer app sends
|
||||
// ("doormile-cx/1.0.0+12", "android"), and they are the only way to
|
||||
// answer "is this failing on one build or everywhere" — which is the
|
||||
// first question asked when an app-side report arrives. The request id
|
||||
// is what correlates a customer saying "it failed" with the line that
|
||||
// recorded the real error behind the customer-safe message.
|
||||
//
|
||||
// Recorded as empty rather than omitted when absent, so a caller that
|
||||
// sends nothing is visibly a caller that sends nothing.
|
||||
fields := []interface{}{
|
||||
"method", method,
|
||||
"path", path,
|
||||
"status", status,
|
||||
"latency", latency,
|
||||
"client", c.Get("X-Client"),
|
||||
"platform", c.Get("X-Platform"),
|
||||
}
|
||||
if id, ok := c.Locals("requestid").(string); ok && id != "" {
|
||||
fields = append(fields, "requestid", id)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
utils.Error("API request error",
|
||||
"method", method,
|
||||
"path", path,
|
||||
"status", status,
|
||||
"latency", latency,
|
||||
"error", err.Error(),
|
||||
)
|
||||
utils.Error("API request error", append(fields, "error", err.Error())...)
|
||||
} else {
|
||||
utils.Info("API request success",
|
||||
"method", method,
|
||||
"path", path,
|
||||
"status", status,
|
||||
"latency", latency,
|
||||
)
|
||||
utils.Info("API request success", fields...)
|
||||
}
|
||||
|
||||
return err
|
||||
|
||||
33
middlewares/requestid.go
Normal file
33
middlewares/requestid.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// RequestID echoes the caller's X-Request-Id on every response, errors
|
||||
// included, and mints one when the caller did not send it. The customer app
|
||||
// funnels all failures into a single retryable error state, so a support
|
||||
// conversation about "it failed" has nothing to correlate on unless the id the
|
||||
// client already holds comes back on the failing response too.
|
||||
//
|
||||
// Also stashed in c.Locals("requestid") so handlers can log it alongside the
|
||||
// real error they are hiding behind a customer-safe message.
|
||||
func RequestID() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
id := c.Get("X-Request-Id")
|
||||
if id == "" {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err == nil {
|
||||
id = hex.EncodeToString(b)
|
||||
}
|
||||
}
|
||||
if id != "" {
|
||||
c.Locals("requestid", id)
|
||||
c.Set("X-Request-Id", id)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,19 @@ func Migrate(db *gorm.DB) error {
|
||||
&models.MilerDutyLog{},
|
||||
&models.MilerBreakLog{},
|
||||
&models.MilerSupportTicket{},
|
||||
|
||||
// Customer app (doormile_cx). All additive: new tables plus new
|
||||
// nullable/zero-default columns on pickupbookings, so an existing row
|
||||
// and every console-created booking stay valid with no backfill.
|
||||
&models.ServiceableState{},
|
||||
&models.ServiceableDistrict{},
|
||||
&models.PickupSlotTemplate{},
|
||||
&models.CustomerBookingLimit{},
|
||||
&models.BookingDestination{},
|
||||
&models.BookingParcelPhoto{},
|
||||
&models.BookingStageEvent{},
|
||||
&models.CustomerRefreshToken{},
|
||||
&models.CustomerDevice{},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -94,5 +107,47 @@ func Migrate(db *gorm.DB) error {
|
||||
utils.Info("✅ consignments_status_check constraint includes Collected_By_Miler")
|
||||
}
|
||||
|
||||
// Human-facing identifiers for the customer app: DM-###### for a pickup
|
||||
// booking, DMX######## for an order. Sequence-backed rather than random,
|
||||
// because both columns are UNIQUE and a random short id collides long
|
||||
// before a short id runs out — a collision here is a failed booking at the
|
||||
// moment of payment, not a retry.
|
||||
//
|
||||
// No CYCLE and no MAXVALUE on purpose: past 999999 the format simply grows
|
||||
// a digit (DM-1000000) instead of wrapping onto an id that already exists.
|
||||
// Existing rows keep their old DM-BK-/DM-TRK- strings; nothing parses
|
||||
// either format, so the two coexist safely.
|
||||
if res := db.Exec(`CREATE SEQUENCE IF NOT EXISTS cx_booking_reference_seq START 100000 INCREMENT 1`); res.Error != nil {
|
||||
utils.Error("❌ Failed to create cx_booking_reference_seq", "error", res.Error)
|
||||
} else {
|
||||
utils.Info("✅ cx_booking_reference_seq ready")
|
||||
}
|
||||
if res := db.Exec(`CREATE SEQUENCE IF NOT EXISTS cx_tracking_seq START 10000000 INCREMENT 1`); res.Error != nil {
|
||||
utils.Error("❌ Failed to create cx_tracking_seq", "error", res.Error)
|
||||
} else {
|
||||
utils.Info("✅ cx_tracking_seq ready")
|
||||
}
|
||||
|
||||
// One booking must not hold two destinations at the same position: the
|
||||
// customer addresses a destination by index in
|
||||
// PATCH /customer/bookings/{ref}/destinations/{index}, so a duplicate seq
|
||||
// makes that route ambiguous and would let an edit land on the wrong
|
||||
// address.
|
||||
if res := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bookingdestinations_booking_seq
|
||||
ON bookingdestinations (bookingid, seq)`); res.Error != nil {
|
||||
utils.Error("❌ Failed to create bookingdestinations (bookingid, seq) unique index", "error", res.Error)
|
||||
} else {
|
||||
utils.Info("✅ bookingdestinations (bookingid, seq) unique index ready")
|
||||
}
|
||||
|
||||
// The timeline is read as "every event for this booking, oldest first" on
|
||||
// every tracking poll, which is the hottest customer read there is.
|
||||
if res := db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookingstageevents_booking_time
|
||||
ON bookingstageevents (bookingid, occurredat)`); res.Error != nil {
|
||||
utils.Error("❌ Failed to create bookingstageevents (bookingid, occurredat) index", "error", res.Error)
|
||||
} else {
|
||||
utils.Info("✅ bookingstageevents (bookingid, occurredat) index ready")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -71,8 +71,14 @@ type Consignment struct {
|
||||
Returninitiatedat *time.Time `json:"returninitiatedat" gorm:"column:returninitiatedat"`
|
||||
Returndeliveredat *time.Time `json:"returndeliveredat" gorm:"column:returndeliveredat"`
|
||||
Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"`
|
||||
Condition string `json:"condition" gorm:"column:condition;size:50"` // recorded at hub inbound scan: Good, Damaged, etc.
|
||||
Shelf string `json:"shelf" gorm:"column:shelf;size:50"` // hub storage location assigned at inbound scan
|
||||
// Inwardedat is when the parcel was physically received at a base — written
|
||||
// by the rider handover (POST /miler/consignments/:id/inward-at-hub) and by
|
||||
// the console inbound scan. Distinct from Updatedat, which moves on every
|
||||
// write: the handover time is a business fact staff reconcile against, so it
|
||||
// needs a column of its own. Null until the parcel is actually received.
|
||||
Inwardedat *time.Time `json:"inwardedat" gorm:"column:inwardedat"`
|
||||
Condition string `json:"condition" gorm:"column:condition;size:50"` // recorded at hub inbound scan: Good, Damaged, etc.
|
||||
Shelf string `json:"shelf" gorm:"column:shelf;size:50"` // hub storage location assigned at inbound scan
|
||||
// Deliveryotp is issued when the consignment goes out for delivery and is
|
||||
// given to the receiver, not the rider — it is the only proof the parcel
|
||||
// reached the right person. Never serialised outward: returning it in an API
|
||||
|
||||
@@ -24,7 +24,20 @@ type PickupBooking struct {
|
||||
// parcel came out of. Separate column because pickuplocationid points at a
|
||||
// different table entirely; writing a tenantlocations id into it violates
|
||||
// that foreign key. This is what per-site reporting groups by.
|
||||
Tenantlocationid *int `json:"tenantlocationid" gorm:"column:tenantlocationid;index"`
|
||||
Tenantlocationid *int `json:"tenantlocationid" gorm:"column:tenantlocationid;index"`
|
||||
// Pickupsourcetype says what KIND of place this booking is collected from —
|
||||
// one of constants.PickupSource*. It is stored on the booking row, not looked
|
||||
// up from a location master, because a customer-door pickup has no location
|
||||
// id at all: only the row can tell "no location because it is a front door"
|
||||
// apart from "no location because nobody filled it in". Empty on rows written
|
||||
// before this column existed; derivePickupSourceType classifies those from
|
||||
// what they do carry, so the API never returns a blank type.
|
||||
Pickupsourcetype string `json:"pickup_source_type" gorm:"column:pickupsourcetype;size:20"`
|
||||
// Pickuphubid is set only when Pickupsourcetype is "hub" — the base the
|
||||
// parcel is collected FROM (Base → Customer). It is a separate column from
|
||||
// Nearesthubid, which is the base a parcel is routed TO. Conflating them
|
||||
// would make a base-origin booking look like a base-destination one.
|
||||
Pickuphubid *int `json:"pickuphubid" gorm:"column:pickuphubid;index"`
|
||||
Pickupaddress string `json:"pickupaddress" gorm:"column:pickupaddress;not null"`
|
||||
Pickuppincode string `json:"pickuppincode" gorm:"column:pickuppincode;not null"`
|
||||
Pickuplatitude float64 `json:"pickuplatitude" gorm:"column:pickuplatitude;not null"`
|
||||
@@ -60,14 +73,80 @@ type PickupBooking struct {
|
||||
// list, so a "Converted_To_Consignment" booking can still show
|
||||
// Out_for_Delivery / Delivered). omitempty keeps it out of every other
|
||||
// PickupBooking response that does not populate it.
|
||||
Consignmentstatus string `json:"consignmentstatus,omitempty" gorm:"-"`
|
||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||
Consignmentstatus string `json:"consignmentstatus,omitempty" gorm:"-"`
|
||||
// Trackingno is not a column either — it lives on the consignment, which only
|
||||
// exists once the parcel is collected. It is filled in by handlers that serve
|
||||
// a customer, because without it the customer app has no way to reach
|
||||
// GET /customer/track/:trackingno at all: a booking is addressed by id, a
|
||||
// shipment by tracking number, and nothing joined the two.
|
||||
Trackingno string `json:"trackingno,omitempty" gorm:"-"`
|
||||
// Destinationcount and Totalpackagecount are not columns either. A
|
||||
// customer-app pickup is ONE booking carrying N destinations, and the admin
|
||||
// list has to be able to say "3 destinations · 4 packages" without shipping
|
||||
// the whole destination array on every row — the console drains up to 12
|
||||
// pages of 100 bookings and does not use the array in the list. Filled in by
|
||||
// the admin bookings list from one grouped query per page (see
|
||||
// applyDestinationCounts). Deliberately NOT omitempty: a console-created
|
||||
// booking has no bookingdestinations rows at all and must report 0, which
|
||||
// the console reads as "no destinations recorded" — omitting the field would
|
||||
// make it indistinguishable from a single-destination booking.
|
||||
Destinationcount int `json:"destinationcount" gorm:"-"`
|
||||
Totalpackagecount int `json:"totalpackagecount" gorm:"-"`
|
||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||
|
||||
// ── Customer-app (doormile_cx) columns ──────────────────────────────────
|
||||
// All additive and nullable/zero-valued, so every existing row and every
|
||||
// console-created booking stays valid without a backfill.
|
||||
|
||||
// Slotid is the pickup window the customer chose, as the opaque id the app
|
||||
// sent back ("slot_20260905_t1"). Preferredpickupfrom/to carry the same
|
||||
// window as real times for the assignment engine; this keeps the customer's
|
||||
// own choice recorded verbatim, so a slot definition edited later cannot
|
||||
// silently rewrite what they picked.
|
||||
Slotid string `json:"slotid" gorm:"column:slotid;size:40"`
|
||||
|
||||
// Customerstage is the operational stage the customer app renders, one of
|
||||
// the nine keys in constants.CxStage*. Distinct from Status, which is the
|
||||
// booking lifecycle the console and the miler app read: Status stops moving
|
||||
// at Converted_To_Consignment while the parcels keep going, and the two
|
||||
// vocabularies are not a renaming of each other. Derived from the same
|
||||
// writes, stored so a read is one row rather than a replay of the history.
|
||||
Customerstage string `json:"customerstage" gorm:"column:customerstage;size:24"`
|
||||
// Customerstatus is active / completed / cancelled. Derived, but sent
|
||||
// explicitly — making the client infer it from a stage is how two surfaces
|
||||
// end up disagreeing about whether an order is finished.
|
||||
Customerstatus string `json:"customerstatus" gorm:"column:customerstatus;size:16"`
|
||||
|
||||
// The estimate range the customer was actually shown at booking time, in
|
||||
// whole rupees. Kept forever, including after settlement: the receipt
|
||||
// renders amountPaid − estimate min as the weight adjustment, and a price
|
||||
// dispute needs the number that was on screen, not a re-run of today's
|
||||
// pricing rules.
|
||||
Estimateminrupees int `json:"estimateminrupees" gorm:"column:estimateminrupees;default:0"`
|
||||
Estimatemaxrupees int `json:"estimatemaxrupees" gorm:"column:estimatemaxrupees;default:0"`
|
||||
// Routekm is the pickup→destination distance the estimate was priced on;
|
||||
// it drives the route outline and the receipt.
|
||||
Routekm float64 `json:"routekm" gorm:"column:routekm;default:0"`
|
||||
|
||||
// Pickuptitle/Pickupsub are the two-line pickup label the customer picked
|
||||
// from the place search ("12 Nehru Street" / "Gandhipuram, Coimbatore
|
||||
// 641012"). Pickupaddress remains the single flat string the rest of the
|
||||
// system uses; these keep the split the app renders without it having to
|
||||
// re-parse one back into two.
|
||||
Pickuptitle string `json:"pickuptitle" gorm:"column:pickuptitle;size:64"`
|
||||
Pickupsub string `json:"pickupsub" gorm:"column:pickupsub"`
|
||||
|
||||
// Cancelreason is free text or one of the app's five presets. Nullable in
|
||||
// spirit — an empty string means the customer gave no reason, which is
|
||||
// allowed.
|
||||
Cancelreason string `json:"cancelreason" gorm:"column:cancelreason;size:120"`
|
||||
|
||||
// Relations
|
||||
Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"`
|
||||
ServiceOptions []BookingServiceOption `json:"serviceoptions" gorm:"foreignKey:Bookingid"`
|
||||
Payments []BookingPayment `json:"payments" gorm:"foreignKey:Bookingid"`
|
||||
Destinations []BookingDestination `json:"destinations" gorm:"foreignKey:Bookingid"`
|
||||
}
|
||||
|
||||
func (PickupBooking) TableName() string {
|
||||
@@ -75,8 +154,16 @@ func (PickupBooking) TableName() string {
|
||||
}
|
||||
|
||||
type BookingParcel struct {
|
||||
Bookingparcelid int `json:"bookingparcelid" gorm:"primaryKey;column:bookingparcelid"`
|
||||
Bookingid int `json:"bookingid" gorm:"column:bookingid"`
|
||||
Bookingparcelid int `json:"bookingparcelid" gorm:"primaryKey;column:bookingparcelid"`
|
||||
Bookingid int `json:"bookingid" gorm:"column:bookingid"`
|
||||
// Bookingdestinationid says which destination this package is going to.
|
||||
// Null on console-created and pre-existing bookings, which have exactly one
|
||||
// delivery address and therefore no ambiguity. On a customer-app booking it
|
||||
// is what lets the miler weigh three packages for Chennai and one for
|
||||
// Kochi and have each weight settle against the right order — without it,
|
||||
// a multi-destination pickup has one pile of parcels and no way to say
|
||||
// which parcel belongs to which tracking number.
|
||||
Bookingdestinationid *int `json:"bookingdestinationid" gorm:"column:bookingdestinationid;index"`
|
||||
Itemcategory string `json:"itemcategory" gorm:"column:itemcategory"`
|
||||
Itemdescription string `json:"itemdescription" gorm:"column:itemdescription"`
|
||||
Declaredvalue float64 `json:"declaredvalue" gorm:"column:declaredvalue"`
|
||||
|
||||
241
models/customer_app.go
Normal file
241
models/customer_app.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Schema for the customer app (doormile_cx) surface.
|
||||
//
|
||||
// The one structural idea here is that a customer books a PICKUP, not a
|
||||
// shipment: one booking fans out to 1..N destinations, and each destination
|
||||
// becomes its own consignment with its own tracking number when the miler
|
||||
// completes the pickup. A single-destination booking is the same shape, not a
|
||||
// special case — pickupbookings keeps its flat delivery* columns mirrored from
|
||||
// destination 0 so the miler app, the hub console and the routing code keep
|
||||
// reading the row they already read.
|
||||
|
||||
// ServiceableState is a state the customer may choose a destination in. The
|
||||
// picker hides any state with no available district, but the row stays so ops
|
||||
// can open one without an app release.
|
||||
type ServiceableState struct {
|
||||
Statecode string `json:"statecode" gorm:"primaryKey;column:statecode;size:8"`
|
||||
Statename string `json:"statename" gorm:"column:statename;not null"`
|
||||
// Transittag is display copy shown under the state name ("Ultra-fast
|
||||
// transit", "Opening soon"). Capped at 22 characters by the design.
|
||||
Transittag string `json:"transittag" gorm:"column:transittag;size:22"`
|
||||
Displayorder int `json:"displayorder" gorm:"column:displayorder;default:0"`
|
||||
Status string `json:"status" gorm:"column:status;default:Active"` // Active, InActive
|
||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||
}
|
||||
|
||||
func (ServiceableState) TableName() string { return "serviceablestates" }
|
||||
|
||||
// ServiceableDistrict is one district inside a state. Unavailable districts are
|
||||
// returned too: the picker filters them out but the app shows their names in a
|
||||
// quiet "coming soon" line, so dropping them would lose real copy.
|
||||
type ServiceableDistrict struct {
|
||||
Districtcode string `json:"districtcode" gorm:"primaryKey;column:districtcode;size:16"`
|
||||
Statecode string `json:"statecode" gorm:"column:statecode;size:8;index;not null"`
|
||||
Districtname string `json:"districtname" gorm:"column:districtname;not null"`
|
||||
Available bool `json:"available" gorm:"column:available;default:false"`
|
||||
// Note says why a district is not available — "Opening soon", "Paused this
|
||||
// week". Only meaningful when Available is false.
|
||||
Note string `json:"note" gorm:"column:note;size:60"`
|
||||
// Hubid is the base that serves this district. Nullable: a district can be
|
||||
// announced before its hub exists.
|
||||
Hubid *int `json:"hubid" gorm:"column:hubid"`
|
||||
// Promise is the delivery promise shown on the destination card,
|
||||
// "Next-day delivery" / "2-day delivery".
|
||||
Promise string `json:"promise" gorm:"column:promise;size:40"`
|
||||
// Centre coordinates price and route a destination before any street
|
||||
// address exists — only state and district are required at booking time, so
|
||||
// this is frequently the only geography a destination has.
|
||||
Centrelatitude float64 `json:"centrelatitude" gorm:"column:centrelatitude"`
|
||||
Centrelongitude float64 `json:"centrelongitude" gorm:"column:centrelongitude"`
|
||||
Pincodeprefix string `json:"pincodeprefix" gorm:"column:pincodeprefix;size:6"`
|
||||
Displayorder int `json:"displayorder" gorm:"column:displayorder;default:0"`
|
||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||
}
|
||||
|
||||
func (ServiceableDistrict) TableName() string { return "serviceabledistricts" }
|
||||
|
||||
// PickupSlotTemplate is a recurring daily pickup window. The customer-facing
|
||||
// slot id is minted per date from the template code, so a slot always resolves
|
||||
// back to a real preferredpickupfrom/to on the booking — a slot ops cannot
|
||||
// staff is worse than no slot.
|
||||
type PickupSlotTemplate struct {
|
||||
Slottemplateid int `json:"slottemplateid" gorm:"primaryKey;column:slottemplateid"`
|
||||
// Code is the stable part of the slot id: "t1" renders as
|
||||
// "slot_20260905_t1".
|
||||
Code string `json:"code" gorm:"column:code;size:16;not null"`
|
||||
Starthour int `json:"starthour" gorm:"column:starthour;not null"`
|
||||
Startminute int `json:"startminute" gorm:"column:startminute;default:0"`
|
||||
Endhour int `json:"endhour" gorm:"column:endhour;not null"`
|
||||
Endminute int `json:"endminute" gorm:"column:endminute;default:0"`
|
||||
// Capacity is how many pickups this window absorbs in one zone.
|
||||
Capacity int `json:"capacity" gorm:"column:capacity;default:20"`
|
||||
// Applocationid scopes a template to one city. Null means every city.
|
||||
Applocationid *int `json:"applocationid" gorm:"column:applocationid;index"`
|
||||
// Tag is the single promotional label the design allows on at most one slot
|
||||
// ("Fastest pickup"). Caption is the softer line under it.
|
||||
Tag string `json:"tag" gorm:"column:tag;size:40"`
|
||||
Caption string `json:"caption" gorm:"column:caption;size:60"`
|
||||
Displayorder int `json:"displayorder" gorm:"column:displayorder;default:0"`
|
||||
Status string `json:"status" gorm:"column:status;default:Active"`
|
||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||
}
|
||||
|
||||
func (PickupSlotTemplate) TableName() string { return "pickupslottemplates" }
|
||||
|
||||
// CustomerBookingLimit caps a pickup. Kept in the database rather than in the
|
||||
// app so ops can vary it by city or tier without an app release. A row with a
|
||||
// null applocationid is the global default.
|
||||
type CustomerBookingLimit struct {
|
||||
Limitid int `json:"limitid" gorm:"primaryKey;column:limitid"`
|
||||
Applocationid *int `json:"applocationid" gorm:"column:applocationid;index"`
|
||||
Maxpackages int `json:"maxpackages" gorm:"column:maxpackages;default:20"`
|
||||
Maxdestinations int `json:"maxdestinations" gorm:"column:maxdestinations;default:5"`
|
||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||
}
|
||||
|
||||
func (CustomerBookingLimit) TableName() string { return "customerbookinglimits" }
|
||||
|
||||
// BookingDestination is one place inside a pickup booking, and the row that
|
||||
// becomes a consignment at pickup completion. This is the table that makes
|
||||
// "one visit, N orders" expressible: before it a booking carried exactly one
|
||||
// delivery address in its own columns and there was nowhere to put the second.
|
||||
type BookingDestination struct {
|
||||
Bookingdestinationid int `json:"bookingdestinationid" gorm:"primaryKey;column:bookingdestinationid"`
|
||||
Bookingid int `json:"bookingid" gorm:"column:bookingid;index;not null"`
|
||||
// Seq is the 0-based position within the booking and is the {index} in
|
||||
// PATCH /customer/bookings/{ref}/destinations/{index}. Stable for the life
|
||||
// of the booking — destinations are never reordered, because the customer
|
||||
// addresses them by position.
|
||||
Seq int `json:"seq" gorm:"column:seq;not null"`
|
||||
|
||||
// Only state and district are required at booking time. Names are stored
|
||||
// alongside the codes rather than joined at read time: the client renders
|
||||
// "Chennai, Tamil Nadu" straight from the booking and never looks a code
|
||||
// up, and a district renamed later must not silently rewrite an address the
|
||||
// customer already agreed to.
|
||||
Statecode string `json:"statecode" gorm:"column:statecode;size:8;not null"`
|
||||
Statename string `json:"statename" gorm:"column:statename;not null"`
|
||||
Districtcode string `json:"districtcode" gorm:"column:districtcode;size:16;not null"`
|
||||
Districtname string `json:"districtname" gorm:"column:districtname;not null"`
|
||||
Packagecount int `json:"packagecount" gorm:"column:packagecount;default:1"`
|
||||
|
||||
// Everything below is optional at booking time and may be completed by the
|
||||
// customer afterwards or by the miler at the door. Empty means "not added
|
||||
// yet", which the UI states explicitly rather than hiding.
|
||||
Street string `json:"street" gorm:"column:street"`
|
||||
Building string `json:"building" gorm:"column:building"`
|
||||
Landmark string `json:"landmark" gorm:"column:landmark"`
|
||||
Recipientname string `json:"recipientname" gorm:"column:recipientname"`
|
||||
Recipientphone string `json:"recipientphone" gorm:"column:recipientphone"`
|
||||
Instructions string `json:"instructions" gorm:"column:instructions"`
|
||||
Pinlatitude *float64 `json:"pinlatitude" gorm:"column:pinlatitude"`
|
||||
Pinlongitude *float64 `json:"pinlongitude" gorm:"column:pinlongitude"`
|
||||
Pincode string `json:"pincode" gorm:"column:pincode;size:10"`
|
||||
|
||||
// Codamount is money the miler collects at this door on the customer's
|
||||
// behalf. Doormile is the carrier, never the seller — this is the
|
||||
// customer's own collection, and it is per destination because it is
|
||||
// collected per delivery.
|
||||
Codamount float64 `json:"codamount" gorm:"column:codamount;default:0"`
|
||||
|
||||
// Set at pickup completion, when this destination becomes an order.
|
||||
Consignmentid *int `json:"consignmentid" gorm:"column:consignmentid;index"`
|
||||
Trackingno string `json:"trackingno" gorm:"column:trackingno;size:32;index"`
|
||||
// Stage is the per-order stage from order_created onward. Empty until the
|
||||
// order exists; the booking's own stage governs everything before that.
|
||||
Stage string `json:"stage" gorm:"column:stage;size:24"`
|
||||
Expecteddeliveryat *time.Time `json:"expecteddeliveryat" gorm:"column:expecteddeliveryat"`
|
||||
Deliveredat *time.Time `json:"deliveredat" gorm:"column:deliveredat"`
|
||||
|
||||
// Verification is what the miler recorded at the door: the weight the price
|
||||
// actually settled on, and who recorded it. Photos live in
|
||||
// bookingparcelphotos, one row per package.
|
||||
Verifiedweightkg *float64 `json:"verifiedweightkg" gorm:"column:verifiedweightkg"`
|
||||
Verifiedat *time.Time `json:"verifiedat" gorm:"column:verifiedat"`
|
||||
Verifiedbyuserid *int `json:"verifiedbyuserid" gorm:"column:verifiedbyuserid"`
|
||||
|
||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
|
||||
}
|
||||
|
||||
func (BookingDestination) TableName() string { return "bookingdestinations" }
|
||||
|
||||
// BookingParcelPhoto is a photograph the miler took of a package at the door.
|
||||
// It is the evidence half of the receipt — a settled weight without the photo
|
||||
// is a number the customer has no way to check.
|
||||
type BookingParcelPhoto struct {
|
||||
Photoid int `json:"photoid" gorm:"primaryKey;column:photoid"`
|
||||
Bookingid int `json:"bookingid" gorm:"column:bookingid;index;not null"`
|
||||
Bookingdestinationid *int `json:"bookingdestinationid" gorm:"column:bookingdestinationid;index"`
|
||||
// Objectkey is the storage key. The customer is served a short-lived signed
|
||||
// URL derived from it, never a permanent public link — a parcel photo can
|
||||
// show the inside of someone's doorway.
|
||||
Objectkey string `json:"objectkey" gorm:"column:objectkey;not null"`
|
||||
Capturedbyuserid *int `json:"capturedbyuserid" gorm:"column:capturedbyuserid"`
|
||||
Capturedat time.Time `json:"capturedat" gorm:"column:capturedat;default:CURRENT_TIMESTAMP"`
|
||||
}
|
||||
|
||||
func (BookingParcelPhoto) TableName() string { return "bookingparcelphotos" }
|
||||
|
||||
// BookingStageEvent is the append-only audit log every customer timeline is
|
||||
// built from: one row per stage actually reached, with the real time it was
|
||||
// reached and who caused it. Nothing here is synthesised or backfilled — a
|
||||
// timeline with invented timestamps is worse than a short one, because the
|
||||
// customer cannot tell which entries are real.
|
||||
type BookingStageEvent struct {
|
||||
Stageeventid int `json:"stageeventid" gorm:"primaryKey;column:stageeventid"`
|
||||
Bookingid int `json:"bookingid" gorm:"column:bookingid;index;not null"`
|
||||
// Bookingdestinationid is null for the booking-level stages (booked through
|
||||
// order_created) and set for the per-order ones (in_transit onward), which
|
||||
// may differ between destinations of the same booking.
|
||||
Bookingdestinationid *int `json:"bookingdestinationid" gorm:"column:bookingdestinationid;index"`
|
||||
Stage string `json:"stage" gorm:"column:stage;size:24;not null"`
|
||||
// Actortype/Actorid answer "who did this" — miler, ops user, the customer,
|
||||
// or the system. Required by the audit rule, and the only way to explain a
|
||||
// cancellation to a customer who did not make it.
|
||||
Actortype string `json:"actortype" gorm:"column:actortype;size:16;not null"`
|
||||
Actorid *int `json:"actorid" gorm:"column:actorid"`
|
||||
Source string `json:"source" gorm:"column:source;size:64"`
|
||||
Remarks string `json:"remarks" gorm:"column:remarks"`
|
||||
Occurredat time.Time `json:"occurredat" gorm:"column:occurredat;index;not null"`
|
||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||
}
|
||||
|
||||
func (BookingStageEvent) TableName() string { return "bookingstageevents" }
|
||||
|
||||
// CustomerRefreshToken backs the 60-day session. Stored hashed: a leaked
|
||||
// database row must not itself be a usable credential. Rotation is recorded via
|
||||
// Replacedbyid so a replayed old token identifies the chain it came from.
|
||||
type CustomerRefreshToken struct {
|
||||
Refreshtokenid int `json:"refreshtokenid" gorm:"primaryKey;column:refreshtokenid"`
|
||||
Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid;index;not null"`
|
||||
Tokenhash string `json:"-" gorm:"column:tokenhash;size:64;uniqueIndex;not null"`
|
||||
Expiresat time.Time `json:"expiresat" gorm:"column:expiresat;not null"`
|
||||
Revokedat *time.Time `json:"revokedat" gorm:"column:revokedat"`
|
||||
Replacedbyid *int `json:"replacedbyid" gorm:"column:replacedbyid"`
|
||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||
}
|
||||
|
||||
func (CustomerRefreshToken) TableName() string { return "customerrefreshtokens" }
|
||||
|
||||
// CustomerDevice is a push registration. One row per device token, not per
|
||||
// customer: a customer with a phone and a tablet must get the notification on
|
||||
// both, and appcustomers.device_token could only ever hold the last one.
|
||||
type CustomerDevice struct {
|
||||
Customerdeviceid int `json:"customerdeviceid" gorm:"primaryKey;column:customerdeviceid"`
|
||||
Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid;index;not null"`
|
||||
Token string `json:"token" gorm:"column:token;uniqueIndex;not null"`
|
||||
Platform string `json:"platform" gorm:"column:platform;size:16"`
|
||||
Appversion string `json:"appversion" gorm:"column:appversion;size:32"`
|
||||
Lastseenat time.Time `json:"lastseenat" gorm:"column:lastseenat;default:CURRENT_TIMESTAMP"`
|
||||
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
|
||||
}
|
||||
|
||||
func (CustomerDevice) TableName() string { return "customerdevices" }
|
||||
175
models/customer_app_test.go
Normal file
175
models/customer_app_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Table names and column tags are strings the compiler never checks. A typo in
|
||||
// either produces a binary that builds cleanly and then fails against the
|
||||
// database at runtime — which, for the customer app, means the first real
|
||||
// booking. These are cheap tests for a class of bug nothing else catches.
|
||||
|
||||
// Every customer-app model maps to the table AutoMigrate creates and the seed
|
||||
// fills. A mismatch here means seed_customer_app.sql populates one table while
|
||||
// the code reads another.
|
||||
func TestCustomerAppTableNames(t *testing.T) {
|
||||
cases := []struct {
|
||||
model interface{ TableName() string }
|
||||
want string
|
||||
}{
|
||||
{ServiceableState{}, "serviceablestates"},
|
||||
{ServiceableDistrict{}, "serviceabledistricts"},
|
||||
{PickupSlotTemplate{}, "pickupslottemplates"},
|
||||
{CustomerBookingLimit{}, "customerbookinglimits"},
|
||||
{BookingDestination{}, "bookingdestinations"},
|
||||
{BookingParcelPhoto{}, "bookingparcelphotos"},
|
||||
{BookingStageEvent{}, "bookingstageevents"},
|
||||
{CustomerRefreshToken{}, "customerrefreshtokens"},
|
||||
{CustomerDevice{}, "customerdevices"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := tc.model.TableName(); got != tc.want {
|
||||
t.Errorf("%T.TableName() = %q, want %q",
|
||||
tc.model, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// columnOf reads the gorm column name off a struct field, the way the driver
|
||||
// does.
|
||||
func columnOf(t *testing.T, model interface{}, field string) string {
|
||||
t.Helper()
|
||||
|
||||
f, ok := reflect.TypeOf(model).FieldByName(field)
|
||||
if !ok {
|
||||
t.Fatalf("%T has no field %s", model, field)
|
||||
}
|
||||
for _, part := range strings.Split(f.Tag.Get("gorm"), ";") {
|
||||
if strings.HasPrefix(part, "column:") {
|
||||
return strings.TrimPrefix(part, "column:")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// The columns the fan-out is built on. bookingdestinations.seq in particular is
|
||||
// addressed by position in PATCH .../destinations/{index} and is what stop
|
||||
// ordering sorts by — a renamed column there silently reorders a rider's route.
|
||||
func TestBookingDestinationColumns(t *testing.T) {
|
||||
want := map[string]string{
|
||||
"Bookingdestinationid": "bookingdestinationid",
|
||||
"Bookingid": "bookingid",
|
||||
"Seq": "seq",
|
||||
"Statecode": "statecode",
|
||||
"Districtcode": "districtcode",
|
||||
"Packagecount": "packagecount",
|
||||
"Recipientname": "recipientname",
|
||||
"Recipientphone": "recipientphone",
|
||||
"Codamount": "codamount",
|
||||
"Consignmentid": "consignmentid",
|
||||
"Trackingno": "trackingno",
|
||||
"Stage": "stage",
|
||||
"Verifiedweightkg": "verifiedweightkg",
|
||||
}
|
||||
for field, col := range want {
|
||||
if got := columnOf(t, BookingDestination{}, field); got != col {
|
||||
t.Errorf("BookingDestination.%s column = %q, want %q", field, got, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The columns added to the live pickupbookings table. These land on 512
|
||||
// existing rows, so the names have to match what migrate.go creates.
|
||||
func TestPickupBookingCustomerColumns(t *testing.T) {
|
||||
want := map[string]string{
|
||||
"Slotid": "slotid",
|
||||
"Customerstage": "customerstage",
|
||||
"Customerstatus": "customerstatus",
|
||||
"Estimateminrupees": "estimateminrupees",
|
||||
"Estimatemaxrupees": "estimatemaxrupees",
|
||||
"Routekm": "routekm",
|
||||
"Pickuptitle": "pickuptitle",
|
||||
"Pickupsub": "pickupsub",
|
||||
"Cancelreason": "cancelreason",
|
||||
}
|
||||
for field, col := range want {
|
||||
if got := columnOf(t, PickupBooking{}, field); got != col {
|
||||
t.Errorf("PickupBooking.%s column = %q, want %q", field, got, col)
|
||||
}
|
||||
}
|
||||
|
||||
// The link that lets a parcel know which order it belongs to.
|
||||
if got := columnOf(t, BookingParcel{}, "Bookingdestinationid"); got != "bookingdestinationid" {
|
||||
t.Errorf("BookingParcel.Bookingdestinationid column = %q, want %q",
|
||||
got, "bookingdestinationid")
|
||||
}
|
||||
}
|
||||
|
||||
// A refresh token is a bearer credential valid for sixty days. It must never be
|
||||
// serialised outward — a JSON tag of anything but "-" would put it in a
|
||||
// response body.
|
||||
func TestRefreshTokenHashIsNeverSerialised(t *testing.T) {
|
||||
f, ok := reflect.TypeOf(CustomerRefreshToken{}).FieldByName("Tokenhash")
|
||||
if !ok {
|
||||
t.Fatal("CustomerRefreshToken has no Tokenhash field")
|
||||
}
|
||||
if got := f.Tag.Get("json"); got != "-" {
|
||||
t.Errorf("Tokenhash json tag = %q, want \"-\" — a stored credential must "+
|
||||
"never leave the server", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The delivery OTP is the receiver's proof, not the rider's. Returning it in a
|
||||
// response would hand the rider the code they are supposed to be told at the
|
||||
// door.
|
||||
func TestDeliveryOtpIsNeverSerialised(t *testing.T) {
|
||||
f, ok := reflect.TypeOf(Consignment{}).FieldByName("Deliveryotp")
|
||||
if !ok {
|
||||
t.Fatal("Consignment has no Deliveryotp field")
|
||||
}
|
||||
if got := f.Tag.Get("json"); got != "-" {
|
||||
t.Errorf("Deliveryotp json tag = %q, want \"-\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The customer PIN hash predates the OTP flow and is now unused, but the column
|
||||
// still exists. It must stay unserialised.
|
||||
func TestCustomerPinHashIsNeverSerialised(t *testing.T) {
|
||||
f, ok := reflect.TypeOf(AppCustomer{}).FieldByName("Loginpinhash")
|
||||
if !ok {
|
||||
t.Fatal("AppCustomer has no Loginpinhash field")
|
||||
}
|
||||
if got := f.Tag.Get("json"); got != "-" {
|
||||
t.Errorf("Loginpinhash json tag = %q, want \"-\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Two columns on pickupbookings that are computed, not stored. A missing
|
||||
// gorm:"-" would make AutoMigrate create real columns for them and the driver
|
||||
// would try to write there.
|
||||
func TestComputedBookingFieldsAreNotColumns(t *testing.T) {
|
||||
for _, field := range []string{"Consignmentstatus", "Trackingno"} {
|
||||
f, ok := reflect.TypeOf(PickupBooking{}).FieldByName(field)
|
||||
if !ok {
|
||||
t.Fatalf("PickupBooking has no field %s", field)
|
||||
}
|
||||
if got := f.Tag.Get("gorm"); got != "-" {
|
||||
t.Errorf("PickupBooking.%s gorm tag = %q, want \"-\" — it is filled in "+
|
||||
"by handlers, not stored", field, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The device token is unique across the whole table, not per customer: a shared
|
||||
// handset must be REASSIGNED rather than duplicated, or one person's parcel
|
||||
// updates reach another person's phone.
|
||||
func TestCustomerDeviceTokenIsGloballyUnique(t *testing.T) {
|
||||
f, _ := reflect.TypeOf(CustomerDevice{}).FieldByName("Token")
|
||||
if tag := f.Tag.Get("gorm"); !strings.Contains(tag, "uniqueIndex") {
|
||||
t.Errorf("CustomerDevice.Token gorm tag = %q, want a uniqueIndex — the "+
|
||||
"upsert on conflict depends on it", tag)
|
||||
}
|
||||
}
|
||||
104
routes/routes.go
104
routes/routes.go
@@ -7,6 +7,7 @@ import (
|
||||
"doormile/config"
|
||||
"doormile/controllers"
|
||||
"doormile/db"
|
||||
"doormile/internal/sms"
|
||||
"doormile/internal/ws"
|
||||
"doormile/middlewares"
|
||||
|
||||
@@ -73,23 +74,60 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
||||
"checks": fiber.Map{
|
||||
"postgres": dbStatus,
|
||||
"redis": redisStatus,
|
||||
// Reported, but deliberately NOT gating readiness: the miler and
|
||||
// console surfaces work perfectly without SMS. It is here because
|
||||
// 'the OTP never arrived' was answerable only by reading code, and
|
||||
// for this service's whole life the answer has been that no gateway
|
||||
// was ever registered.
|
||||
"sms": fiber.Map{
|
||||
"transport": sms.Transport(),
|
||||
"configured": sms.Configured(),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------
|
||||
// CUSTOMER APIS
|
||||
// CUSTOMER APIS — doormile_cx
|
||||
// --------------------
|
||||
// The customer books a PICKUP, not a shipment: one visit, 1..N
|
||||
// destinations, and a tracking number per destination minted only when the
|
||||
// miler completes the pickup. Everything under /customer/* answers in the
|
||||
// customer envelope ({success, data, message}; errors carry error.code) —
|
||||
// auth included, deliberately unlike /miler/verify-pin, whose payload sits
|
||||
// outside `data` and cost the miler client a release to discover.
|
||||
//
|
||||
// This replaces the PIN-based customer surface (register / login /
|
||||
// verify-pin / reset-pin, and the single-destination booking create, list,
|
||||
// detail and cancel). Those were retired rather than moved: a booking with
|
||||
// one delivery address in its own columns is not a shape the product has
|
||||
// any more.
|
||||
customer := api.Group("/customer")
|
||||
customer.Post("/register", controllers.RegisterCustomer(cfg))
|
||||
customer.Post("/login", authThrottle, controllers.LoginCustomer)
|
||||
customer.Post("/verify-pin", authThrottle, controllers.VerifyCustomerPin(cfg))
|
||||
customer.Post("/reset-pin", authThrottle, controllers.ResetCustomerPin)
|
||||
customer.Post("/send-email-otp", authThrottle, controllers.SendCustomerEmailOtp(cfg))
|
||||
customer.Post("/verify-email-otp", authThrottle, controllers.VerifyCustomerEmailOtp())
|
||||
|
||||
// Auth — a 4-digit code to a phone or an email address, no password
|
||||
// anywhere. Throttled on the shared budget with every other credential
|
||||
// endpoint so an attacker cannot reset it by rotating between them.
|
||||
customer.Post("/auth/otp/request", authThrottle, controllers.CxRequestOtp(cfg))
|
||||
customer.Post("/auth/signup", authThrottle, controllers.CxSignup(cfg))
|
||||
// Idempotent: the client retries on flaky networks, and a replayed verify
|
||||
// must return the original session rather than mint a second one.
|
||||
customer.Post("/auth/otp/verify", authThrottle, middlewares.Idempotency(), controllers.CxVerifyOtp(cfg))
|
||||
customer.Post("/auth/refresh", authThrottle, controllers.CxRefresh(cfg))
|
||||
|
||||
// Serviceability and configuration are read before sign-in: the booking
|
||||
// form is explorable without an account, and gating the state picker behind
|
||||
// auth would make the app's first screen a login wall.
|
||||
customer.Get("/serviceability/states", controllers.GetCxStates)
|
||||
customer.Get("/serviceability/states/:stateCode/districts", controllers.GetCxDistricts)
|
||||
customer.Get("/pickup-slots", controllers.GetCxPickupSlots)
|
||||
customer.Get("/config/booking-limits", controllers.GetCxBookingLimits)
|
||||
|
||||
// Authenticated Customer App routes
|
||||
customerAuth := customer.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(9))
|
||||
|
||||
customerAuth.Get("/auth/me", controllers.CxMe)
|
||||
customerAuth.Post("/auth/logout", controllers.CxLogout)
|
||||
|
||||
customerAuth.Get("/profile", controllers.GetCustomerProfile)
|
||||
customerAuth.Put("/profile", controllers.UpdateCustomerProfile)
|
||||
|
||||
@@ -98,14 +136,38 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
||||
customerAuth.Put("/locations/:id", controllers.UpdateCustomerLocation)
|
||||
customerAuth.Delete("/locations/:id", controllers.DeleteCustomerLocation)
|
||||
|
||||
customerAuth.Put("/device-token", controllers.SaveCustomerDeviceToken)
|
||||
// Push registration. One row per device token, not one column per customer:
|
||||
// a phone and a tablet both have to receive the delivery notification.
|
||||
customerAuth.Post("/devices", controllers.RegisterCxDevice)
|
||||
customerAuth.Delete("/devices/:token", controllers.UnregisterCxDevice)
|
||||
|
||||
customerAuth.Post("/bookings", middlewares.CityGateMiddleware, controllers.CreateCustomerBooking)
|
||||
customerAuth.Get("/bookings", controllers.GetCustomerBookings)
|
||||
customerAuth.Get("/bookings/:bookingid", controllers.GetCustomerBookingDetails)
|
||||
customerAuth.Post("/bookings/:bookingid/cancel", controllers.CancelCustomerBooking)
|
||||
customerAuth.Get("/bookings/:bookingid/price", controllers.GetCustomerBookingQuote)
|
||||
customerAuth.Get("/track/:trackingno", controllers.TrackConsignment)
|
||||
// Places are proxied, never keyed: the legacy rider app shipped a Maps key
|
||||
// in the binary and it had to be revoked. The customer app is handed
|
||||
// results, not credentials.
|
||||
customerAuth.Get("/places/reverse-geocode", controllers.ReverseGeocodeCx(cfg))
|
||||
customerAuth.Get("/places/search", controllers.SearchCxPlaces(cfg))
|
||||
|
||||
// Called on every route and package-count change, so it is cheap and
|
||||
// cacheable — and a failed estimate never blocks a booking.
|
||||
customerAuth.Post("/fare/estimate", controllers.EstimateCxFare)
|
||||
|
||||
// Idempotency-Key on create: the client retries over bad networks and a
|
||||
// duplicate pickup is unacceptable.
|
||||
customerAuth.Post("/bookings", middlewares.CityGateMiddleware, middlewares.Idempotency(), controllers.CreateCxBooking)
|
||||
customerAuth.Get("/bookings", controllers.GetCxBookings)
|
||||
customerAuth.Get("/bookings/:reference", controllers.GetCxBookingDetail)
|
||||
customerAuth.Post("/bookings/:reference/cancel", controllers.CancelCxBooking)
|
||||
customerAuth.Patch("/bookings/:reference/destinations/:index", controllers.PatchCxDestination)
|
||||
|
||||
// One order by tracking number, for push deep links (doormile://track/…).
|
||||
customerAuth.Get("/orders/:trackingId", controllers.GetCxOrder)
|
||||
|
||||
// QA only. Refused outright unless ENV is non-production AND
|
||||
// CX_ALLOW_STAGE_OVERRIDE=true — two independent switches, because either
|
||||
// one being wrong in production would let any customer mark their own
|
||||
// parcel delivered. It exists so every tracking state is reachable for
|
||||
// design QA and the app's debug stepper can be deleted.
|
||||
customerAuth.Post("/ops/bookings/:reference/stage", controllers.ForceCxStage)
|
||||
|
||||
// --------------------
|
||||
// MILER APIS
|
||||
@@ -193,6 +255,16 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
||||
milerAuth.Post("/consignments/:id/start-delivery", middlewares.Idempotency(), controllers.MilerStartDelivery)
|
||||
milerAuth.Post("/consignments/:id/deliver", middlewares.Idempotency(), controllers.MilerDeliverConsignment)
|
||||
milerAuth.Post("/consignments/:id/skip", controllers.MilerSkipDelivery)
|
||||
// Rider handover at a base. The authoritative record that a hub-routed parcel
|
||||
// physically changed hands; answers with the resulting state rather than a
|
||||
// bare 200, and carries the shared idempotency middleware because riders retry
|
||||
// on bad signal at a loading bay.
|
||||
milerAuth.Post("/consignments/:id/inward-at-hub", middlewares.Idempotency(), controllers.MilerInwardConsignmentAtHub)
|
||||
|
||||
// Base master data on a rider token — id, name, address, pincode and
|
||||
// coordinates for every active base. /admin/tenants/:id/locations is a
|
||||
// different dataset (a client's own sites) and is closed to role 5 by design.
|
||||
milerAuth.Get("/bases", controllers.MilerGetBases)
|
||||
|
||||
// Earnings
|
||||
milerAuth.Get("/earnings", controllers.MilerGetEarnings)
|
||||
@@ -366,6 +438,10 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
||||
hubAuth.Get("/inbound/today", controllers.GetHubInboundToday)
|
||||
hubAuth.Get("/inbound", controllers.GetHubInboundRange)
|
||||
hubAuth.Post("/bookings/:id/inbound", controllers.CreateInboundScan)
|
||||
// What riders are carrying towards this base but have not handed over yet,
|
||||
// and the matching receive/dispute action for when they arrive.
|
||||
hubAuth.Get("/inbound/expected", controllers.GetHubInboundExpected)
|
||||
hubAuth.Post("/inbound/:id/reconcile", controllers.ReconcileHubInbound)
|
||||
hubAuth.Post("/bookings/:id/assign-miler", controllers.HubAssignMiler)
|
||||
hubAuth.Post("/bookings/:id/auto-assign", controllers.HubAutoAssign)
|
||||
hubAuth.Post("/bookings/batch-assign", controllers.HubBatchAssign)
|
||||
|
||||
405
routes/routes_customer_test.go
Normal file
405
routes/routes_customer_test.go
Normal file
@@ -0,0 +1,405 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// Routing contract for the customer surface, plus a regression guard proving
|
||||
// the miler, admin and hub surfaces were not touched.
|
||||
//
|
||||
// Same boundary as routes_logistics_test.go: everything up to the handler is
|
||||
// tested here with no database. A wrong path, a route registered under the
|
||||
// wrong group, a missing auth gate, or a param route shadowing a static one are
|
||||
// all real bugs that compile perfectly and that unit tests cannot see. A 200 is
|
||||
// never claimed — that needs Postgres and Redis.
|
||||
|
||||
// cxAuthedRoutes is every authenticated route in the customer contract. If a
|
||||
// path here stops resolving, the app gets a router 404 with no envelope at all
|
||||
// and the client's error state cannot explain it.
|
||||
var cxAuthedRoutes = []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/customer/auth/me", ""},
|
||||
{http.MethodPost, "/api/v1/customer/auth/logout", `{}`},
|
||||
|
||||
{http.MethodGet, "/api/v1/customer/profile", ""},
|
||||
{http.MethodPut, "/api/v1/customer/profile", `{"name":"Joe Oommen"}`},
|
||||
|
||||
{http.MethodGet, "/api/v1/customer/locations", ""},
|
||||
{http.MethodPost, "/api/v1/customer/locations", `{"address":"a","pincode":"641012","latitude":11.0,"longitude":76.9}`},
|
||||
{http.MethodPut, "/api/v1/customer/locations/1", `{"address":"a"}`},
|
||||
{http.MethodDelete, "/api/v1/customer/locations/1", ""},
|
||||
|
||||
{http.MethodPost, "/api/v1/customer/devices", `{"token":"abc","platform":"android"}`},
|
||||
{http.MethodDelete, "/api/v1/customer/devices/abc", ""},
|
||||
|
||||
{http.MethodGet, "/api/v1/customer/places/reverse-geocode?lat=11.0&lng=76.9", ""},
|
||||
{http.MethodGet, "/api/v1/customer/places/search?q=brookefields", ""},
|
||||
|
||||
{http.MethodPost, "/api/v1/customer/fare/estimate", `{"destinations":[{"stateCode":"TN","districtCode":"TN-MAA","packageCount":1}]}`},
|
||||
|
||||
{http.MethodPost, "/api/v1/customer/bookings", `{"slotId":"slot_20991231_t1","destinations":[]}`},
|
||||
{http.MethodGet, "/api/v1/customer/bookings", ""},
|
||||
{http.MethodGet, "/api/v1/customer/bookings/DM-482913", ""},
|
||||
{http.MethodPost, "/api/v1/customer/bookings/DM-482913/cancel", `{"reason":"Package not ready"}`},
|
||||
{http.MethodPatch, "/api/v1/customer/bookings/DM-482913/destinations/0", `{"street":"12th Main"}`},
|
||||
|
||||
{http.MethodGet, "/api/v1/customer/orders/DMX10482913", ""},
|
||||
|
||||
{http.MethodPost, "/api/v1/customer/ops/bookings/DM-482913/stage", `{"stage":"delivered"}`},
|
||||
}
|
||||
|
||||
// cxPublicRoutes are reachable without a token on purpose: the booking form is
|
||||
// explorable before sign-in, and gating the state picker behind auth would make
|
||||
// the app's first screen a login wall.
|
||||
var cxPublicRoutes = []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/customer/auth/otp/request", `{"identifier":""}`},
|
||||
{http.MethodPost, "/api/v1/customer/auth/signup", `{"name":"J"}`},
|
||||
{http.MethodPost, "/api/v1/customer/auth/otp/verify", `{"identifier":"nope","code":""}`},
|
||||
{http.MethodPost, "/api/v1/customer/auth/refresh", `{"refreshToken":""}`},
|
||||
}
|
||||
|
||||
// Every authenticated customer route exists and is gated. A missing token must
|
||||
// produce 401, never 404 (route absent) and never 500 (gate skipped and the
|
||||
// handler reached a nil database).
|
||||
func TestCustomerRoutesRequireATokenAndExist(t *testing.T) {
|
||||
app := newApp()
|
||||
|
||||
for _, r := range cxAuthedRoutes {
|
||||
t.Run(r.method+" "+r.path, func(t *testing.T) {
|
||||
status, body := do(t, app, r.method, r.path, "", r.body)
|
||||
|
||||
if status == fiber.StatusNotFound {
|
||||
t.Fatalf("route is not registered — the client would get a router 404 with no envelope (body: %s)", body)
|
||||
}
|
||||
if status >= 500 {
|
||||
t.Fatalf("status = %d: the auth gate did not stop this before the handler (body: %s)", status, body)
|
||||
}
|
||||
if status != fiber.StatusUnauthorized {
|
||||
t.Errorf("status = %d without a token, want 401 (body: %s)", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A customer route must refuse a token from any other surface. Role 9 is the
|
||||
// customer; 5 is a miler, 1 an admin, 6 hub staff. A miler token opening a
|
||||
// customer's booking would be a cross-surface data leak.
|
||||
func TestCustomerRoutesRefuseOtherRoles(t *testing.T) {
|
||||
app := newApp()
|
||||
|
||||
for _, roleID := range []int{1, 3, 4, 5, 6} {
|
||||
bearer := token(t, 99, roleID)
|
||||
|
||||
for _, r := range cxAuthedRoutes {
|
||||
status, body := do(t, app, r.method, r.path, bearer, r.body)
|
||||
|
||||
if status != fiber.StatusForbidden {
|
||||
t.Errorf("role %d on %s %s: status = %d, want 403 (body: %s)",
|
||||
roleID, r.method, r.path, status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The pre-auth routes are reachable without a token. They still must not 404
|
||||
// (route missing) and must not 500 — a malformed identifier is the caller's
|
||||
// mistake and has to be answered as one.
|
||||
func TestCustomerPublicRoutesAreReachableWithoutAToken(t *testing.T) {
|
||||
app := newApp()
|
||||
|
||||
for _, r := range cxPublicRoutes {
|
||||
t.Run(r.method+" "+r.path, func(t *testing.T) {
|
||||
status, body := do(t, app, r.method, r.path, "", r.body)
|
||||
|
||||
if status == fiber.StatusNotFound {
|
||||
t.Fatalf("route is not registered (body: %s)", body)
|
||||
}
|
||||
if status >= 500 {
|
||||
t.Fatalf("status = %d on a malformed request, want 4xx (body: %s)", status, body)
|
||||
}
|
||||
if status < 400 {
|
||||
t.Errorf("status = %d on a deliberately invalid body, want 4xx (body: %s)", status, body)
|
||||
}
|
||||
// The refusal has to come from the HANDLER, in the customer
|
||||
// envelope, not from the auth middleware. These four routes are
|
||||
// reached before sign-in, so a bare "authorization header is
|
||||
// required" here would mean one had been registered after the
|
||||
// group's Use and silently put behind a login wall.
|
||||
if !strings.Contains(body, `"error"`) {
|
||||
t.Errorf("refusal did not come from the handler — this route may have been "+
|
||||
"registered behind the auth middleware (body: %s)", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The retired PIN surface must be gone. Leaving it registered would keep a
|
||||
// credential endpoint alive that could reset any account from a phone number,
|
||||
// and would let the old app keep writing single-destination bookings that the
|
||||
// new tracking screen cannot render.
|
||||
func TestRetiredCustomerRoutesAreRemoved(t *testing.T) {
|
||||
app := newApp()
|
||||
|
||||
retired := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/customer/register"},
|
||||
{http.MethodPost, "/api/v1/customer/login"},
|
||||
{http.MethodPost, "/api/v1/customer/verify-pin"},
|
||||
{http.MethodPost, "/api/v1/customer/reset-pin"},
|
||||
{http.MethodPost, "/api/v1/customer/send-email-otp"},
|
||||
{http.MethodPost, "/api/v1/customer/verify-email-otp"},
|
||||
{http.MethodGet, "/api/v1/customer/track/DM-TRK-ABCD-123"},
|
||||
{http.MethodGet, "/api/v1/customer/bookings/42/price"},
|
||||
{http.MethodPut, "/api/v1/customer/device-token"},
|
||||
}
|
||||
|
||||
for _, r := range retired {
|
||||
status, body := do(t, app, r.method, r.path, "", `{}`)
|
||||
|
||||
// The assertion is "no handler serves this any more", not "404".
|
||||
//
|
||||
// Fiber mounts customerAuth via customer.Use(...) on the /customer
|
||||
// PREFIX, so any path under it that matches no route falls through to
|
||||
// the auth middleware and answers 401 rather than 404. That is
|
||||
// pre-existing behaviour of every group in this router (/miler,
|
||||
// /admin and /hub all do it) and is not something this work changed —
|
||||
// but it is what the retired PIN endpoints now return, and the app
|
||||
// developer needs to know that a stale build calling
|
||||
// POST /customer/login sees 401, not 404.
|
||||
if status < 400 {
|
||||
t.Errorf("%s %s: status = %d — a retired route is still being served (body: %s)",
|
||||
r.method, r.path, status, body)
|
||||
}
|
||||
if status >= 500 {
|
||||
t.Errorf("%s %s: status = %d — a retired route should refuse cleanly, not fault (body: %s)",
|
||||
r.method, r.path, status, body)
|
||||
}
|
||||
// The old PIN handlers answered 200/201 with a token. Nothing here may
|
||||
// still mint one.
|
||||
if strings.Contains(body, `"token"`) || strings.Contains(body, `"accessToken"`) {
|
||||
t.Errorf("%s %s still returns a credential: %s", r.method, r.path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Regression guard: the other surfaces are untouched ───────────────────────
|
||||
|
||||
// milerSurface is every miler route, with the role it requires. This exists to
|
||||
// answer one question in CI rather than by inspection: did the customer work
|
||||
// change the rider's API? A path disappearing, moving group, or losing its role
|
||||
// gate fails here.
|
||||
var milerSurface = []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/miler/profile"},
|
||||
{http.MethodPut, "/api/v1/miler/profile"},
|
||||
{http.MethodPut, "/api/v1/miler/device-token"},
|
||||
{http.MethodPut, "/api/v1/miler/location"},
|
||||
{http.MethodPut, "/api/v1/miler/availability"},
|
||||
{http.MethodGet, "/api/v1/miler/assignments"},
|
||||
{http.MethodGet, "/api/v1/miler/assignments/1"},
|
||||
{http.MethodPost, "/api/v1/miler/assignments/1/accept"},
|
||||
{http.MethodPost, "/api/v1/miler/assignments/1/reject"},
|
||||
{http.MethodPost, "/api/v1/miler/bookings/1/reached"},
|
||||
{http.MethodPatch, "/api/v1/miler/bookings/1/addresses"},
|
||||
{http.MethodPost, "/api/v1/miler/bookings/1/parcel"},
|
||||
{http.MethodPost, "/api/v1/miler/bookings/1/payment"},
|
||||
{http.MethodPost, "/api/v1/miler/bookings/1/pickup-complete"},
|
||||
{http.MethodPost, "/api/v1/miler/bookings/1/vehicle-required"},
|
||||
{http.MethodPost, "/api/v1/miler/bookings/1/cancel"},
|
||||
{http.MethodPost, "/api/v1/miler/bookings/1/skip"},
|
||||
{http.MethodGet, "/api/v1/miler/bookings"},
|
||||
{http.MethodPost, "/api/v1/miler/duty/start"},
|
||||
{http.MethodPut, "/api/v1/miler/duty/end"},
|
||||
{http.MethodGet, "/api/v1/miler/duty/current"},
|
||||
{http.MethodGet, "/api/v1/miler/consignments/1"},
|
||||
{http.MethodPost, "/api/v1/miler/consignments/1/start-delivery"},
|
||||
{http.MethodPost, "/api/v1/miler/consignments/1/deliver"},
|
||||
{http.MethodPost, "/api/v1/miler/consignments/1/skip"},
|
||||
{http.MethodPost, "/api/v1/miler/consignments/1/inward-at-hub"},
|
||||
{http.MethodGet, "/api/v1/miler/bases"},
|
||||
{http.MethodGet, "/api/v1/miler/earnings"},
|
||||
{http.MethodGet, "/api/v1/miler/notifications"},
|
||||
{http.MethodPost, "/api/v1/miler/support"},
|
||||
{http.MethodGet, "/api/v1/miler/support"},
|
||||
{http.MethodPost, "/api/v1/miler/uploads/sign"},
|
||||
}
|
||||
|
||||
// consoleSurface is a representative slice of the admin and hub consoles —
|
||||
// including every route the customer work touched code behind (assignment,
|
||||
// express booking creation, hub queues).
|
||||
var consoleSurface = []struct {
|
||||
method string
|
||||
path string
|
||||
role int
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/admin/dashboard", 1},
|
||||
{http.MethodGet, "/api/v1/admin/bookings", 1},
|
||||
{http.MethodGet, "/api/v1/admin/bookings/1", 1},
|
||||
{http.MethodPost, "/api/v1/admin/bookings/1/assign-miler", 1},
|
||||
{http.MethodPost, "/api/v1/admin/bookings/1/cancel", 1},
|
||||
{http.MethodPost, "/api/v1/admin/expressbooking", 1},
|
||||
{http.MethodPost, "/api/v1/admin/expressbooking/bulk", 1},
|
||||
{http.MethodGet, "/api/v1/admin/consignments", 1},
|
||||
{http.MethodGet, "/api/v1/admin/customers", 1},
|
||||
{http.MethodGet, "/api/v1/hub/dashboard", 6},
|
||||
{http.MethodGet, "/api/v1/hub/bookings/unassigned", 6},
|
||||
{http.MethodPost, "/api/v1/hub/bookings/1/assign-miler", 6},
|
||||
{http.MethodPost, "/api/v1/hub/bookings/1/auto-assign", 6},
|
||||
{http.MethodPost, "/api/v1/hub/bookings/batch-assign", 6},
|
||||
{http.MethodGet, "/api/v1/hub/inbound/expected", 6},
|
||||
}
|
||||
|
||||
// Every miler route still exists and is still gated to role 5. This is the
|
||||
// regression guard for "did the customer work break the rider app's routing".
|
||||
func TestMilerSurfaceIsUnchanged(t *testing.T) {
|
||||
app := newApp()
|
||||
customerBearer := token(t, 77, 9)
|
||||
|
||||
for _, r := range milerSurface {
|
||||
t.Run(r.method+" "+r.path, func(t *testing.T) {
|
||||
if status, body := do(t, app, r.method, r.path, "", `{}`); status != fiber.StatusUnauthorized {
|
||||
t.Errorf("no token: status = %d, want 401 (body: %s)", status, body)
|
||||
}
|
||||
// And a customer token must not reach a rider endpoint.
|
||||
if status, body := do(t, app, r.method, r.path, customerBearer, `{}`); status != fiber.StatusForbidden {
|
||||
t.Errorf("customer token: status = %d, want 403 (body: %s)", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleSurfaceIsUnchanged(t *testing.T) {
|
||||
app := newApp()
|
||||
customerBearer := token(t, 77, 9)
|
||||
|
||||
for _, r := range consoleSurface {
|
||||
t.Run(r.method+" "+r.path, func(t *testing.T) {
|
||||
if status, body := do(t, app, r.method, r.path, "", `{}`); status != fiber.StatusUnauthorized {
|
||||
t.Errorf("no token: status = %d, want 401 (body: %s)", status, body)
|
||||
}
|
||||
if status, body := do(t, app, r.method, r.path, customerBearer, `{}`); status != fiber.StatusForbidden {
|
||||
t.Errorf("customer token: status = %d, want 403 (body: %s)", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The customer envelope must not have leaked onto the other surfaces. A miler
|
||||
// or console client reads `code` at the top level, not `error.code`, and a
|
||||
// silently reshaped error body is the kind of thing that costs a release to
|
||||
// discover.
|
||||
func TestOtherSurfacesKeepTheirOwnErrorEnvelope(t *testing.T) {
|
||||
app := newApp()
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/v1/miler/bookings",
|
||||
"/api/v1/admin/bookings",
|
||||
"/api/v1/hub/dashboard",
|
||||
} {
|
||||
_, body := do(t, app, http.MethodGet, path, "", "")
|
||||
|
||||
if strings.Contains(body, `"error"`) {
|
||||
t.Errorf("%s: refusal body carries the customer surface's nested error object: %s", path, body)
|
||||
}
|
||||
if !strings.Contains(body, `"success"`) || !strings.Contains(body, `"message"`) {
|
||||
t.Errorf("%s: refusal body lost its original shape: %s", path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The four catalogue reads must be reachable WITHOUT a token.
|
||||
//
|
||||
// The booking form is explorable before sign-in — the state picker is the app's
|
||||
// first screen. These four are registered before `customer.Use(...)`, which is
|
||||
// the only thing keeping them public: move any of them below that line and the
|
||||
// app's opening screen silently becomes a login wall, with nothing else to
|
||||
// catch it. `cxPublicRoutes` above covers the auth endpoints; this covers the
|
||||
// catalogue, which had no such assertion until a wrong URL in the field
|
||||
// surfaced the gap.
|
||||
//
|
||||
// With no database configured these reach the handler and fault on a nil
|
||||
// db.DB — which is the proof. A 401 or 403 would mean the request never got
|
||||
// that far.
|
||||
func TestCatalogueRoutesAreNotBehindAuth(t *testing.T) {
|
||||
app := newApp()
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/v1/customer/serviceability/states",
|
||||
"/api/v1/customer/serviceability/states/TN/districts",
|
||||
"/api/v1/customer/pickup-slots?lat=11.0168&lng=76.9558",
|
||||
"/api/v1/customer/config/booking-limits",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
status, body := do(t, app, http.MethodGet, path, "", "")
|
||||
|
||||
if status == fiber.StatusUnauthorized {
|
||||
t.Fatalf("status = 401: this route is behind the auth middleware. "+
|
||||
"The booking form must be explorable before sign-in — check it is "+
|
||||
"registered BEFORE customer.Use(...) in routes.go (body: %s)", body)
|
||||
}
|
||||
if status == fiber.StatusForbidden {
|
||||
t.Fatalf("status = 403: this route is behind the role gate (body: %s)", body)
|
||||
}
|
||||
if status == fiber.StatusNotFound {
|
||||
t.Fatalf("status = 404: route not registered (body: %s)", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A wrong-role token must not change that answer either — these routes do
|
||||
// not consult the caller at all.
|
||||
adminBearer := token(t, 1, 1)
|
||||
for _, path := range []string{
|
||||
"/api/v1/customer/serviceability/states",
|
||||
"/api/v1/customer/config/booking-limits",
|
||||
} {
|
||||
if status, body := do(t, app, http.MethodGet, path, adminBearer, ""); status == fiber.StatusForbidden {
|
||||
t.Errorf("%s refused an admin token with 403 — a catalogue read is public "+
|
||||
"and should ignore the caller entirely (body: %s)", path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A MISTYPED path under /customer/* answers 401 or 403, never 404.
|
||||
//
|
||||
// Fiber mounts customerAuth via customer.Use(...) on the /customer PREFIX, so
|
||||
// any path matching no route falls through to the auth middleware. Every group
|
||||
// in this router behaves this way (/miler, /admin, /hub included) and it is not
|
||||
// something the customer work introduced — but it is genuinely confusing in the
|
||||
// field: a typo like /serviceability/state (singular) reports an auth problem
|
||||
// rather than a missing route, which sends people looking for a permissions bug
|
||||
// that is not there.
|
||||
//
|
||||
// Asserted so the behaviour is at least documented and deliberate.
|
||||
func TestMistypedCustomerPathReportsAuthNotNotFound(t *testing.T) {
|
||||
app := newApp()
|
||||
const typo = "/api/v1/customer/serviceability/state" // note: singular
|
||||
|
||||
status, body := do(t, app, http.MethodGet, typo, "", "")
|
||||
if status != fiber.StatusUnauthorized {
|
||||
t.Errorf("no token on a mistyped path: status = %d, want 401 (body: %s)", status, body)
|
||||
}
|
||||
|
||||
status, body = do(t, app, http.MethodGet, typo, token(t, 1, 1), "")
|
||||
if status != fiber.StatusForbidden {
|
||||
t.Errorf("wrong-role token on a mistyped path: status = %d, want 403 (body: %s)", status, body)
|
||||
}
|
||||
if !strings.Contains(body, "insufficient permissions") {
|
||||
t.Errorf("unexpected refusal body: %s", body)
|
||||
}
|
||||
}
|
||||
257
routes/routes_logistics_test.go
Normal file
257
routes/routes_logistics_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/routes"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
)
|
||||
|
||||
// Real HTTP requests against the real router.
|
||||
//
|
||||
// WHAT THIS DOES AND DOES NOT PROVE.
|
||||
//
|
||||
// Every route in this file is behind AuthMiddleware + RoleCheckMiddleware, and
|
||||
// both reject before the handler runs — so a request with a wrong-role or absent
|
||||
// token never reaches a line that touches Postgres. That makes it possible to
|
||||
// exercise the entire HTTP surface with no database, and it is worth doing:
|
||||
// it catches a mistyped path, a route registered under the wrong group, a
|
||||
// missing middleware, and a param route shadowing a static one. Those are real
|
||||
// bugs that compile perfectly and that unit tests over pure functions cannot see.
|
||||
//
|
||||
// It does NOT prove a handler works. Nothing here reaches a query, a
|
||||
// transaction, or a response body built from real rows. A 200 from these
|
||||
// endpoints has never been observed and this file does not claim one.
|
||||
//
|
||||
// The line is drawn deliberately: everything up to the handler is tested here;
|
||||
// everything from the handler inwards needs a database and is still unverified.
|
||||
|
||||
const jwtSecret = "test-secret-for-routing-only"
|
||||
|
||||
func newApp() *fiber.App {
|
||||
// Recover is what main.go installs too. Here it also acts as a guardrail:
|
||||
// with no database configured, any request that DID reach a handler would
|
||||
// nil-panic and take the whole test binary down. Nothing in this file is
|
||||
// supposed to get that far — recover turns a mistake into a failed test
|
||||
// rather than a crashed run.
|
||||
app := fiber.New()
|
||||
app.Use(recover.New())
|
||||
routes.RegisterRoutes(app, &config.Config{JWTSecret: jwtSecret})
|
||||
return app
|
||||
}
|
||||
|
||||
// token mints a valid JWT for a role, so the role gate can be exercised
|
||||
// independently of whether a token parses at all.
|
||||
func token(t *testing.T, userID, roleID int) string {
|
||||
t.Helper()
|
||||
tok, err := utils.GenerateToken(userID, "test@doormile.com", roleID, 0, 1001, jwtSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("could not mint a %d-role token: %v", roleID, err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func do(t *testing.T, app *fiber.App, method, path, bearer, body string) (int, string) {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != "" {
|
||||
rdr = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rdr)
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := app.Test(req, int(10*time.Second/time.Millisecond))
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(out)
|
||||
}
|
||||
|
||||
// The routes added for the base-handover flow, with the role each one requires.
|
||||
var newRoutes = []struct {
|
||||
method string
|
||||
path string
|
||||
wantRole int
|
||||
body string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/miler/consignments/42/inward-at-hub", 5, `{"hub_id":7}`},
|
||||
{http.MethodGet, "/api/v1/miler/bases", 5, ""},
|
||||
{http.MethodGet, "/api/v1/hub/inbound/expected", 6, ""},
|
||||
{http.MethodPost, "/api/v1/hub/inbound/42/reconcile", 6, `{"received":true}`},
|
||||
}
|
||||
|
||||
// Routes that already existed and whose responses this work changed.
|
||||
var changedRoutes = []struct {
|
||||
method string
|
||||
path string
|
||||
wantRole int
|
||||
body string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/miler/bookings/42/pickup-complete", 5, ""},
|
||||
{http.MethodGet, "/api/v1/miler/bookings", 5, ""},
|
||||
{http.MethodGet, "/api/v1/miler/consignments/42", 5, ""},
|
||||
{http.MethodGet, "/api/v1/admin/bookings/42", 1, ""},
|
||||
{http.MethodPost, "/api/v1/admin/expressbooking", 1, `{"tenantid":1,"pickuppincode":"641004","parcels":[{"weight":1}]}`},
|
||||
{http.MethodGet, "/api/v1/hub/bookings/unassigned", 6, ""},
|
||||
{http.MethodGet, "/api/v1/hub/inbound/today", 6, ""},
|
||||
{http.MethodPost, "/api/v1/customer/bookings", 9, `{"pickuppincode":"641004"}`},
|
||||
}
|
||||
|
||||
func allRoutes() []struct {
|
||||
method string
|
||||
path string
|
||||
wantRole int
|
||||
body string
|
||||
} {
|
||||
return append(append([]struct {
|
||||
method string
|
||||
path string
|
||||
wantRole int
|
||||
body string
|
||||
}{}, newRoutes...), changedRoutes...)
|
||||
}
|
||||
|
||||
// A route that is registered rejects an anonymous request with 401. One that is
|
||||
// NOT registered falls through to Fiber's own 404 — which is exactly how a
|
||||
// mistyped path hides, since both "fail".
|
||||
func TestEveryRouteIsRegistered(t *testing.T) {
|
||||
app := newApp()
|
||||
for _, r := range allRoutes() {
|
||||
t.Run(r.method+" "+r.path, func(t *testing.T) {
|
||||
status, body := do(t, app, r.method, r.path, "", r.body)
|
||||
if status == http.StatusNotFound {
|
||||
t.Fatalf("route is NOT registered — got 404: %s", body)
|
||||
}
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous request should be 401, got %d: %s", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The gate that produced the "insufficient permissions" report: a valid token of
|
||||
// the wrong role must be refused, and refused BEFORE the handler runs — with no
|
||||
// database configured, a handler that executed would panic on a nil db.DB, so a
|
||||
// clean 403 is itself the proof that nothing downstream ran.
|
||||
func TestWrongRoleIsRefusedBeforeTheHandlerRuns(t *testing.T) {
|
||||
app := newApp()
|
||||
// One role from each group, so every case is covered by some wrong role.
|
||||
roles := map[int]string{1: "admin", 5: "miler", 6: "hub staff", 9: "customer"}
|
||||
|
||||
for _, r := range allRoutes() {
|
||||
for role, name := range roles {
|
||||
if role == r.wantRole {
|
||||
continue
|
||||
}
|
||||
// Admin roles 1/3/4 are interchangeable; only test a genuinely wrong one.
|
||||
if r.wantRole == 1 && (role == 3 || role == 4) {
|
||||
continue
|
||||
}
|
||||
t.Run(fmt.Sprintf("%s as %s", r.path, name), func(t *testing.T) {
|
||||
status, body := do(t, app, r.method, r.path, token(t, 1, role), r.body)
|
||||
if status != http.StatusForbidden && status != http.StatusUnauthorized {
|
||||
t.Errorf("a %s token on a role-%d route returned %d, want 403/401: %s",
|
||||
name, r.wantRole, status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal has to be machine-readable, not just a status code — the console
|
||||
// and the rider app both branch on the body.
|
||||
func TestRefusalBodyIsWellFormed(t *testing.T) {
|
||||
app := newApp()
|
||||
status, body := do(t, app, http.MethodGet, "/api/v1/miler/bases", token(t, 1, 1), "")
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("admin token on a miler route: got %d, want 403", status)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
||||
t.Fatalf("refusal body is not JSON: %q", body)
|
||||
}
|
||||
if parsed["success"] != false {
|
||||
t.Errorf(`refusal should carry "success": false, got %v`, parsed["success"])
|
||||
}
|
||||
if parsed["message"] != "insufficient permissions for this resource" {
|
||||
t.Errorf("unexpected refusal message: %v", parsed["message"])
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed or unsigned token must never be accepted as a valid session.
|
||||
func TestGarbageTokensAreRejected(t *testing.T) {
|
||||
app := newApp()
|
||||
for _, tok := range []string{
|
||||
"not-a-jwt",
|
||||
"eyJhbGciOiJub25lIn0.eyJyb2xlaWQiOjV9.", // alg:none, roleid 5
|
||||
"",
|
||||
} {
|
||||
status, _ := do(t, app, http.MethodGet, "/api/v1/miler/bases", tok, "")
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("token %q returned %d, want 401", tok, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A token signed with the wrong secret must not open a session — the check that
|
||||
// stops a token minted elsewhere from being trusted here.
|
||||
func TestTokenSignedWithAnotherSecretIsRejected(t *testing.T) {
|
||||
app := newApp()
|
||||
foreign, err := utils.GenerateToken(1, "x@y.z", 5, 0, 1001, "a-different-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status, _ := do(t, app, http.MethodGet, "/api/v1/miler/bases", foreign, ""); status != http.StatusUnauthorized {
|
||||
t.Errorf("foreign-signed token returned %d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
// `/miler/bases` is static and `/miler/consignments/:consignmentid` is dynamic.
|
||||
// Fiber matches in registration order, so a param route registered first would
|
||||
// swallow a static sibling — the bug the Orders route table has a comment about.
|
||||
func TestStaticRoutesAreNotShadowedByParamRoutes(t *testing.T) {
|
||||
app := newApp()
|
||||
|
||||
// Probed with a token of the WRONG role on purpose. A 403 proves the request
|
||||
// matched this route and reached its role gate; a 404 would mean it matched
|
||||
// nothing, which is how a param route swallowing a static sibling shows up.
|
||||
// Using the right role instead would enter the handler and hit the database,
|
||||
// which is not what this file tests.
|
||||
cases := []struct {
|
||||
path string
|
||||
wrongRole int
|
||||
}{
|
||||
{"/api/v1/miler/bases", 1}, // static, sits beside /consignments/:id
|
||||
{"/api/v1/hub/inbound/expected", 5}, // static, sits beside /inbound/:id/reconcile
|
||||
{"/api/v1/miler/consignments/logs", 1}, // static, sits beside /consignments/:id
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.path, func(t *testing.T) {
|
||||
status, body := do(t, app, http.MethodGet, c.path, token(t, 1, c.wrongRole), "")
|
||||
if status == http.StatusNotFound {
|
||||
t.Fatalf("404 — the route is shadowed or unregistered: %s", body)
|
||||
}
|
||||
if status != http.StatusForbidden {
|
||||
t.Errorf("got %d, want 403 (matched the route, refused the role): %s", status, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
104
scratch/check_booking_paging.go
Normal file
104
scratch/check_booking_paging.go
Normal file
@@ -0,0 +1,104 @@
|
||||
//go:build ignore
|
||||
|
||||
// Read-only: does the console's page-by-page drain actually see every booking?
|
||||
//
|
||||
// GetAdminBookings runs Offset/Limit with NO ORDER BY. In Postgres that makes
|
||||
// the row order across pages unspecified, so a paged drain can legally return
|
||||
// the same row twice and never return another. This replays the exact 6 pages
|
||||
// the console fetches and compares the union against the table.
|
||||
//
|
||||
// go run scratch/check_booking_paging.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
const pageSize = 100 // what the server actually returns: min(100, requested)
|
||||
|
||||
type row struct{ Bookingid int }
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB connection")
|
||||
}
|
||||
|
||||
var total int64
|
||||
db.DB.Raw(`SELECT count(*) FROM pickupbookings`).Scan(&total)
|
||||
pages := int((total + pageSize - 1) / pageSize)
|
||||
fmt.Printf("bookings: %d, pages of %d: %d\n\n", total, pageSize, pages)
|
||||
|
||||
// The truth: every id that exists.
|
||||
var all []row
|
||||
db.DB.Raw(`SELECT bookingid FROM pickupbookings`).Scan(&all)
|
||||
truth := map[int]bool{}
|
||||
for _, r := range all {
|
||||
truth[r.Bookingid] = true
|
||||
}
|
||||
|
||||
// The drain, exactly as the console runs it — unordered Offset/Limit.
|
||||
seen := map[int]int{}
|
||||
for p := 0; p < pages; p++ {
|
||||
var page []row
|
||||
db.DB.Raw(`SELECT bookingid FROM pickupbookings LIMIT ? OFFSET ?`, pageSize, p*pageSize).Scan(&page)
|
||||
for _, r := range page {
|
||||
seen[r.Bookingid]++
|
||||
}
|
||||
fmt.Printf(" page %d: %d rows\n", p+1, len(page))
|
||||
}
|
||||
|
||||
missed := []int{}
|
||||
for id := range truth {
|
||||
if seen[id] == 0 {
|
||||
missed = append(missed, id)
|
||||
}
|
||||
}
|
||||
dupes := []int{}
|
||||
for id, n := range seen {
|
||||
if n > 1 {
|
||||
dupes = append(dupes, id)
|
||||
}
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(missed)))
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(dupes)))
|
||||
|
||||
fmt.Printf("\nunordered drain saw %d distinct of %d\n", len(seen), len(truth))
|
||||
fmt.Printf(" MISSED %d: %v\n", len(missed), head(missed, 20))
|
||||
fmt.Printf(" DUPLICATED %d: %v\n", len(dupes), head(dupes, 20))
|
||||
|
||||
// The same drain with a deterministic order — the proposed fix.
|
||||
seenOrdered := map[int]int{}
|
||||
for p := 0; p < pages; p++ {
|
||||
var page []row
|
||||
db.DB.Raw(`SELECT bookingid FROM pickupbookings ORDER BY bookingid DESC LIMIT ? OFFSET ?`,
|
||||
pageSize, p*pageSize).Scan(&page)
|
||||
for _, r := range page {
|
||||
seenOrdered[r.Bookingid]++
|
||||
}
|
||||
}
|
||||
missedOrdered := 0
|
||||
for id := range truth {
|
||||
if seenOrdered[id] == 0 {
|
||||
missedOrdered++
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nwith ORDER BY bookingid DESC: saw %d distinct, missed %d\n",
|
||||
len(seenOrdered), missedOrdered)
|
||||
}
|
||||
|
||||
func head(xs []int, n int) []int {
|
||||
if len(xs) > n {
|
||||
return xs[:n]
|
||||
}
|
||||
return xs
|
||||
}
|
||||
71
scratch/check_bookingno.go
Normal file
71
scratch/check_bookingno.go
Normal file
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB")
|
||||
}
|
||||
|
||||
fmt.Println("=== unique constraints on bookingno / trackingno ===")
|
||||
type c struct{ Conname, Def string }
|
||||
var cs []c
|
||||
db.DB.Raw(`SELECT c.conname, pg_get_constraintdef(c.oid) def
|
||||
FROM pg_constraint c JOIN pg_class t ON t.oid=c.conrelid
|
||||
WHERE t.relname IN ('pickupbookings','consignments') AND c.contype='u'`).Scan(&cs)
|
||||
if len(cs) == 0 {
|
||||
fmt.Println(" NONE — nothing stops a duplicate at the database level")
|
||||
}
|
||||
for _, x := range cs {
|
||||
fmt.Printf(" %s: %s\n", x.Conname, x.Def)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== duplicate booking numbers ===")
|
||||
type d struct {
|
||||
Bookingno string
|
||||
N int64
|
||||
}
|
||||
var ds []d
|
||||
db.DB.Raw(`SELECT bookingno, count(*) n FROM pickupbookings
|
||||
GROUP BY 1 HAVING count(*)>1 ORDER BY n DESC LIMIT 10`).Scan(&ds)
|
||||
fmt.Printf(" %d duplicated\n", len(ds))
|
||||
for _, x := range ds {
|
||||
fmt.Printf(" %s x%d\n", x.Bookingno, x.N)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== all-zero random part (would mean rand.Read failed) ===")
|
||||
var zeros int64
|
||||
db.DB.Raw(`SELECT count(*) FROM pickupbookings WHERE bookingno LIKE 'DM-BK-00000000-%'`).Scan(&zeros)
|
||||
fmt.Printf(" %d\n", zeros)
|
||||
|
||||
fmt.Println("\n=== how many bookings share a timestamp suffix ===")
|
||||
type s struct {
|
||||
Suffix string
|
||||
N int64
|
||||
}
|
||||
var ss []s
|
||||
db.DB.Raw(`SELECT split_part(bookingno,'-',4) suffix, count(*) n
|
||||
FROM pickupbookings GROUP BY 1 ORDER BY n DESC LIMIT 5`).Scan(&ss)
|
||||
for _, x := range ss {
|
||||
fmt.Printf(" suffix %-8s %d bookings\n", x.Suffix, x.N)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== duplicate tracking numbers ===")
|
||||
var td []d
|
||||
db.DB.Raw(`SELECT trackingno bookingno, count(*) n FROM consignments
|
||||
GROUP BY 1 HAVING count(*)>1 LIMIT 5`).Scan(&td)
|
||||
fmt.Printf(" %d duplicated\n", len(td))
|
||||
}
|
||||
115
scratch/check_customer_bookings.go
Normal file
115
scratch/check_customer_bookings.go
Normal file
@@ -0,0 +1,115 @@
|
||||
//go:build ignore
|
||||
|
||||
// Read-only: why a customer-app booking may not reach the console.
|
||||
//
|
||||
// go run scratch/check_customer_bookings.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB connection")
|
||||
}
|
||||
|
||||
fmt.Println("=== 1. How many bookings exist, by source ===")
|
||||
type srcRow struct {
|
||||
Bookingsource string
|
||||
N int64
|
||||
}
|
||||
var srcs []srcRow
|
||||
db.DB.Raw(`SELECT COALESCE(NULLIF(bookingsource,''),'(blank)') AS bookingsource, count(*) AS n
|
||||
FROM pickupbookings GROUP BY 1 ORDER BY n DESC`).Scan(&srcs)
|
||||
var total int64
|
||||
for _, s := range srcs {
|
||||
fmt.Printf(" %-16s %d\n", s.Bookingsource, s.N)
|
||||
total += s.N
|
||||
}
|
||||
fmt.Printf(" %-16s %d\n", "TOTAL", total)
|
||||
|
||||
fmt.Println("\n=== 2. The 10 newest bookings ===")
|
||||
type bRow struct {
|
||||
Bookingid int
|
||||
Bookingno string
|
||||
Bookingsource string
|
||||
Status string
|
||||
Tenantid *int
|
||||
Pickuplatitude float64
|
||||
Pickuppincode string
|
||||
Createdat string
|
||||
}
|
||||
var newest []bRow
|
||||
db.DB.Raw(`SELECT bookingid, bookingno, bookingsource, status, tenantid,
|
||||
pickuplatitude, pickuppincode, createdat::text
|
||||
FROM pickupbookings ORDER BY bookingid DESC LIMIT 10`).Scan(&newest)
|
||||
for _, b := range newest {
|
||||
tenant := "NULL"
|
||||
if b.Tenantid != nil {
|
||||
tenant = fmt.Sprintf("%d", *b.Tenantid)
|
||||
}
|
||||
fmt.Printf(" #%-6d %-14s src=%-14s status=%-24s tenant=%-5s lat=%-10.4f pin=%-7s %s\n",
|
||||
b.Bookingid, b.Bookingno, b.Bookingsource, b.Status, tenant,
|
||||
b.Pickuplatitude, b.Pickuppincode, b.Createdat)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== 3. Customer-app bookings specifically ===")
|
||||
var appBookings []bRow
|
||||
db.DB.Raw(`SELECT bookingid, bookingno, bookingsource, status, tenantid,
|
||||
pickuplatitude, pickuppincode, createdat::text
|
||||
FROM pickupbookings WHERE bookingsource = 'Customer_App'
|
||||
ORDER BY bookingid DESC LIMIT 10`).Scan(&appBookings)
|
||||
if len(appBookings) == 0 {
|
||||
fmt.Println(" none at all")
|
||||
}
|
||||
for _, b := range appBookings {
|
||||
fmt.Printf(" #%-6d %-14s status=%-24s lat=%-10.4f pin=%-7s %s\n",
|
||||
b.Bookingid, b.Bookingno, b.Status, b.Pickuplatitude, b.Pickuppincode, b.Createdat)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== 4. Customer-app bookings with no pickup coordinates ===")
|
||||
var noCoords int64
|
||||
db.DB.Raw(`SELECT count(*) FROM pickupbookings
|
||||
WHERE bookingsource = 'Customer_App'
|
||||
AND (pickuplatitude = 0 OR pickuplongitude = 0
|
||||
OR pickuplatitude IS NULL OR pickuplongitude IS NULL)`).Scan(&noCoords)
|
||||
fmt.Printf(" %d (these cannot be assigned a rider and fail the console's zone filter)\n", noCoords)
|
||||
|
||||
fmt.Println("\n=== 5. What one page of the console's own query returns ===")
|
||||
// The console drains GET /admin/bookings, which runs no ORDER BY. This is
|
||||
// the same shape: LIMIT/OFFSET with no ordering.
|
||||
var page1, page1again []idRow
|
||||
db.DB.Raw(`SELECT bookingid FROM pickupbookings LIMIT 5 OFFSET 0`).Scan(&page1)
|
||||
db.DB.Raw(`SELECT bookingid FROM pickupbookings LIMIT 5 OFFSET 0`).Scan(&page1again)
|
||||
fmt.Printf(" unordered page 1, call A: %v\n", ids(page1))
|
||||
fmt.Printf(" unordered page 1, call B: %v\n", ids(page1again))
|
||||
|
||||
var lastPage []idRow
|
||||
offset := total - 5
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
db.DB.Raw(fmt.Sprintf(`SELECT bookingid FROM pickupbookings LIMIT 5 OFFSET %d`, offset)).Scan(&lastPage)
|
||||
fmt.Printf(" unordered LAST page: %v\n", ids(lastPage))
|
||||
fmt.Println(" (if the newest ids appear only on the last page, a truncated drain never sees them)")
|
||||
}
|
||||
|
||||
type idRow struct{ Bookingid int }
|
||||
|
||||
func ids(rows []idRow) []int {
|
||||
out := make([]int, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.Bookingid)
|
||||
}
|
||||
return out
|
||||
}
|
||||
136
scratch/check_handover_schema.go
Normal file
136
scratch/check_handover_schema.go
Normal file
@@ -0,0 +1,136 @@
|
||||
//go:build ignore
|
||||
|
||||
// Read-only verification for the base-handover work. Reads information_schema
|
||||
// and pg_constraint only — no writes, no DDL, no migrations.
|
||||
//
|
||||
// go run scratch/check_handover_schema.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB connection")
|
||||
}
|
||||
|
||||
fmt.Println("=== 1. New columns (expected ABSENT until AutoMigrate runs) ===")
|
||||
type col struct {
|
||||
Table string
|
||||
Name string
|
||||
}
|
||||
for _, want := range []col{
|
||||
{"pickupbookings", "pickupsourcetype"},
|
||||
{"pickupbookings", "pickuphubid"},
|
||||
{"consignments", "inwardedat"},
|
||||
} {
|
||||
var n int64
|
||||
db.DB.Raw(`SELECT count(*) FROM information_schema.columns
|
||||
WHERE table_name = ? AND column_name = ?`, want.Table, want.Name).Scan(&n)
|
||||
fmt.Printf(" %-16s %-18s present=%v\n", want.Table, want.Name, n > 0)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== 2. CHECK constraints on the tables we write ===")
|
||||
type chk struct {
|
||||
Conname string
|
||||
Def string
|
||||
}
|
||||
var checks []chk
|
||||
db.DB.Raw(`SELECT c.conname, pg_get_constraintdef(c.oid) AS def
|
||||
FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE c.contype = 'c'
|
||||
AND t.relname IN ('consignments','consignmentexceptions','consignmenthistory',
|
||||
'pickupbookings','bookingassignments','milerprofiles')
|
||||
ORDER BY t.relname, c.conname`).Scan(&checks)
|
||||
for _, c := range checks {
|
||||
fmt.Printf(" %s\n %s\n", c.Conname, c.Def)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== 3. Foreign keys on the user/id columns we write ===")
|
||||
type fk struct {
|
||||
Table string
|
||||
Conname string
|
||||
Def string
|
||||
}
|
||||
var fks []fk
|
||||
db.DB.Raw(`SELECT t.relname AS table, c.conname, pg_get_constraintdef(c.oid) AS def
|
||||
FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE c.contype = 'f'
|
||||
AND t.relname IN ('consignmentexceptions','consignmenthistory','consignments',
|
||||
'tripsheets','bookingassignments','pickupbookings')
|
||||
ORDER BY t.relname, c.conname`).Scan(&fks)
|
||||
if len(fks) == 0 {
|
||||
fmt.Println(" (none)")
|
||||
}
|
||||
for _, f := range fks {
|
||||
fmt.Printf(" %-24s %s\n", f.Table, f.Def)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== 4. NOT NULL columns on the tables we insert into ===")
|
||||
type nn struct {
|
||||
Table string
|
||||
Column string
|
||||
Def *string
|
||||
}
|
||||
var nns []nn
|
||||
db.DB.Raw(`SELECT table_name AS table, column_name AS column, column_default AS def
|
||||
FROM information_schema.columns
|
||||
WHERE table_name IN ('consignmentexceptions','consignmenthistory')
|
||||
AND is_nullable = 'NO'
|
||||
ORDER BY table_name, ordinal_position`).Scan(&nns)
|
||||
for _, c := range nns {
|
||||
d := "(no default)"
|
||||
if c.Def != nil {
|
||||
d = *c.Def
|
||||
}
|
||||
fmt.Printf(" %-24s %-20s %s\n", c.Table, c.Column, d)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== 5. Hub master data completeness (request 29) ===")
|
||||
type hubRow struct {
|
||||
Total int64
|
||||
NoAddress int64
|
||||
NoPincode int64
|
||||
NoCoords int64
|
||||
ActiveTotal int64
|
||||
}
|
||||
var h hubRow
|
||||
db.DB.Raw(`SELECT count(*) AS total,
|
||||
count(*) FILTER (WHERE address IS NULL OR address = '') AS no_address,
|
||||
count(*) FILTER (WHERE pincode IS NULL OR pincode = '') AS no_pincode,
|
||||
count(*) FILTER (WHERE latitude IS NULL OR latitude = 0 OR longitude IS NULL OR longitude = 0) AS no_coords,
|
||||
count(*) FILTER (WHERE status = 'Active') AS active_total
|
||||
FROM hubs WHERE deletedat IS NULL`).Scan(&h)
|
||||
fmt.Printf(" hubs=%d active=%d missing_address=%d missing_pincode=%d missing_coords=%d\n",
|
||||
h.Total, h.ActiveTotal, h.NoAddress, h.NoPincode, h.NoCoords)
|
||||
|
||||
fmt.Println("\n=== 6. Consignment status distribution (what is live now) ===")
|
||||
type sc struct {
|
||||
Status string
|
||||
N int64
|
||||
}
|
||||
var scs []sc
|
||||
db.DB.Raw(`SELECT status, count(*) AS n FROM consignments
|
||||
WHERE deletedat IS NULL GROUP BY status ORDER BY n DESC`).Scan(&scs)
|
||||
for _, s := range scs {
|
||||
fmt.Printf(" %-22s %d\n", s.Status, s.N)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== 7. Open assignments on already-converted bookings (the off-duty bug) ===")
|
||||
var stuck int64
|
||||
db.DB.Raw(`SELECT count(*) FROM bookingassignments a
|
||||
JOIN pickupbookings b ON b.bookingid = a.bookingid
|
||||
WHERE a.assignmentstatus IN ('Assigned','Accepted')
|
||||
AND b.status = 'Converted_To_Consignment'`).Scan(&stuck)
|
||||
fmt.Printf(" assignments still open on a converted booking: %d\n", stuck)
|
||||
}
|
||||
62
scratch/check_latest.go
Normal file
62
scratch/check_latest.go
Normal file
@@ -0,0 +1,62 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB")
|
||||
}
|
||||
type b struct {
|
||||
Bookingid int
|
||||
Bookingno string
|
||||
Bookingsource string
|
||||
Status string
|
||||
Tenantid *int
|
||||
Appcustomerid int
|
||||
Pickuppincode string
|
||||
Pickuplatitude float64
|
||||
Createdat string
|
||||
}
|
||||
var rows []b
|
||||
db.DB.Raw(`SELECT bookingid,bookingno,bookingsource,status,tenantid,appcustomerid,
|
||||
pickuppincode,pickuplatitude,createdat::text
|
||||
FROM pickupbookings ORDER BY bookingid DESC LIMIT 12`).Scan(&rows)
|
||||
fmt.Println("=== 12 newest bookings (any source) ===")
|
||||
for _, r := range rows {
|
||||
tn := "NULL"
|
||||
if r.Tenantid != nil {
|
||||
tn = fmt.Sprint(*r.Tenantid)
|
||||
}
|
||||
fmt.Printf(" #%-5d src=%-13q status=%-24s tenant=%-5s cust=%-4d pin=%-7s lat=%.4f %s\n",
|
||||
r.Bookingid, r.Bookingsource, r.Status, tn, r.Appcustomerid,
|
||||
r.Pickuppincode, r.Pickuplatitude, r.Createdat)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== exact distinct bookingsource values (byte-for-byte) ===")
|
||||
type s struct {
|
||||
V string
|
||||
N int64
|
||||
}
|
||||
var ss []s
|
||||
db.DB.Raw(`SELECT '['||bookingsource||']' v, count(*) n FROM pickupbookings GROUP BY 1 ORDER BY n DESC`).Scan(&ss)
|
||||
for _, x := range ss {
|
||||
fmt.Printf(" %-20s %d\n", x.V, x.N)
|
||||
}
|
||||
|
||||
var appTotal int64
|
||||
db.DB.Raw(`SELECT count(*) FROM pickupbookings WHERE bookingsource='Customer_App'`).Scan(&appTotal)
|
||||
fmt.Printf("\nCustomer_App bookings the console page should list: %d\n", appTotal)
|
||||
}
|
||||
37
scratch/check_miler23.go
Normal file
37
scratch/check_miler23.go
Normal file
@@ -0,0 +1,37 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB")
|
||||
}
|
||||
var avail string
|
||||
db.DB.Raw(`SELECT availabilitystatus FROM milerprofiles WHERE userid = 23`).Scan(&avail)
|
||||
fmt.Printf("miler 23 availability: %s\n", avail)
|
||||
|
||||
var open int64
|
||||
db.DB.Raw(`SELECT count(*) FROM bookingassignments ba
|
||||
JOIN pickupbookings b ON b.bookingid = ba.bookingid
|
||||
WHERE ba.mileruserid = 23 AND ba.assignmentstatus IN ('Assigned','Accepted')`).Scan(&open)
|
||||
fmt.Printf("miler 23 open assignments: %d\n", open)
|
||||
|
||||
var onCancelled int64
|
||||
db.DB.Raw(`SELECT count(*) FROM bookingassignments ba
|
||||
JOIN pickupbookings b ON b.bookingid = ba.bookingid
|
||||
WHERE ba.assignmentstatus IN ('Assigned','Accepted') AND b.status = 'Cancelled'`).Scan(&onCancelled)
|
||||
fmt.Printf("\nACROSS ALL RIDERS — open assignments on a CANCELLED booking: %d\n", onCancelled)
|
||||
}
|
||||
38
scratch/check_phone.go
Normal file
38
scratch/check_phone.go
Normal file
@@ -0,0 +1,38 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB")
|
||||
}
|
||||
type c struct {
|
||||
Appcustomerid int
|
||||
Firstname string
|
||||
Phone string
|
||||
Configid int
|
||||
Status string
|
||||
}
|
||||
var rows []c
|
||||
db.DB.Raw(`SELECT appcustomerid,firstname,phone,configid,status FROM appcustomers
|
||||
WHERE phone IN ('9876543210') OR appcustomerid = 5`).Scan(&rows)
|
||||
if len(rows) == 0 {
|
||||
fmt.Println("no appcustomer with phone 9876543210, and no id 5")
|
||||
}
|
||||
for _, r := range rows {
|
||||
fmt.Printf(" id=%d name=%q phone=%s configid=%d status=%s\n",
|
||||
r.Appcustomerid, r.Firstname, r.Phone, r.Configid, r.Status)
|
||||
}
|
||||
}
|
||||
80
scratch/check_two_bookings.go
Normal file
80
scratch/check_two_bookings.go
Normal file
@@ -0,0 +1,80 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
cfg := config.Load()
|
||||
db.Connect(cfg)
|
||||
if db.DB == nil {
|
||||
log.Fatal("no DB")
|
||||
}
|
||||
|
||||
type b struct {
|
||||
Bookingid int
|
||||
Bookingno string
|
||||
Status string
|
||||
Createdat string
|
||||
Updatedat string
|
||||
Assignedmileruserid *int
|
||||
Pickuplatitude float64
|
||||
Pickuplongitude float64
|
||||
Pickuppincode string
|
||||
Deliverypincode string
|
||||
Deliveryaddress string
|
||||
Appcustomerid int
|
||||
}
|
||||
var rows []b
|
||||
db.DB.Raw(`SELECT bookingid, bookingno, status, createdat::text, updatedat::text,
|
||||
assignedmileruserid, pickuplatitude, pickuplongitude, pickuppincode,
|
||||
deliverypincode, deliveryaddress, appcustomerid
|
||||
FROM pickupbookings WHERE bookingid IN (546,547,179) ORDER BY bookingid DESC`).Scan(&rows)
|
||||
for _, r := range rows {
|
||||
miler := "none"
|
||||
if r.Assignedmileruserid != nil {
|
||||
miler = fmt.Sprintf("%d", *r.Assignedmileruserid)
|
||||
}
|
||||
fmt.Printf("#%d %s\n status=%s created=%s\n updated=%s miler=%s customer=%d\n pickup=(%.4f,%.4f) %s -> %s drop=%q\n\n",
|
||||
r.Bookingid, r.Bookingno, r.Status, r.Createdat, r.Updatedat, miler,
|
||||
r.Appcustomerid, r.Pickuplatitude, r.Pickuplongitude, r.Pickuppincode,
|
||||
r.Deliverypincode, r.Deliveryaddress)
|
||||
}
|
||||
|
||||
fmt.Println("=== assignments on those bookings ===")
|
||||
type a struct {
|
||||
Bookingid int
|
||||
Assignmentstatus string
|
||||
Assignedat string
|
||||
Remarks string
|
||||
}
|
||||
var as []a
|
||||
db.DB.Raw(`SELECT bookingid, assignmentstatus, assignedat::text, remarks
|
||||
FROM bookingassignments WHERE bookingid IN (546,547,179)`).Scan(&as)
|
||||
if len(as) == 0 {
|
||||
fmt.Println(" none — no rider was ever assigned")
|
||||
}
|
||||
for _, x := range as {
|
||||
fmt.Printf(" booking %d: %s at %s %q\n", x.Bookingid, x.Assignmentstatus, x.Assignedat, x.Remarks)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== how many bookings sit in each status ===")
|
||||
type s struct {
|
||||
Status string
|
||||
N int64
|
||||
}
|
||||
var ss []s
|
||||
db.DB.Raw(`SELECT status, count(*) n FROM pickupbookings GROUP BY 1 ORDER BY n DESC`).Scan(&ss)
|
||||
for _, x := range ss {
|
||||
fmt.Printf(" %-26s %d\n", x.Status, x.N)
|
||||
}
|
||||
}
|
||||
174
scratch/cx_readonly_probe.go
Normal file
174
scratch/cx_readonly_probe.go
Normal file
@@ -0,0 +1,174 @@
|
||||
//go:build ignore
|
||||
|
||||
// Read-only probe of the live `logistics` database for the customer-app work.
|
||||
//
|
||||
// STRICTLY READ-ONLY. It runs SELECTs against information_schema and a few
|
||||
// COUNTs, and nothing else — no DDL, no INSERT, no UPDATE. It exists to answer
|
||||
// one question before anybody boots the service against this database:
|
||||
//
|
||||
// is migrations.Migrate() actually additive here, or would it collide with
|
||||
// something that already exists?
|
||||
//
|
||||
// main.go calls Migrate() on startup, so "just run the server and see" is a
|
||||
// schema change to a database the Miler app and both consoles are live against.
|
||||
// This is the safe half of that check.
|
||||
//
|
||||
// go run scratch/cx_readonly_probe.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// The tables migrations.Migrate() would create for the customer app.
|
||||
var newTables = []string{
|
||||
"serviceablestates",
|
||||
"serviceabledistricts",
|
||||
"pickupslottemplates",
|
||||
"customerbookinglimits",
|
||||
"bookingdestinations",
|
||||
"bookingparcelphotos",
|
||||
"bookingstageevents",
|
||||
"customerrefreshtokens",
|
||||
"customerdevices",
|
||||
}
|
||||
|
||||
// Columns it would add to existing tables. These are the ones that touch rows
|
||||
// the live system already reads.
|
||||
var newColumns = map[string][]string{
|
||||
"pickupbookings": {
|
||||
"slotid", "customerstage", "customerstatus",
|
||||
"estimateminrupees", "estimatemaxrupees", "routekm",
|
||||
"pickuptitle", "pickupsub", "cancelreason",
|
||||
},
|
||||
"bookingparcels": {"bookingdestinationid"},
|
||||
}
|
||||
|
||||
var newSequences = []string{"cx_booking_reference_seq", "cx_tracking_seq"}
|
||||
|
||||
func main() {
|
||||
// Credentials come from the environment only. The other probes in this
|
||||
// folder hardcode the production password; that is one more copy of a live
|
||||
// credential in the repository than there needs to be, and this file does
|
||||
// not add another.
|
||||
//
|
||||
// set -a && . ./.env && set +a && go run scratch/cx_readonly_probe.go
|
||||
host := env("DB_HOST", "")
|
||||
port := env("DB_PORT", "5433")
|
||||
user := env("DB_USER", "")
|
||||
pass := env("DB_PASSWORD", "")
|
||||
name := env("DB_NAME", "logistics")
|
||||
|
||||
if host == "" || user == "" || pass == "" {
|
||||
log.Fatal("set DB_HOST, DB_USER and DB_PASSWORD first — " +
|
||||
"e.g. `set -a && . ./.env && set +a`")
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
|
||||
host, user, pass, name, port)
|
||||
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Fatalf("ping: %v", err)
|
||||
}
|
||||
fmt.Printf("connected read-only to %s@%s:%s/%s\n\n", user, host, port, name)
|
||||
|
||||
var total int
|
||||
must(db.QueryRow(`SELECT count(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'public'`).Scan(&total))
|
||||
fmt.Printf("existing public tables: %d\n\n", total)
|
||||
|
||||
fmt.Println("── tables the migration would CREATE ──")
|
||||
existing := 0
|
||||
for _, t := range newTables {
|
||||
var n int
|
||||
must(db.QueryRow(`SELECT count(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = $1`, t).Scan(&n))
|
||||
if n > 0 {
|
||||
existing++
|
||||
var rows int
|
||||
if err := db.QueryRow(fmt.Sprintf(`SELECT count(*) FROM %q`, t)).Scan(&rows); err != nil {
|
||||
rows = -1
|
||||
}
|
||||
fmt.Printf(" %-24s ALREADY EXISTS (%d rows)\n", t, rows)
|
||||
} else {
|
||||
fmt.Printf(" %-24s absent — would be created\n", t)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n── columns the migration would ADD to live tables ──")
|
||||
for table, cols := range newColumns {
|
||||
var tableRows int
|
||||
if err := db.QueryRow(fmt.Sprintf(`SELECT count(*) FROM %q`, table)).Scan(&tableRows); err != nil {
|
||||
fmt.Printf(" %s: could not count rows: %v\n", table, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" %s (%d rows):\n", table, tableRows)
|
||||
for _, col := range cols {
|
||||
var n int
|
||||
must(db.QueryRow(`SELECT count(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2`,
|
||||
table, col).Scan(&n))
|
||||
state := "absent — would be added"
|
||||
if n > 0 {
|
||||
state = "ALREADY EXISTS"
|
||||
}
|
||||
fmt.Printf(" %-24s %s\n", col, state)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n── sequences ──")
|
||||
for _, s := range newSequences {
|
||||
var n int
|
||||
must(db.QueryRow(`SELECT count(*) FROM information_schema.sequences
|
||||
WHERE sequence_schema = 'public' AND sequence_name = $1`, s).Scan(&n))
|
||||
state := "absent — would be created"
|
||||
if n > 0 {
|
||||
state = "ALREADY EXISTS"
|
||||
}
|
||||
fmt.Printf(" %-30s %s\n", s, state)
|
||||
}
|
||||
|
||||
// Anything that would make the migration NOT additive: a name collision on
|
||||
// a table that is not ours, or a column of an incompatible type.
|
||||
fmt.Println("\n── collision check ──")
|
||||
if existing == 0 {
|
||||
fmt.Println(" none — every customer-app table is new to this database")
|
||||
} else {
|
||||
fmt.Printf(" %d of %d target tables already exist; inspect them before migrating\n",
|
||||
existing, len(newTables))
|
||||
}
|
||||
|
||||
// How much live data the additive columns would land on.
|
||||
var bookings, consignments int
|
||||
_ = db.QueryRow(`SELECT count(*) FROM pickupbookings`).Scan(&bookings)
|
||||
_ = db.QueryRow(`SELECT count(*) FROM consignments`).Scan(&consignments)
|
||||
fmt.Printf("\nlive rows that would gain nullable columns: pickupbookings=%d consignments=%d\n",
|
||||
bookings, consignments)
|
||||
|
||||
fmt.Println("\nNOTHING WAS WRITTEN. This probe issues SELECTs only.")
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
log.Fatalf("query: %v", err)
|
||||
}
|
||||
}
|
||||
131
scratch/cx_tenant_probe.go
Normal file
131
scratch/cx_tenant_probe.go
Normal file
@@ -0,0 +1,131 @@
|
||||
//go:build ignore
|
||||
|
||||
// Read-only probe: why a customer-app booking may not reach the console.
|
||||
//
|
||||
// STRICTLY READ-ONLY. SELECTs and COUNTs only — no DDL, no INSERT, no UPDATE,
|
||||
// no DELETE. It answers three questions before anything is changed:
|
||||
//
|
||||
// 1. Do customer-app bookings exist, and what tenantid do they carry?
|
||||
// 2. Which tenants exist, and which are Doormile's own operational ones?
|
||||
// 3. Which console logins are tenant-scoped, and would therefore be unable
|
||||
// to see a booking whose tenantid is NULL?
|
||||
//
|
||||
// go run scratch/cx_tenant_probe.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
env("DB_HOST", "127.0.0.1"), env("DB_PORT", "5433"),
|
||||
env("DB_USER", "admin"), env("DB_PASSWORD", ""), env("DB_NAME", "logistics"),
|
||||
)
|
||||
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
fmt.Println("open:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err != nil {
|
||||
fmt.Println("ping:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
section("1. Bookings by source and tenant attribution")
|
||||
rows(db, `
|
||||
SELECT COALESCE(bookingsource,'(null)') AS source,
|
||||
CASE WHEN tenantid IS NULL THEN 'NULL' ELSE 'set' END AS tenant,
|
||||
COUNT(*) AS n,
|
||||
MAX(bookingid) AS newest_id
|
||||
FROM pickupbookings
|
||||
GROUP BY 1,2
|
||||
ORDER BY 1,2`)
|
||||
|
||||
section("2. The 5 newest customer-app bookings")
|
||||
rows(db, `
|
||||
SELECT bookingid, bookingno, COALESCE(tenantid::text,'NULL') AS tenantid,
|
||||
status, COALESCE(customerstatus,'') AS customerstatus, createdat
|
||||
FROM pickupbookings
|
||||
WHERE bookingsource = 'Customer_App'
|
||||
ORDER BY bookingid DESC
|
||||
LIMIT 5`)
|
||||
|
||||
section("3. Tenants")
|
||||
rows(db, `SELECT tenantid, tenantname, status FROM tenants ORDER BY tenantid`)
|
||||
|
||||
section("4. Console logins and their tenant scope (NULL = Doormile staff, sees everything)")
|
||||
rows(db, `
|
||||
SELECT email, role, COALESCE(tenantid::text,'NULL (unscoped)') AS tenant_scope
|
||||
FROM doormile_auth
|
||||
ORDER BY tenantid NULLS FIRST, email`)
|
||||
|
||||
section("5. Total bookings (does the console's page budget still truncate?)")
|
||||
rows(db, `SELECT COUNT(*) AS total_bookings FROM pickupbookings`)
|
||||
}
|
||||
|
||||
func section(title string) { fmt.Printf("\n===== %s =====\n", title) }
|
||||
|
||||
func rows(db *sql.DB, query string) {
|
||||
rs, err := db.Query(query)
|
||||
if err != nil {
|
||||
fmt.Println(" query failed:", err)
|
||||
return
|
||||
}
|
||||
defer rs.Close()
|
||||
|
||||
cols, _ := rs.Columns()
|
||||
fmt.Println(" " + join(cols, " | "))
|
||||
|
||||
for rs.Next() {
|
||||
vals := make([]interface{}, len(cols))
|
||||
ptrs := make([]interface{}, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rs.Scan(ptrs...); err != nil {
|
||||
fmt.Println(" scan:", err)
|
||||
return
|
||||
}
|
||||
out := make([]string, len(cols))
|
||||
for i, v := range vals {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
out[i] = "NULL"
|
||||
case []byte:
|
||||
out[i] = string(t)
|
||||
default:
|
||||
out[i] = fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
fmt.Println(" " + join(out, " | "))
|
||||
}
|
||||
}
|
||||
|
||||
func join(parts []string, sep string) string {
|
||||
s := ""
|
||||
for i, p := range parts {
|
||||
if i > 0 {
|
||||
s += sep
|
||||
}
|
||||
s += p
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func env(k, fallback string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
170
seed_customer_app.sql
Normal file
170
seed_customer_app.sql
Normal file
@@ -0,0 +1,170 @@
|
||||
-- ============================================================
|
||||
-- Doormile — customer app (doormile_cx) seed data
|
||||
-- Idempotent: safe to re-run (ON CONFLICT DO UPDATE throughout)
|
||||
--
|
||||
-- Seeded to mirror the client's in-app mock exactly, because those cases back
|
||||
-- the app's 18 widget tests and its design QA:
|
||||
-- * Tamil Nadu / Kerala / Karnataka open
|
||||
-- * Puducherry serviceable with NO open district (the "opening soon" state)
|
||||
-- * Madurai / Kozhikode / Mangaluru unavailable, each with a reason
|
||||
-- * At least one fully-booked pickup slot (t5, capacity 0)
|
||||
-- Change any of those and a green test suite stops meaning anything.
|
||||
--
|
||||
-- Run AFTER the Go service has started once, so AutoMigrate has created the
|
||||
-- tables this file fills.
|
||||
-- ============================================================
|
||||
|
||||
-- ── Serviceable states ───────────────────────────────────────────────────────
|
||||
-- districtCount is computed at read time from the districts below, so a state
|
||||
-- listed here with nothing open still returns and carries its transit tag. The
|
||||
-- app hides a state showing 0 rather than the server omitting it.
|
||||
|
||||
INSERT INTO serviceablestates (statecode, statename, transittag, displayorder, status, createdat, updatedat)
|
||||
VALUES
|
||||
('TN', 'Tamil Nadu', 'Ultra-fast transit', 1, 'Active', NOW(), NOW()),
|
||||
('KL', 'Kerala', 'Next-day transit', 2, 'Active', NOW(), NOW()),
|
||||
('KA', 'Karnataka', 'Next-day transit', 3, 'Active', NOW(), NOW()),
|
||||
('TG', 'Telangana', 'Next-day transit', 4, 'Active', NOW(), NOW()),
|
||||
('PY', 'Puducherry', 'Opening soon', 5, 'Active', NOW(), NOW())
|
||||
ON CONFLICT (statecode) DO UPDATE
|
||||
SET statename = EXCLUDED.statename,
|
||||
transittag = EXCLUDED.transittag,
|
||||
displayorder = EXCLUDED.displayorder,
|
||||
status = EXCLUDED.status,
|
||||
updatedat = NOW();
|
||||
|
||||
-- ── Serviceable districts ────────────────────────────────────────────────────
|
||||
-- Unavailable districts are seeded too, and returned by the API. The picker
|
||||
-- filters them out but the app shows their names in a quiet "coming soon" line,
|
||||
-- so deleting them would remove real copy from the screen. `note` is why —
|
||||
-- never left blank on an unavailable row, or the app has to invent a reason.
|
||||
--
|
||||
-- centrelatitude/longitude matter more than they look: only state and district
|
||||
-- are required at booking time, so for most destinations these coordinates are
|
||||
-- the ONLY geography the parcel has until the miler corrects it at the door.
|
||||
-- They price the estimate and they keep the stop out of the 0,0 bucket that
|
||||
-- route sequencing skips.
|
||||
|
||||
INSERT INTO serviceabledistricts
|
||||
(districtcode, statecode, districtname, available, note, promise,
|
||||
centrelatitude, centrelongitude, pincodeprefix, displayorder, createdat, updatedat)
|
||||
VALUES
|
||||
-- Tamil Nadu
|
||||
('TN-CBE', 'TN', 'Coimbatore', TRUE, '', 'Next-day delivery', 11.016845, 76.955832, '641', 1, NOW(), NOW()),
|
||||
('TN-MAA', 'TN', 'Chennai', TRUE, '', 'Next-day delivery', 13.082680, 80.270718, '600', 2, NOW(), NOW()),
|
||||
('TN-SLM', 'TN', 'Salem', TRUE, '', '2-day delivery', 11.664325, 78.146011, '636', 3, NOW(), NOW()),
|
||||
('TN-TRY', 'TN', 'Tiruchirappalli', TRUE, '', '2-day delivery', 10.790483, 78.704674, '620', 4, NOW(), NOW()),
|
||||
('TN-ERD', 'TN', 'Erode', TRUE, '', '2-day delivery', 11.341000, 77.717300, '638', 5, NOW(), NOW()),
|
||||
('TN-KNK', 'TN', 'Kanyakumari', TRUE, '', '2-day delivery', 8.178200, 77.435400, '629', 6, NOW(), NOW()),
|
||||
('TN-MDU', 'TN', 'Madurai', FALSE, 'Opening soon', '', 9.925201, 78.119775, '625', 7, NOW(), NOW()),
|
||||
|
||||
-- Kerala
|
||||
('KL-EKM', 'KL', 'Ernakulam', TRUE, '', 'Next-day delivery', 9.981636, 76.299881, '682', 1, NOW(), NOW()),
|
||||
('KL-TVM', 'KL', 'Thiruvananthapuram', TRUE, '', '2-day delivery', 8.524139, 76.936638, '695', 2, NOW(), NOW()),
|
||||
('KL-TSR', 'KL', 'Thrissur', TRUE, '', '2-day delivery', 10.527640, 76.214402, '680', 3, NOW(), NOW()),
|
||||
('KL-KKD', 'KL', 'Kozhikode', FALSE, 'Opening soon', '', 11.258753, 75.780411, '673', 4, NOW(), NOW()),
|
||||
|
||||
-- Karnataka
|
||||
('KA-BLR', 'KA', 'Bengaluru Urban', TRUE, '', 'Next-day delivery', 12.971599, 77.594566, '560', 1, NOW(), NOW()),
|
||||
('KA-MYS', 'KA', 'Mysuru', TRUE, '', '2-day delivery', 12.295810, 76.639381, '570', 2, NOW(), NOW()),
|
||||
('KA-MNG', 'KA', 'Dakshina Kannada', FALSE, 'Paused this week', '', 12.914142, 74.856000, '575', 3, NOW(), NOW()),
|
||||
|
||||
-- Telangana
|
||||
('TG-HYD', 'TG', 'Hyderabad', TRUE, '', 'Next-day delivery', 17.385044, 78.486671, '500', 1, NOW(), NOW()),
|
||||
|
||||
-- Puducherry: serviceable state, no open district. This is the "Opening
|
||||
-- soon" state the app has a designed screen for, and the only place that
|
||||
-- screen can be exercised.
|
||||
('PY-PDY', 'PY', 'Puducherry', FALSE, 'Opening soon', '', 11.913860, 79.812600, '605', 1, NOW(), NOW())
|
||||
ON CONFLICT (districtcode) DO UPDATE
|
||||
SET statecode = EXCLUDED.statecode,
|
||||
districtname = EXCLUDED.districtname,
|
||||
available = EXCLUDED.available,
|
||||
note = EXCLUDED.note,
|
||||
promise = EXCLUDED.promise,
|
||||
centrelatitude = EXCLUDED.centrelatitude,
|
||||
centrelongitude = EXCLUDED.centrelongitude,
|
||||
pincodeprefix = EXCLUDED.pincodeprefix,
|
||||
displayorder = EXCLUDED.displayorder,
|
||||
updatedat = NOW();
|
||||
|
||||
-- Attach each district to the nearest active hub, so the destination card can
|
||||
-- name a serving base. Done by proximity rather than hard-coded ids because hub
|
||||
-- ids differ between environments and a wrong id is worse than a missing name.
|
||||
UPDATE serviceabledistricts d
|
||||
SET hubid = nearest.hubid
|
||||
FROM LATERAL (
|
||||
SELECT h.hubid
|
||||
FROM hubs h
|
||||
WHERE h.status = 'Active'
|
||||
AND h.deletedat IS NULL
|
||||
AND h.latitude <> 0
|
||||
ORDER BY (h.latitude - d.centrelatitude) ^ 2
|
||||
+ (h.longitude - d.centrelongitude) ^ 2
|
||||
LIMIT 1
|
||||
) AS nearest
|
||||
WHERE d.hubid IS NULL;
|
||||
|
||||
-- ── Pickup slot templates ────────────────────────────────────────────────────
|
||||
-- Roughly six windows a day, which is what the design lays out. The customer
|
||||
-- never sees these rows: the API expands them onto today and tomorrow and mints
|
||||
-- a dated slot id, so a slot the customer picks always resolves back to a real
|
||||
-- preferredpickupfrom/to that the assignment engine can staff.
|
||||
--
|
||||
-- t5 is seeded at capacity 0 on purpose — it is the ONLY way to reach the
|
||||
-- "Fully booked" state in design QA without actually filling a window.
|
||||
|
||||
INSERT INTO pickupslottemplates
|
||||
(code, starthour, startminute, endhour, endminute, capacity, applocationid,
|
||||
tag, caption, displayorder, status, createdat, updatedat)
|
||||
VALUES
|
||||
('t1', 8, 0, 10, 0, 25, NULL, 'Fastest pickup', 'Arriving in approx. 45 mins', 1, 'Active', NOW(), NOW()),
|
||||
('t2', 10, 0, 12, 0, 25, NULL, '', '', 2, 'Active', NOW(), NOW()),
|
||||
('t3', 12, 0, 14, 0, 25, NULL, '', '', 3, 'Active', NOW(), NOW()),
|
||||
('t4', 14, 0, 16, 0, 25, NULL, '', '', 4, 'Active', NOW(), NOW()),
|
||||
('t5', 16, 0, 18, 0, 0, NULL, '', '', 5, 'Active', NOW(), NOW()),
|
||||
('t6', 18, 0, 20, 0, 20, NULL, '', '', 6, 'Active', NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Booking limits ───────────────────────────────────────────────────────────
|
||||
-- The global default row. Ops varies these per city by inserting a row with an
|
||||
-- applocationid; the client keeps 20/5 if the call fails, and the server never
|
||||
-- answers 0 for either — a zero cap would reject every booking on the platform.
|
||||
|
||||
-- maxdestinations is seeded at 1 ON PURPOSE, not at the 5 the design allows.
|
||||
--
|
||||
-- The fan-out works server-side, but the deployed rider app keys its stop list
|
||||
-- on `orderid`, which is booking-level — so all three stops of a
|
||||
-- three-destination pickup collapse into one in its local store, and two
|
||||
-- parcels would be left with no stop and no way to close them. Accepting such a
|
||||
-- booking would create work nobody can complete.
|
||||
--
|
||||
-- Because the client reads this value from GET /customer/config/booking-limits
|
||||
-- and adapts, capping it here keeps every pickup single-destination with no
|
||||
-- feature flag and no app release. Raise it to 5 with a single UPDATE once a
|
||||
-- rider build that keys on `consignmentid` is live:
|
||||
--
|
||||
-- UPDATE customerbookinglimits SET maxdestinations = 5 WHERE applocationid IS NULL;
|
||||
--
|
||||
-- Same discipline as MILER_HUB_HANDOVER_ENABLED: never turn on a server
|
||||
-- behaviour the deployed rider app cannot finish.
|
||||
INSERT INTO customerbookinglimits (applocationid, maxpackages, maxdestinations, createdat, updatedat)
|
||||
SELECT NULL, 20, 1, NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM customerbookinglimits WHERE applocationid IS NULL);
|
||||
|
||||
-- ── Test account ─────────────────────────────────────────────────────────────
|
||||
-- Automated tests and design QA cannot receive a real text, and the previous
|
||||
-- end-to-end attempt on this platform stalled for exactly that reason: customer
|
||||
-- login needed an OTP on a real handset and could not be scripted.
|
||||
--
|
||||
-- Pair this row with CX_STAGING_OTP=1234 in the staging environment. That env
|
||||
-- var is refused outright when ENV=production (internal/sms), because a fixed
|
||||
-- code is a skeleton key for every account on the platform.
|
||||
|
||||
INSERT INTO appcustomers (firstname, lastname, phone, email, status, configid, createdat, updatedat)
|
||||
SELECT 'Doormile', 'QA', '+919999900001', 'qa@doormile.com', 'Active', 1001, NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM appcustomers WHERE phone = '+919999900001');
|
||||
|
||||
INSERT INTO appcustomers (firstname, lastname, phone, email, status, configid, createdat, updatedat)
|
||||
SELECT 'Design', 'QA', '+919999900002', 'design.qa@doormile.com', 'Active', 1001, NOW(), NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM appcustomers WHERE phone = '+919999900002');
|
||||
103
utils/epoch.go
Normal file
103
utils/epoch.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Timestamps on the customer surface.
|
||||
//
|
||||
// The requirement is epoch milliseconds, UTC, integer — a real instant, not a
|
||||
// wall clock. That is not what this database hands back. The DSN sets
|
||||
// TimeZone=Asia/Kolkata and the timestamp columns hold IST wall-clock digits
|
||||
// (see DBNow), so a stored value read into a time.Time carries the right
|
||||
// digits with the wrong (or no) zone. Calling UnixMilli on it directly is off
|
||||
// by 5h30m — the same class of defect as sending a naive local string with a Z
|
||||
// on it, which has already produced "yesterday's work shown as today" on the
|
||||
// miler app. So every timestamp leaving /customer/* goes through here.
|
||||
//
|
||||
// Reinterpreting the wall clock in IST is correct for both shapes this
|
||||
// database produces: a value tagged UTC that actually holds IST digits, and a
|
||||
// value correctly tagged +05:30. Both name the same instant afterwards.
|
||||
|
||||
// istLocation is Asia/Kolkata, with an exact fixed-offset fallback for
|
||||
// containers shipped without tzdata. IST observes no DST, so +05:30 is not an
|
||||
// approximation.
|
||||
var istLocation = func() *time.Location {
|
||||
if loc, err := time.LoadLocation("Asia/Kolkata"); err == nil {
|
||||
return loc
|
||||
}
|
||||
return time.FixedZone("IST", 5*3600+30*60)
|
||||
}()
|
||||
|
||||
// IST reinterprets a database timestamp's wall clock as Indian Standard Time,
|
||||
// yielding the instant it actually names.
|
||||
func IST(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), istLocation)
|
||||
}
|
||||
|
||||
// EpochMillis converts a database timestamp to UTC epoch milliseconds.
|
||||
func EpochMillis(t time.Time) int64 {
|
||||
return IST(t).UnixMilli()
|
||||
}
|
||||
|
||||
// EpochMillisPtr is EpochMillis over a nullable column. A nil timestamp stays
|
||||
// null in JSON rather than becoming the epoch, which the client would render
|
||||
// as 1 Jan 1970.
|
||||
func EpochMillisPtr(t *time.Time) *int64 {
|
||||
if t == nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
ms := EpochMillis(*t)
|
||||
return &ms
|
||||
}
|
||||
|
||||
// ISTNow is the current moment in IST, for formatting and for comparing
|
||||
// against a value already put through IST().
|
||||
func ISTNow() time.Time {
|
||||
return time.Now().In(istLocation)
|
||||
}
|
||||
|
||||
// ISTLocation exposes the zone for callers building their own times.
|
||||
func ISTLocation() *time.Location { return istLocation }
|
||||
|
||||
// FormatISTDate renders a date the way the customer app shows it: "Thu, 12 Sep".
|
||||
// Display strings are formatted server-side, in IST, so the client never has to
|
||||
// know the operating timezone.
|
||||
func FormatISTDate(t time.Time) string {
|
||||
return IST(t).Format("Mon, 2 Jan")
|
||||
}
|
||||
|
||||
// FormatISTDay renders a date relative to today where that reads better —
|
||||
// "Today", "Tomorrow", otherwise "Mon, 8 Sep". Used for pickup slot days.
|
||||
func FormatISTDay(t time.Time) string {
|
||||
d := IST(t)
|
||||
today := ISTNow()
|
||||
y1, m1, d1 := d.Date()
|
||||
y2, m2, d2 := today.Date()
|
||||
switch {
|
||||
case y1 == y2 && m1 == m2 && d1 == d2:
|
||||
return "Today"
|
||||
case y1 == y2 && m1 == m2 && d1 == d2+1:
|
||||
return "Tomorrow"
|
||||
default:
|
||||
tomorrow := today.AddDate(0, 0, 1)
|
||||
y3, m3, d3 := tomorrow.Date()
|
||||
if y1 == y3 && m1 == m3 && d1 == d3 {
|
||||
return "Tomorrow"
|
||||
}
|
||||
return d.Format("Mon, 2 Jan")
|
||||
}
|
||||
}
|
||||
|
||||
// FormatISTWindow renders a pickup window as the design writes it:
|
||||
// "2:00 – 4:00 PM" — en dash, spaced, and the meridiem stated once when both
|
||||
// ends share it.
|
||||
func FormatISTWindow(from, to time.Time) string {
|
||||
f, t := IST(from), IST(to)
|
||||
fMer, tMer := f.Format("PM"), t.Format("PM")
|
||||
if fMer == tMer {
|
||||
return fmt.Sprintf("%s – %s", f.Format("3:04"), t.Format("3:04 PM"))
|
||||
}
|
||||
return fmt.Sprintf("%s – %s", f.Format("3:04 PM"), t.Format("3:04 PM"))
|
||||
}
|
||||
116
utils/epoch_test.go
Normal file
116
utils/epoch_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The customer contract asks for epoch milliseconds, UTC, integer. This
|
||||
// database stores IST wall-clock digits (see DBNow), so the conversion is the
|
||||
// one place a 5h30m error can enter every timestamp the app renders — which is
|
||||
// exactly the defect class §3.3 of the contract warns about after it produced
|
||||
// "yesterday's work shown as today" on the miler app.
|
||||
|
||||
func TestEpochMillisTreatsStoredWallClockAsIST(t *testing.T) {
|
||||
// A row written as 2026-09-05 14:30:00 by this database means 2:30pm in
|
||||
// Chennai, whatever zone the time.Time happens to carry.
|
||||
stored := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC)
|
||||
|
||||
got := EpochMillis(stored)
|
||||
want := time.Date(2026, 9, 5, 9, 0, 0, 0, time.UTC).UnixMilli() // 14:30 IST == 09:00 UTC
|
||||
|
||||
if got != want {
|
||||
t.Errorf("EpochMillis(2026-09-05 14:30 stored) = %d, want %d (a %d ms error)",
|
||||
got, want, got-want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpochMillisAgreesForBothTaggings(t *testing.T) {
|
||||
// The driver can hand back the same instant either tagged UTC with IST
|
||||
// digits, or correctly tagged +05:30. Both must name one moment, or a
|
||||
// column type change silently shifts every timestamp on the tracking screen.
|
||||
ist := ISTLocation()
|
||||
taggedUTC := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC)
|
||||
taggedIST := time.Date(2026, 9, 5, 14, 30, 0, 0, ist)
|
||||
|
||||
if EpochMillis(taggedUTC) != EpochMillis(taggedIST) {
|
||||
t.Errorf("EpochMillis disagrees on tagging: utc-tagged=%d ist-tagged=%d",
|
||||
EpochMillis(taggedUTC), EpochMillis(taggedIST))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpochMillisPtrKeepsNullNull(t *testing.T) {
|
||||
// A nil timestamp must stay null in JSON. Coercing it to 0 renders as
|
||||
// 1 Jan 1970 on the timeline, which reads as a real event.
|
||||
if got := EpochMillisPtr(nil); got != nil {
|
||||
t.Errorf("EpochMillisPtr(nil) = %v, want nil", got)
|
||||
}
|
||||
|
||||
var zero time.Time
|
||||
if got := EpochMillisPtr(&zero); got != nil {
|
||||
t.Errorf("EpochMillisPtr(zero time) = %v, want nil", got)
|
||||
}
|
||||
|
||||
stored := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC)
|
||||
got := EpochMillisPtr(&stored)
|
||||
if got == nil || *got != EpochMillis(stored) {
|
||||
t.Errorf("EpochMillisPtr(%v) = %v, want %d", stored, got, EpochMillis(stored))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatISTWindowMatchesTheDesign(t *testing.T) {
|
||||
// "2:00 – 4:00 PM" — en dash, spaced, meridiem stated once when both ends
|
||||
// share it. The client renders this string verbatim.
|
||||
cases := []struct {
|
||||
name string
|
||||
fromH, fromM int
|
||||
toH, toM int
|
||||
want string
|
||||
}{
|
||||
{"afternoon window", 14, 0, 16, 0, "2:00 – 4:00 PM"},
|
||||
{"morning window", 8, 0, 10, 0, "8:00 – 10:00 AM"},
|
||||
{"straddles noon", 10, 0, 14, 0, "10:00 AM – 2:00 PM"},
|
||||
{"half hour bounds", 18, 30, 20, 30, "6:30 – 8:30 PM"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
from := time.Date(2026, 9, 5, tc.fromH, tc.fromM, 0, 0, time.UTC)
|
||||
to := time.Date(2026, 9, 5, tc.toH, tc.toM, 0, 0, time.UTC)
|
||||
if got := FormatISTWindow(from, to); got != tc.want {
|
||||
t.Errorf("FormatISTWindow = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatISTDayUsesRelativeWordsOnlyWhenTrue(t *testing.T) {
|
||||
now := ISTNow()
|
||||
|
||||
if got := FormatISTDay(now); got != "Today" {
|
||||
t.Errorf("FormatISTDay(now) = %q, want \"Today\"", got)
|
||||
}
|
||||
if got := FormatISTDay(now.AddDate(0, 0, 1)); got != "Tomorrow" {
|
||||
t.Errorf("FormatISTDay(tomorrow) = %q, want \"Tomorrow\"", got)
|
||||
}
|
||||
|
||||
// Three days out has no relative word — it falls back to the dated form,
|
||||
// which must not read as "Today".
|
||||
got := FormatISTDay(now.AddDate(0, 0, 3))
|
||||
if got == "Today" || got == "Tomorrow" {
|
||||
t.Errorf("FormatISTDay(+3 days) = %q, want a dated label", got)
|
||||
}
|
||||
}
|
||||
|
||||
// FormatISTDay crossing a month boundary is the case a naive day+1 comparison
|
||||
// gets wrong: 30 September plus one day is 1 October, not 31 September.
|
||||
func TestFormatISTDayAcrossMonthEnd(t *testing.T) {
|
||||
now := ISTNow()
|
||||
tomorrow := now.AddDate(0, 0, 1)
|
||||
if now.Month() == tomorrow.Month() {
|
||||
t.Skip("not a month boundary today; the AddDate path is exercised by the general case")
|
||||
}
|
||||
if got := FormatISTDay(tomorrow); got != "Tomorrow" {
|
||||
t.Errorf("FormatISTDay across a month end = %q, want \"Tomorrow\"", got)
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,18 @@ type Claims struct {
|
||||
}
|
||||
|
||||
func GenerateToken(userID int, email string, roleID int, tenantID int, configID int, secret string) (string, error) {
|
||||
expirationTime := time.Now().Add(24 * time.Hour)
|
||||
return GenerateTokenWithTTL(userID, email, roleID, tenantID, configID, secret, 24*time.Hour)
|
||||
}
|
||||
|
||||
// GenerateTokenWithTTL is GenerateToken with an explicit lifetime.
|
||||
//
|
||||
// The customer app pairs a short access token with a long-lived refresh token,
|
||||
// so a stolen access token expires in an hour rather than a day, while the
|
||||
// customer still stays signed in for months. The miler and console surfaces
|
||||
// keep the 24-hour default: they have no refresh endpoint, and shortening their
|
||||
// token would sign a rider out mid-shift.
|
||||
func GenerateTokenWithTTL(userID int, email string, roleID int, tenantID int, configID int, secret string, ttl time.Duration) (string, error) {
|
||||
expirationTime := time.Now().Add(ttl)
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
|
||||
97
utils/response_cx.go
Normal file
97
utils/response_cx.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package utils
|
||||
|
||||
import "github.com/gofiber/fiber/v2"
|
||||
|
||||
// Customer-app (doormile_cx) response envelope.
|
||||
//
|
||||
// Deliberately a separate helper set from OK/List/Fail rather than a reuse of
|
||||
// them. The customer contract fixes three things the miler/console contract
|
||||
// does not: `message` is always present on success (empty string, never
|
||||
// omitted), the machine-readable code is nested under `error.code` rather than
|
||||
// sitting at the top level, and `total`/`nextCursor` ride the list envelope.
|
||||
// Serving one endpoint in one shape and its neighbour in another is exactly
|
||||
// what cost the miler client a release on /miler/verify-pin — so the customer
|
||||
// surface gets its own helpers and every /customer/* response goes through
|
||||
// them, auth included.
|
||||
|
||||
// CxErr* are the error codes in the customer contract. The client branches on
|
||||
// these; `message` is customer-safe English shown verbatim in the UI.
|
||||
const (
|
||||
CxErrInvalid = "invalid"
|
||||
CxErrInvalidName = "invalid_name"
|
||||
CxErrInvalidOtp = "invalid_otp"
|
||||
CxErrUnauthorized = "unauthorized"
|
||||
CxErrForbidden = "forbidden"
|
||||
CxErrNotFound = "not_found"
|
||||
CxErrConflict = "conflict"
|
||||
CxErrUnserviceable = "unserviceable"
|
||||
CxErrRateLimited = "rate_limited"
|
||||
CxErrServer = "server_error"
|
||||
)
|
||||
|
||||
// CxOK is the success envelope: {success, data, message}.
|
||||
func CxOK(c *fiber.Ctx, data interface{}) error {
|
||||
return c.JSON(fiber.Map{"success": true, "data": data, "message": ""})
|
||||
}
|
||||
|
||||
// CxCreated is CxOK with a 201.
|
||||
func CxCreated(c *fiber.Ctx, data interface{}) error {
|
||||
return c.Status(fiber.StatusCreated).
|
||||
JSON(fiber.Map{"success": true, "data": data, "message": ""})
|
||||
}
|
||||
|
||||
// CxList is the list envelope. data is always an array — never null, because
|
||||
// the client types it as a list and a null throws in the parser. nextCursor is
|
||||
// null when the page is the last one.
|
||||
func CxList(c *fiber.Ctx, data interface{}, total int, nextCursor *string) error {
|
||||
body := fiber.Map{
|
||||
"success": true,
|
||||
"data": data,
|
||||
"total": total,
|
||||
"nextCursor": nil,
|
||||
"message": "",
|
||||
}
|
||||
if nextCursor != nil && *nextCursor != "" {
|
||||
body["nextCursor"] = *nextCursor
|
||||
}
|
||||
return c.JSON(body)
|
||||
}
|
||||
|
||||
// CxFail is the error envelope: {success:false, message, error:{code}}. The
|
||||
// app funnels every failure into one retryable error state and renders
|
||||
// `message` verbatim, so callers must pass customer-safe English here — never
|
||||
// an enum key, a driver error or a wrapped stack.
|
||||
func CxFail(c *fiber.Ctx, status int, code, msg string) error {
|
||||
return c.Status(status).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": msg,
|
||||
"error": fiber.Map{"code": code},
|
||||
})
|
||||
}
|
||||
|
||||
// Shorthands for the statuses the contract pins to a specific code.
|
||||
func CxBadRequest(c *fiber.Ctx, msg string) error {
|
||||
return CxFail(c, fiber.StatusBadRequest, CxErrInvalid, msg)
|
||||
}
|
||||
|
||||
func CxUnauthorized(c *fiber.Ctx, msg string) error {
|
||||
return CxFail(c, fiber.StatusUnauthorized, CxErrUnauthorized, msg)
|
||||
}
|
||||
|
||||
func CxForbidden(c *fiber.Ctx, msg string) error {
|
||||
return CxFail(c, fiber.StatusForbidden, CxErrForbidden, msg)
|
||||
}
|
||||
|
||||
func CxNotFound(c *fiber.Ctx, msg string) error {
|
||||
return CxFail(c, fiber.StatusNotFound, CxErrNotFound, msg)
|
||||
}
|
||||
|
||||
func CxConflict(c *fiber.Ctx, msg string) error {
|
||||
return CxFail(c, fiber.StatusConflict, CxErrConflict, msg)
|
||||
}
|
||||
|
||||
// CxInternal never leaks the underlying error to the customer. Log the real
|
||||
// one; the app shows this.
|
||||
func CxInternal(c *fiber.Ctx) error {
|
||||
return CxFail(c, fiber.StatusInternalServerError, CxErrServer, "Something went wrong")
|
||||
}
|
||||
Reference in New Issue
Block a user