From d12629a1e405377fc1d91978b34300898d537570 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Wed, 2 Sep 2026 16:32:53 +0530 Subject: [PATCH 01/11] updates on the api endpoints on the customer page and more --- CLAUDE.md | 64 ++- constants/constants.go | 31 ++ controllers/adminController.go | 137 +++++- controllers/customerController.go | 29 +- controllers/hubController.go | 81 ++-- controllers/hubInboundController.go | 286 ++++++++++++ controllers/logisticsHandoverController.go | 514 +++++++++++++++++++++ controllers/logisticsHandover_test.go | 149 ++++++ controllers/logisticsRouting_test.go | 137 ++++++ controllers/milerAppController.go | 84 +++- controllers/milerController.go | 116 +++-- docs/DEV_ONBOARDING.md | 271 +++++++++++ docs/logistics-base-handover.md | 360 +++++++++++++++ docs/miler-app-api.md | 61 ++- go.mod | 8 +- internal/legs/legs.go | 71 +++ internal/legs/legs_test.go | 117 +++++ internal/routing/dropforleg_test.go | 141 ++++++ internal/routing/optimizer.go | 66 ++- models/audit.go | 10 +- models/booking.go | 15 +- routes/routes.go | 14 + routes/routes_logistics_test.go | 257 +++++++++++ scratch/check_booking_paging.go | 104 +++++ scratch/check_bookingno.go | 71 +++ scratch/check_customer_bookings.go | 115 +++++ scratch/check_handover_schema.go | 136 ++++++ scratch/check_latest.go | 62 +++ scratch/check_miler23.go | 37 ++ scratch/check_phone.go | 38 ++ scratch/check_two_bookings.go | 80 ++++ 31 files changed, 3541 insertions(+), 121 deletions(-) create mode 100644 controllers/hubInboundController.go create mode 100644 controllers/logisticsHandoverController.go create mode 100644 controllers/logisticsHandover_test.go create mode 100644 controllers/logisticsRouting_test.go create mode 100644 docs/DEV_ONBOARDING.md create mode 100644 docs/logistics-base-handover.md create mode 100644 internal/legs/legs.go create mode 100644 internal/legs/legs_test.go create mode 100644 internal/routing/dropforleg_test.go create mode 100644 routes/routes_logistics_test.go create mode 100644 scratch/check_booking_paging.go create mode 100644 scratch/check_bookingno.go create mode 100644 scratch/check_customer_bookings.go create mode 100644 scratch/check_handover_schema.go create mode 100644 scratch/check_latest.go create mode 100644 scratch/check_miler23.go create mode 100644 scratch/check_phone.go create mode 100644 scratch/check_two_bookings.go diff --git a/CLAUDE.md b/CLAUDE.md index 3a6a665..13f7edf 100644 --- a/CLAUDE.md +++ b/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. @@ -196,7 +196,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 +403,65 @@ 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. + +--- + ## 9. Current blockers & open work (whole-project level) **[carried forward]** diff --git a/constants/constants.go b/constants/constants.go index 7b59e71..23573bc 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -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 diff --git a/controllers/adminController.go b/controllers/adminController.go index 95f4c7d..2f683ac 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -2142,7 +2142,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 +2218,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 +2285,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 +2347,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, @@ -2525,7 +2598,65 @@ func GetAdminBookingDetails(c *fiber.Ctx) error { 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 { diff --git a/controllers/customerController.go b/controllers/customerController.go index 039a5f4..0ba874e 100644 --- a/controllers/customerController.go +++ b/controllers/customerController.go @@ -421,18 +421,23 @@ func CreateCustomerBooking(c *fiber.Ctx) error { 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", + Bookingno: generateBookingNo(), + Appcustomerid: customerID, + Pickuplocationid: req.Pickuplocationid, + Pickupaddress: req.Pickupaddress, + Pickuppincode: req.Pickuppincode, + Pickuplatitude: req.Pickuplatitude, + Pickuplongitude: req.Pickuplongitude, + Deliveryaddress: req.Deliveryaddress, + Deliverypincode: req.Deliverypincode, + Deliverylatitude: req.Deliverylatitude, + Deliverylongitude: req.Deliverylongitude, + Bookingsource: "Customer_App", + // A B2C booking is always collected at the sender's own door, so the source + // type is recorded rather than left blank — the rider app titles the stop + // with the sender's name and address instead of grouping it under the + // rider's own base. + Pickupsourcetype: constants.PickupSourceCustomer, Status: constants.BookingPendingPickup, Preferredpickupfrom: req.Preferredpickupfrom, Preferredpickupto: req.Preferredpickupto, diff --git a/controllers/hubController.go b/controllers/hubController.go index 0236767..99de847 100644 --- a/controllers/hubController.go +++ b/controllers/hubController.go @@ -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") diff --git a/controllers/hubInboundController.go b/controllers/hubInboundController.go new file mode 100644 index 0000000..dd3aa69 --- /dev/null +++ b/controllers/hubInboundController.go @@ -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, + }) +} diff --git a/controllers/logisticsHandoverController.go b/controllers/logisticsHandoverController.go new file mode 100644 index 0000000..d16bf31 --- /dev/null +++ b/controllers/logisticsHandoverController.go @@ -0,0 +1,514 @@ +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. + var booking models.PickupBooking + if tx.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil { + 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") + } + + if err := tx.Commit().Error; err != nil { + return utils.Internal(c, "failed to record the handover") + } + + // 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, + }) +} diff --git a/controllers/logisticsHandover_test.go b/controllers/logisticsHandover_test.go new file mode 100644 index 0000000..a8d706b --- /dev/null +++ b/controllers/logisticsHandover_test.go @@ -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`) + } +} diff --git a/controllers/logisticsRouting_test.go b/controllers/logisticsRouting_test.go new file mode 100644 index 0000000..d3f8c6a --- /dev/null +++ b/controllers/logisticsRouting_test.go @@ -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) +} diff --git a/controllers/milerAppController.go b/controllers/milerAppController.go index 9c4c4b8..d3670a8 100644 --- a/controllers/milerAppController.go +++ b/controllers/milerAppController.go @@ -322,34 +322,68 @@ func MilerGetMyBookings(c *fiber.Ctx) error { } } + // 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) + + // next_action / next_hub: the leg this parcel is on, rebuilt from server + // state on every poll. pickup-complete used to be the only place that ever + // said it, so a restart mid-leg left the app with nothing authoritative to + // read — consignment status alone cannot separate a hub-routed parcel from + // a freshly-collected hyperlocal one, since both can sit on Created. + nextAction := constants.NextActionPickup + var nextHub fiber.Map + if b.Status == constants.BookingCancelled { + nextAction = constants.NextActionNone + } else if b.Consignmentid != nil { + if cn, ok := codByConsignment[*b.Consignmentid]; ok { + nextAction = nextActionForConsignment(cn.Status) + nextHub = nextHubForConsignment(&cn) + } + } + 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, + "bookingid": b.Bookingid, + "bookingreference": b.Bookingno, + "status": b.Status, + "stoptype": milerStopType(b.Status), + "consignmentid": b.Consignmentid, + "consignmentstatus": consignmentStatus, + "next_action": nextAction, + "next_hub": nextHub, + "pickup_source_type": sourceType, + "sourceid": sourceID, + "pickuplocationid": sourceID, + "pickup_source_name": sourceName, + "pickupaddress": sourceAddress, + "pickuplatitude": b.Pickuplatitude, + "pickuplongitude": b.Pickuplongitude, + "deliveryaddress": b.Deliveryaddress, + "deliverylatitude": b.Deliverylatitude, + "deliverylongitude": b.Deliverylongitude, + "customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname), + "customerphone": customer.Phone, // Arrival fact: the rider app derives its "Arrived" rung from // pickup-scheduled + a non-null reachedat, so this survives an app // restart without a separate booking status. Null until the rider hits // the reached endpoint. arrivallatitude/longitude are the GPS captured // at that moment (null if the app sent none). - "reachedat": b.Arrivedat, - "arrivallatitude": b.Arrivallatitude, - "arrivallongitude": b.Arrivallongitude, - "parcels": b.Parcels, - "serviceoptions": b.ServiceOptions, - "codamount": codAmount, - "paymentmode": paymentMode, - "createdat": b.Createdat, + "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 @@ -442,6 +476,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, }) } diff --git a/controllers/milerController.go b/controllers/milerController.go index dcc5e49..c4a2c2c 100644 --- a/controllers/milerController.go +++ b/controllers/milerController.go @@ -16,6 +16,7 @@ import ( "doormile/db" "doormile/dto" "doormile/internal/assignment" + "doormile/internal/legs" "doormile/internal/notify" "doormile/internal/routing" "doormile/models" @@ -957,10 +958,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 +967,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 +975,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 @@ -1068,15 +1055,29 @@ func BookingPickupComplete(c *fiber.Ctx) error { 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 - } + utils.Warn("BookingPickupComplete: no base could be resolved for this pickup", "miler_user_id", milerUserID, "booking_id", bookingID) + } + + // A hub-routed parcel: with the hub-handover flow ON it stops at Created — + // collected, in the rider's hands, on its way to a base — and only reaches + // Inwarded_at_Hub when the handover is actually recorded. With it OFF + // (default, and what the current app expects) it is marked Inwarded_at_Hub + // here, which is not where the parcel physically is but is what the current + // app and the console's inbound views read. + consignmentStatus := constants.ConsignmentInwardedAtHub + if hubHandoverEnabled() { + consignmentStatus = constants.ConsignmentCreated } // Hyperlocal shortcut: pickup and delivery in the same postal area mean no @@ -1088,7 +1089,6 @@ func BookingPickupComplete(c *fiber.Ctx) error { // 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) { @@ -1138,6 +1138,14 @@ func BookingPickupComplete(c *fiber.Ctx) error { Currenthubid: defaultHubID, } + // Under the compatibility flow the parcel is treated as received at the base + // the moment it is collected, so the received-at fact is stamped here too — + // otherwise every parcel inwarded this way would have a null handover time + // and base reconciliation would have nothing to compare against. + if consignmentStatus == constants.ConsignmentInwardedAtHub { + consignment.Inwardedat = &now + } + var payment models.BookingPayment if tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil { if payment.Paymentstatus == constants.PaymentStatusPaid { @@ -1188,9 +1196,31 @@ func BookingPickupComplete(c *fiber.Ctx) error { // 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 { + if consignmentStatus == constants.ConsignmentCollectedByMiler || + consignmentStatus == constants.ConsignmentCreated { + // Created here means hub-routed and still in the rider's hands: they are + // carrying it to a base, so they are not free yet. 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 consignmentStatus == constants.ConsignmentInwardedAtHub { + if err := tx.Model(&models.BookingAssignment{}). + Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?", + bookingID, milerUserID, + []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). + Updates(map[string]interface{}{ + "assignmentstatus": constants.AssignmentCompleted, + "completedat": now, + }).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to close assignment") + } + } if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). Update("availabilitystatus", postPickupAvailability).Error; err != nil { tx.Rollback() @@ -1221,30 +1251,28 @@ func BookingPickupComplete(c *fiber.Ctx) error { } } - return utils.OK(c, fiber.Map{ + // 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. + resp := fiber.Map{ "tracking_no": trackingNo, "consignment_id": consignment.Consignmentid, "consignmentstatus": consignment.Status, + "status": consignment.Status, "booking_no": booking.Bookingno, "booking_status": booking.Status, - "next_action": pickupNextAction(consignment.Status), - }) -} - -// 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" + "next_action": nextActionForConsignment(consignment.Status), } + if consignment.Status == constants.ConsignmentCreated || + consignment.Status == constants.ConsignmentInwardedAtHub { + resp["next_hub"] = renderBase(handoverHub) + } + return utils.OK(c, resp) } func BookingVehicleRequiredEscalate(c *fiber.Ctx) error { diff --git a/docs/DEV_ONBOARDING.md b/docs/DEV_ONBOARDING.md new file mode 100644 index 0000000..537edfd --- /dev/null +++ b/docs/DEV_ONBOARDING.md @@ -0,0 +1,271 @@ +# Doormile Backend — Developer Onboarding & Working Memory + +Read this before touching the backend. It is the portable version of knowledge +that otherwise lives only in one machine's Claude session memory. It covers two +things a new dev (or a fresh Claude session on another machine) needs: + +1. **How we use Claude on this project** — the project-memory file, the skills, + the working conventions. +2. **Operational knowledge that isn't in the code** — deploy topology, build + gotchas, production landmines, and the incident history behind current + design choices. + +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/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. + +--- + +## Part 1 — How Claude is used on this project + +### 1.1 Project memory (`CLAUDE.md`) +`CLAUDE.md` at the repo root is the single source of project context, loaded +automatically into every Claude session in this repo. It is checked into git, so +it travels to every machine and every dev. If you change how the system works, +update `CLAUDE.md` in the same change — it is treated as authoritative. + +Sections in `CLAUDE.md` are tagged **[verified this session]** (confirmed +against source) vs **[carried forward]** (reported by a prior session, not +re-verified). Respect the distinction — don't treat carried-forward claims as +confirmed. + +### 1.2 Session memory (machine-local — this is why this doc exists) +Claude also keeps per-fact memory files under +`~/.claude/projects//memory/`, indexed by `MEMORY.md`. These are +**not in git** and **do not travel** to another machine or dev. They accumulate +operational facts, incidents, and gotchas across sessions. + +Part 2 below is a distillation of those files into a form the whole team can +read. When a memory fact changes, update *both* the memory file (for Claude) and +this doc (for humans). + +### 1.3 Claude skills in use +The repo has the `addyosmani/agent-skills` pack installed at `.agents/skills/` +and symlinked into `.claude/skills/`. These are third-party, broad-trigger +skills that run with full agent permissions. Roster (see `skills.md` for the +full table): + +`api-and-interface-design`, `browser-testing-with-devtools`, +`ci-cd-and-automation`, `code-review-and-quality`, `code-simplification`, +`context-engineering`, `debugging-and-error-recovery`, +`deprecation-and-migration`, `documentation-and-adrs`, +`doubt-driven-development`, `frontend-ui-engineering`, +`git-workflow-and-versioning`, `idea-refine`, `incremental-implementation`, +`interview-me`, `observability-and-instrumentation`, +`performance-optimization`, `planning-and-task-breakdown`, +`security-and-hardening`, `shipping-and-launch`, `source-driven-development`, +`spec-driven-development`, `test-driven-development`, `using-agent-skills`. + +**How we treat them:** available on request, *not* auto-adopted over the +conventions in `CLAUDE.md` §7. The established conventions win: use the `utils` +response helpers, the `constants` enums, reuse `AssignMilerToBooking` and +`haversineKM`, prefer minimal-leverage fixes. Invoke a skill explicitly when its +scope fits; don't let a broad trigger override house style. + +### 1.4 Standing working preferences (from `CLAUDE.md`) +- Direct, honest technical assessments. Don't declare victory early. +- Production-grade from the start, minimal-effort highest-leverage fixes. +- **Warn before any consequential server/schema change.** +- Once a decision is made, proceed and report — but re-ask on money/data-correctness. + +--- + +## Part 2 — Operational knowledge (not derivable from the code) + +### 2.1 Build & toolchain +- Go **1.25** (`go.mod` says `go 1.25.0`). Module `doormile`. +- On the primary dev Mac, Go is installed at `/Users/tenext/go` but **not on + PATH**. Prefix: `export PATH="/Users/tenext/go/bin:$PATH"` before any `go` + command, or a bare `go build` returns "command not found". +- `go build ./...`, `go vet ./...`, `go test ./...` all pass repo-wide. +- `scratch/*.go` are throwaway `func main()` scripts tagged `//go:build ignore` + so the toolchain skips them. If a `main redeclared` error appears, it's a + stripped build tag under `scratch/`, **not** a real app problem. +- `go build .` emits a ~65MB `doormile` binary in the repo root; it's + gitignored — delete it, don't commit it. +- Tests exist only for pure logic (`isHyperlocal`, `calculateVolumetricWeight`, + `ParsePage`). Everything else is verified by build+vet, review, or live test. + +### 2.2 Deploy topology — how a change reaches production +- Live backend runs on **k3s** (not plain docker), server `66.116.225.226` port + **4422** (SSH, key-based auth from the primary Mac; `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`). +- Workload: **statefulset `doormile`**, namespace `doormile`, **3 replicas**. + Binary is `/app/server` in the pod; image + `docker.io/doormile/doormile-backend:latest`, `imagePullPolicy: Always`. + Pods report `APP_ENV=staging`. +- **There is NO CI.** Nothing watches the repo. A `kubectl rollout restart` + alone rebuilds nothing — it re-pulls the same image digest. +- **A change reaches prod only by a manual build+push** (needs Docker daemon up + + Docker Hub creds): + ``` + docker build -t doormile/doormile-backend:latest . + docker push doormile/doormile-backend:latest + kubectl rollout restart statefulset/doormile -n doormile + ``` +- **The image builds from the working tree** (`ADD . /app/`). Uncommitted local + edits and any throwaway file under `cmd/`/`scratch/` get baked in. **Always + `git status` before building**, and **confirm code is committed *after* a + deploy** — `git log origin/main` tells you nothing about what's running. +- **Verify what's actually live** rather than assuming: `kubectl exec -n + doormile doormile-0 -- ls -la /app` shows the binary's build date; or probe a + response field only the new code emits. Group auth (`/admin/*`, `/hub/*`, + `/miler/*`) returns 401 for unmatched routes, so a 404-vs-401 probe can't tell + you if a route exists — use an authenticated request. + +### 2.3 Env vars & the source-of-truth manifest +- Env is **inline on the statefulset spec** (`.spec.template.spec.containers[0].env`), + no ConfigMap/envFrom. Set a flag with + `kubectl -n doormile set env statefulset/doormile KEY=value` (auto-rolls). A + plain restart does **not** add a var that isn't already in the spec. +- **Source-of-truth manifest**: `/opt/kubernetes/manifests/doormile/miletruth.yaml` + (git repo at `/opt/kubernetes/.git`; second copy under `/root/kubernetes/...`). + A `kubectl apply` of it **overwrites** live `set env` changes — so any live + flag change must also be written into this manifest or the next deploy reverts + it. +- The manifest supplies DB/Redis/NATS passwords via `secretKeyRef` + (`doormile-secrets`) while the live pods carry literals — verify that secret + exists before relying on `kubectl apply`. +- **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**. +`dbLocation = Asia/Kolkata`; `DBNow()`/`DBToday()` (`utils/helper.go`) return +IST wall-clock digits *tagged as UTC*. Consequence: clients (e.g. the miler app) +that send a timestamp — such as `logdate` on `POST /miler/logs` — should send +**IST wall-clock (phone local time in India), not UTC**. Sending UTC misaligns +Redis zset scores and time-window queries by 5h30m. + +### 2.5 Postgres CHECK constraints predate the codebase +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`. 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. +(This bit us live: `consignments_status_check` was missing `Collected_By_Miler` +and `Cancelled`, 500-ing every hyperlocal pickup and admin cancel.) + +### 2.6 Miler telemetry pipeline (HTTP → Redis; there is NO MQTT) +MQTT is a jupiter concept; Doormile has none. Miler telemetry is 4 HTTP +endpoints, identity always taken from `c.Locals("userid")` (never the body): +- `PUT /miler/location` → Postgres (`MilerProfile` lat/lng/pincode) + Redis + (`miler:gps:{userid}` 30min TTL, `milers:locations` GEO set feeding dispatch). +- `POST /miler/logs` → **Redis-only** periodic telemetry point + (`miler_periodic_log:{userid}:{ts}` + zsets, scored by timestamp). +- `POST /miler/status` → Redis-only (`miler_status:{userid}`). +- `POST /miler/consignments/logs` → Redis (list+zset) + Postgres + `ConsignmentHistory`; takes an **array** (batch). + +Device sensors (GPS/speed/heading/accuracy, battery/is_charging, connection) +come from Flutter plugins regardless of transport — MQTT isn't needed to collect +them. Console reads of the trail use **newest-first** fetch +(`ZRevRangeByScore`), then reverse to chronological, so a `?limit=N` window +keeps the *latest* fixes (fixed in `6e5da09` — previously `?limit=1` returned +the day's first blank early-boot ping). + +### 2.7 Route optimizer +`routes.workolik.com` (env `ROUTE_OPTIMIZER_URL`) = the rider-bike FastAPI +service (OR-Tools + Valhalla). Doormile endpoint +`POST /api/v1/optimization/doormile/sequence`, contract matches +`internal/routing/optimizer.go`. As of `f6d339a`, +`routing.SequenceMilerStopsAsync` fires after every assignment path. A `step=0` +in prod is never the service being down — it means the rider had <2 active +stops, a stop had missing coords, or the assignment predated the wiring. + +### 2.8 NATS ownership +Doormile has its **own** NATS (`nats://66.116.226.161:4223`, user `doormile`), +separate from jupiter's (`nats.workolik.com:4222`). Streams are declared by the +Go app in `db/streams.go` (`EnsureStreams`, add-only — never deletes/drops). +**Adding a `js.Publish` without adding its subject to `streamSubjects` silently +drops the event.** Publishing is best-effort: `if db.Js != nil { ... }`, +warn-log on failure, never fail the request. Do not run the old Python +`setup_jetstream.py` scripts — they used to clobber the subject list (now +neutered to read-only, but that change lives only on disk, not in git). + +### 2.9 Config gotcha: milers need `configid = 1001` +`LoginMiler`/`VerifyMilerPin` look up `WHERE contactno = ? AND configid = 1001`. +`AppUser.configid` column-defaults to `1`, so any miler created without +explicitly setting configid authenticates against nothing and returns a +misleading `404 no miler account found` even though the row exists and is +Active. `CreateMiler` now defaults it to 1001. **When a miler "doesn't exist" +but the row is visibly there, check `configid` first.** Same trap applies to +`AppCustomer`. + +--- + +## Part 3 — Access control status (know before adding client logins) + +- **Admin/express console (`/admin/*`) has NO tenant scoping.** `LoginAdmin` + hardcodes `tenantid = 0` in the JWT and no admin handler filters by tenant — + every console login sees every tenant's data. **Do not create a client-facing + `doormile_auth` login** until this is fixed (mirror the `HubStaffAccount.Tenantid + *int` pattern: nil = Doormile staff/unrestricted, set = client/scoped). +- **Hub console** is only partially scoped: `scopeBookingsToOwnTenant` is applied + at ~3 of ~20 hub handlers that return booking/consignment data. +- Recurring flaw class in this codebase: **trusting a client-supplied + identifier** (body `userid`, path `:userid`, request `tenantid`). When + reviewing any handler, confirm identity comes from the token and ownership is + proven before read/write. (Several account-takeover/IDOR bugs of this shape + were fixed 2026-08-05.) + +--- + +## Part 4 — Deliberate decisions (do not re-raise unprompted) + +These are conscious calls by the project owner, recorded so they aren't +re-litigated: +- **`.env` and the Firebase key are committed to git.** Flagged as critical, + deliberately deferred. Config also hardcodes the same values as `getEnv` + fallbacks. Don't re-raise unless the owner opens the topic. **Do not add new + secret literals to any committed file.** +- **`/crm/*` stays unauthenticated** — the field-sales Flutter app sends no + token. Revisit only when that app can send a key. +- **B2C `PickupBooking.Tenantid` stays nil** — whether to attribute + direct-to-consumer traffic to a Doormile-ops tenant is a business decision. +- **Delivery OTP stays off for DailyGrubs.** +- Hyperlocal is decided per-booking with a 30km coord fallback (Option A); a + tenant-level service-type flag (Option B) was deferred. +- `PartnerInfo` vs `Tenant` naming, and `Customer` (legacy) vs `AppCustomer` + (new B2C) duplication — flagged, not acted on. + +--- + +## Part 5 — Credentials & test accounts (where they live, not the values) + +Secrets are **not** reproduced here (see Part 4). Pointers: +- **App/DB/Redis/NATS secrets** — `.env` (committed) and the `doormile-secrets` + k8s secret referenced by `miletruth.yaml`. +- **Live production DB** — `logistics` on `31.97.228.132:5433`. Read-only + inspection via a throwaway Go program using `.env` creds is the safe path. +- **Live backend Redis** — the manifest and `.env` have historically drifted + (stale `31.97.228.132:6379` vs live `66.116.226.255:6380`); confirm which the + running pods actually use before trusting either. +- **Test rosters** live in Claude session memory (machine-local): the Coimbatore + miler roster (6 riders, PIN `1234`), 3 customer app test accounts, and the + DailyGrubs onboarding (tenant 13, master admin `developer@doormile.com`). Ask + the owner for current values rather than assuming — they rotate. + +> **Note on production writes:** the auto-mode classifier blocks production +> writes (kubectl set env, DB UPDATE/ALTER) inconsistently. Do **not** route +> around a block — hand the exact command to the owner to run. Read-only +> inspection is fine. diff --git a/docs/logistics-base-handover.md b/docs/logistics-base-handover.md new file mode 100644 index 0000000..cd561b8 --- /dev/null +++ b/docs/logistics-base-handover.md @@ -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: + +{ "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. diff --git a/docs/miler-app-api.md b/docs/miler-app-api.md index ce3d058..845e0b3 100644 --- a/docs/miler-app-api.md +++ b/docs/miler-app-api.md @@ -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 @@ -126,6 +126,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 +189,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 +287,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`. diff --git a/go.mod b/go.mod index 1b99351..a1d8438 100644 --- a/go.mod +++ b/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 diff --git a/internal/legs/legs.go b/internal/legs/legs.go new file mode 100644 index 0000000..9ef98e3 --- /dev/null +++ b/internal/legs/legs.go @@ -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 +} diff --git a/internal/legs/legs_test.go b/internal/legs/legs_test.go new file mode 100644 index 0000000..7cc8ac5 --- /dev/null +++ b/internal/legs/legs_test.go @@ -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) + } +} diff --git a/internal/routing/dropforleg_test.go b/internal/routing/dropforleg_test.go new file mode 100644 index 0000000..7fcfb62 --- /dev/null +++ b/internal/routing/dropforleg_test.go @@ -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) + } +} diff --git a/internal/routing/optimizer.go b/internal/routing/optimizer.go index 85b538d..cf78b2f 100644 --- a/internal/routing/optimizer.go +++ b/internal/routing/optimizer.go @@ -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) { diff --git a/models/audit.go b/models/audit.go index df11870..cce47ec 100644 --- a/models/audit.go +++ b/models/audit.go @@ -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 diff --git a/models/booking.go b/models/booking.go index 1dbedd2..c1e24a8 100644 --- a/models/booking.go +++ b/models/booking.go @@ -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"` diff --git a/routes/routes.go b/routes/routes.go index 9b673d7..89374ce 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -193,6 +193,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 +376,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) diff --git a/routes/routes_logistics_test.go b/routes/routes_logistics_test.go new file mode 100644 index 0000000..c6beee2 --- /dev/null +++ b/routes/routes_logistics_test.go @@ -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) + } + }) + } +} diff --git a/scratch/check_booking_paging.go b/scratch/check_booking_paging.go new file mode 100644 index 0000000..6d155f6 --- /dev/null +++ b/scratch/check_booking_paging.go @@ -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 +} diff --git a/scratch/check_bookingno.go b/scratch/check_bookingno.go new file mode 100644 index 0000000..4405621 --- /dev/null +++ b/scratch/check_bookingno.go @@ -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)) +} diff --git a/scratch/check_customer_bookings.go b/scratch/check_customer_bookings.go new file mode 100644 index 0000000..af712f5 --- /dev/null +++ b/scratch/check_customer_bookings.go @@ -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 +} diff --git a/scratch/check_handover_schema.go b/scratch/check_handover_schema.go new file mode 100644 index 0000000..9694b58 --- /dev/null +++ b/scratch/check_handover_schema.go @@ -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) +} diff --git a/scratch/check_latest.go b/scratch/check_latest.go new file mode 100644 index 0000000..16521a3 --- /dev/null +++ b/scratch/check_latest.go @@ -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) +} diff --git a/scratch/check_miler23.go b/scratch/check_miler23.go new file mode 100644 index 0000000..12dc088 --- /dev/null +++ b/scratch/check_miler23.go @@ -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) +} diff --git a/scratch/check_phone.go b/scratch/check_phone.go new file mode 100644 index 0000000..143e82a --- /dev/null +++ b/scratch/check_phone.go @@ -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) + } +} diff --git a/scratch/check_two_bookings.go b/scratch/check_two_bookings.go new file mode 100644 index 0000000..3ed32d0 --- /dev/null +++ b/scratch/check_two_bookings.go @@ -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) + } +} From 35675d8a9b4d443b1e3726099aa9cf745b90eb27 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Wed, 2 Sep 2026 17:52:36 +0530 Subject: [PATCH 02/11] updates on the customercontroller and the booking.go updates --- controllers/customerController.go | 52 ++++++++++++++++++++ cors_test.go | 80 +++++++++++++++++++++++++++++++ main.go | 58 ++++++++++++++++++++-- models/booking.go | 12 +++-- 4 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 cors_test.go diff --git a/controllers/customerController.go b/controllers/customerController.go index 0ba874e..7dbadf6 100644 --- a/controllers/customerController.go +++ b/controllers/customerController.go @@ -573,6 +573,52 @@ func CreateCustomerBooking(c *fiber.Ctx) error { return utils.Created(c, booking) } +// fillConsignmentFacts attaches the consignment's tracking number and live +// status to each booking that has one. +// +// A booking freezes at Converted_To_Consignment the moment it is collected, +// while the parcel keeps moving on the consignment — so without this a customer +// sees a status that stopped updating the instant their parcel was picked up. +// +// The tracking number matters more: GET /customer/track/:trackingno is keyed on +// it, and nothing else the customer app can read exposes it. Tracking was +// unreachable from the app not because the endpoint was missing but because the +// number never travelled to it. +// +// Batched — one query for the whole page, not one per row. +func fillConsignmentFacts(bookings []models.PickupBooking) { + ids := make([]int, 0, len(bookings)) + for _, b := range bookings { + if b.Consignmentid != nil { + ids = append(ids, *b.Consignmentid) + } + } + if len(ids) == 0 { + return + } + + var consignments []models.Consignment + if err := db.DB.Select("consignmentid, trackingno, status"). + Where("consignmentid IN ?", ids).Find(&consignments).Error; err != nil { + utils.Warn("fillConsignmentFacts: could not load consignments", "error", err) + return + } + + byID := make(map[int]models.Consignment, len(consignments)) + for _, cn := range consignments { + byID[cn.Consignmentid] = cn + } + for i := range bookings { + if bookings[i].Consignmentid == nil { + continue + } + if cn, ok := byID[*bookings[i].Consignmentid]; ok { + bookings[i].Trackingno = cn.Trackingno + bookings[i].Consignmentstatus = cn.Status + } + } +} + func GetCustomerBookings(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) @@ -581,6 +627,8 @@ func GetCustomerBookings(c *fiber.Ctx) error { return utils.Internal(c, "failed to fetch bookings") } + fillConsignmentFacts(bookings) + return utils.List(c, bookings, int64(len(bookings))) } @@ -596,6 +644,10 @@ func GetCustomerBookingDetails(c *fiber.Ctx) error { return utils.NotFound(c, "booking not found") } + one := []models.PickupBooking{booking} + fillConsignmentFacts(one) + booking = one[0] + return utils.OK(c, booking) } diff --git a/cors_test.go b/cors_test.go new file mode 100644 index 0000000..73efb19 --- /dev/null +++ b/cors_test.go @@ -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) + } + } +} diff --git a/main.go b/main.go index bd43cf8..6566c26 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "errors" + "net/url" "os" "os/signal" "strings" @@ -31,6 +32,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" @@ -107,10 +132,37 @@ 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", })) diff --git a/models/booking.go b/models/booking.go index c1e24a8..d1d4205 100644 --- a/models/booking.go +++ b/models/booking.go @@ -73,9 +73,15 @@ 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:"-"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` // Relations Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"` From 1b2690b21a9ee8d95d9cd930a2e04c5fd96a7d67 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Mon, 7 Sep 2026 10:55:11 +0530 Subject: [PATCH 03/11] backend requirements onthe xustomer app --- CLAUDE.md | 147 +- config/config.go | 13 + config/config_test.go | 92 ++ constants/constants.go | 71 + constants/constants_test.go | 116 ++ controllers/adminController.go | 32 + controllers/booking_assignment_service.go | 16 + controllers/customerController.go | 691 ++------- controllers/cxAuthController.go | 614 ++++++++ controllers/cxBookingController.go | 895 ++++++++++++ controllers/cxBookingView.go | 760 ++++++++++ controllers/cxCatalogueController.go | 563 ++++++++ controllers/cxConsignmentHooks.go | 100 ++ controllers/cxCustomerApp_test.go | 856 +++++++++++ controllers/cxDeviceController.go | 85 ++ controllers/cxFareController.go | 266 ++++ controllers/cxHttp_test.go | 713 ++++++++++ controllers/cxIdentifierScramble.go | 181 +++ controllers/cxIdentifierScramble_test.go | 281 ++++ controllers/cxIdentifiers.go | 95 ++ controllers/cxOpsController.go | 160 +++ controllers/cxPickupFanout.go | 251 ++++ controllers/cxPlacesController.go | 350 +++++ controllers/logisticsHandoverController.go | 24 +- controllers/milerAppController.go | 456 ++++-- controllers/milerController.go | 591 +++++--- controllers/otpController.go | 128 -- docs/customer-api-testing.md | 392 +++++ docs/customer-app-api.md | 928 ++++++++++++ docs/openapi-customer.yaml | 1495 ++++++++++++++++++++ dto/auth.go | 41 +- internal/assignment/crm_assignment.go | 17 + internal/cxstage/stage.go | 425 ++++++ internal/cxstage/stage_test.go | 126 ++ internal/sms/sms.go | 105 ++ internal/sms/sms_test.go | 140 ++ internal/storage/spaces.go | 74 + internal/storage/spaces_test.go | 182 +++ main.go | 7 + middlewares/city_gate.go | 18 + middlewares/idempotency.go | 34 +- middlewares/idempotency_test.go | 122 ++ middlewares/logger.go | 38 +- middlewares/requestid.go | 33 + migrations/migrate.go | 55 + models/booking.go | 60 +- models/customer_app.go | 241 ++++ models/customer_app_test.go | 175 +++ routes/routes.go | 80 +- routes/routes_customer_test.go | 405 ++++++ scratch/cx_readonly_probe.go | 174 +++ seed_customer_app.sql | 170 +++ utils/epoch.go | 103 ++ utils/epoch_test.go | 116 ++ utils/helper.go | 13 +- utils/response_cx.go | 97 ++ 56 files changed, 13342 insertions(+), 1071 deletions(-) create mode 100644 config/config_test.go create mode 100644 constants/constants_test.go create mode 100644 controllers/cxAuthController.go create mode 100644 controllers/cxBookingController.go create mode 100644 controllers/cxBookingView.go create mode 100644 controllers/cxCatalogueController.go create mode 100644 controllers/cxConsignmentHooks.go create mode 100644 controllers/cxCustomerApp_test.go create mode 100644 controllers/cxDeviceController.go create mode 100644 controllers/cxFareController.go create mode 100644 controllers/cxHttp_test.go create mode 100644 controllers/cxIdentifierScramble.go create mode 100644 controllers/cxIdentifierScramble_test.go create mode 100644 controllers/cxIdentifiers.go create mode 100644 controllers/cxOpsController.go create mode 100644 controllers/cxPickupFanout.go create mode 100644 controllers/cxPlacesController.go delete mode 100644 controllers/otpController.go create mode 100644 docs/customer-api-testing.md create mode 100644 docs/customer-app-api.md create mode 100644 docs/openapi-customer.yaml create mode 100644 internal/cxstage/stage.go create mode 100644 internal/cxstage/stage_test.go create mode 100644 internal/sms/sms.go create mode 100644 internal/sms/sms_test.go create mode 100644 internal/storage/spaces_test.go create mode 100644 middlewares/idempotency_test.go create mode 100644 middlewares/requestid.go create mode 100644 models/customer_app.go create mode 100644 models/customer_app_test.go create mode 100644 routes/routes_customer_test.go create mode 100644 scratch/cx_readonly_probe.go create mode 100644 seed_customer_app.sql create mode 100644 utils/epoch.go create mode 100644 utils/epoch_test.go create mode 100644 utils/response_cx.go diff --git a/CLAUDE.md b/CLAUDE.md index 13f7edf..e4bb875 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,12 +90,14 @@ Five distinct front doors into this system. Only the backend API surface for each has been directly inspected this session (via `routes.go`); the actual client codebases (Flutter, React) have not been opened in this session. -1. **Customer app (B2C)** — Flutter. Auth via Firebase OTP (phone). Backend - surface: 19 customer routes **[verified this session]** - (`customer`/`customerAuth` groups) — booking creation, tracking, - `AppCustomer`/`AppCustomerLocation`. **[carried forward]**: reported built - and verified in prior sessions; the last live end-to-end test was - blocked here — see §9. +1. **Customer app (B2C)** — Flutter, being replaced: `doormile_customer_app` + (PIN auth, single-destination bookings) is retired in favour of + `doormile_cx`. Backend surface rebuilt 2026-09-05 to the Customer App v1 + contract: **28 customer routes** (`customer`/`customerAuth` groups) — OTP + auth with refresh, serviceability/slots/limits, place proxy, fare estimate, + multi-destination pickups, per-order tracking, push devices. See §8.5. Auth + is a 4-digit OTP to phone or email, NOT Firebase and NOT the miler PIN flow; + the SMS gateway is still unplugged, which is the same wall §9 describes. 2. **Miler app** — Flutter, for delivery riders. Backend surface: 38 routes **[verified this session]** (`miler`/`milerAuth` groups) — duty start/stop, GPS pings, assignment accept/reject/cancel, delivery @@ -462,6 +464,139 @@ has not run against a real DB. `go build`, `go vet` and `go test ./...` all pass --- +## 8.5 Customer app v1 — the `/customer/*` rebuild (2026-09-05) + +**[verified this session]** — implements *Doormile — Backend Requirements +(Customer App v1)* for the new `doormile_cx` Flutter client. Full contract, +decisions and the written answers to the requirement doc's open questions: +[`docs/customer-app-api.md`](docs/customer-app-api.md). Spec: +[`docs/openapi-customer.yaml`](docs/openapi-customer.yaml). + +**The structural change: a customer books a PICKUP, not a shipment.** One +booking → 1..N destinations → one consignment and one tracking number per +destination, minted when the miler completes the pickup. `pickupbookings` +carried exactly one delivery address in its own columns, so there was nowhere to +put a second; `bookingdestinations` is what closes that. + +**The compatibility rule that makes it safe — do not break it:** destination 0 +is mirrored onto the booking's flat `delivery*` columns. The miler app, the hub +console, the routing code and the hyperlocal check all read those columns and +none of them changed. A booking with **no** destination rows (every +console/express booking, every pre-existing row) produces exactly one +consignment through the same loop, byte-for-byte as before. Single-destination +is one leg, never a special case. + +**Two prior surfaces were replaced, on Suriya's call (2026-09-05).** The PIN +auth (`/customer/register|login|verify-pin|reset-pin`, plus the email-OTP pair) +and the single-destination booking create/list/detail/cancel/price and +`/customer/track/:trackingno` are gone — `doormile_customer_app` is being +retired in favour of `doormile_cx`. `controllers/otpController.go` was deleted +with them. Customer routes: 19 → 28. + +**Identifier formats changed platform-wide.** `generateBookingNo()` now mints +`DM-######` and `generateTrackingNo()` mints `DMX########`, both off Postgres +sequences (`cx_booking_reference_seq`, `cx_tracking_seq`, created in +`migrations/migrate.go`). The old generators used four random bytes; both +columns are `UNIQUE` and a random short id collides long before the space runs +out. Existing rows keep their `DM-BK-`/`DM-TRK-` strings — nothing parses either +format, so the two coexist and the console just shows the new one for new work. + +### Conventions added — reuse these, don't reimplement + +- **`utils.CxOK` / `CxCreated` / `CxList` / `CxFail`** (`utils/response_cx.go`) + are the ONLY response helpers for `/customer/*`. Deliberately separate from + `utils.OK`/`Fail`: the customer contract always sends `message` (empty on + success) and nests the code under `error.code`, while miler/console put `code` + at the top level. Never mix them on one surface. +- **`utils.EpochMillis(t)`** (`utils/epoch.go`) is the ONLY way a timestamp + leaves `/customer/*`. This DB stores IST wall-clock digits (see `DBNow`), so + `t.UnixMilli()` is off by 5h30m — the same defect that produced "yesterday's + work shown as today" on the miler app. `utils/epoch_test.go` asserts it for + both taggings the driver can produce. +- **`internal/cxstage`** is the ONE place a customer stage is written. `Record` + takes the caller's `*gorm.DB` — a stage event must commit or roll back with + the operational write it describes. It dedupes per (booking, destination, + stage), and `Notify` fires only after commit. +- **`renderCxBooking` + `loadCxBundle`** (`controllers/cxBookingView.go`) build + the canonical booking object. Every read that returns a booking goes through + them; `loadCxBundle` is a fixed number of queries regardless of page size. +- **`cxDestinationForConsignment(id)`** resolves a consignment to its booking. + Use it instead of `WHERE consignmentid = ?` on `pickupbookings` — that column + names only the FIRST order of a multi-destination pickup (see the bugs below). +- **`cxPickupLegs(tx, booking)`** splits a booking into the journeys to create + at pickup-complete. It is what decides single-vs-fan-out; nothing downstream + needs to know which it got. + +### Stage derivation (the actual work) + +Nine stages, lowercase snake_case, in `constants.CxStage*`. The client parses +them verbatim and **silently falls back to `booked` on an unknown key** — never +add or rename one without a client release. A booking rolls up from its +**slowest** order once parcels split, or a customer sees "Delivered" while a +parcel is still at a hub. Nothing is backfilled: a pre-existing booking gets a +short honest history rather than an invented one. + +`cxstage.Release` is the one place a stage moves **backwards** — a miler +cancelling returns the pickup to the pool rather than cancelling it, and without +walking the stage back the customer keeps seeing a rider who is not coming. + +### Four pre-existing bugs fixed in passing + +All the same root cause, all found because the fan-out forced every consignment +lookup to be re-read. Each would have broken multi-destination pickups outright: + +1. **`MilerDeliverConsignment` could not close orders 2..N** — its ownership + check was `WHERE consignmentid = ? AND assignedmileruserid = ?` on + `pickupbookings`, so a rider delivering the second parcel of a three-stop + visit got "assigned consignment not found" and could not complete at all. +2. **`MilerStartDelivery` notified nobody for orders 2..N** — same join, so no + push and no receiver OTP. +3. **`MilerInwardConsignmentAtHub` left assignments open for orders 2..N** — the + rider could not go off duty (`MilerEndDuty` refuses on an open assignment) + and the leg's distance/earnings recorded as zero. +4. **`GET /miler/bookings` showed only the first order** — one row per booking + keyed on that same column, so the fan-out would have minted orders no rider + could see or deliver. `milerStopsForBooking` now emits one stop per order + after collection, one visit before it, and exactly one row (unchanged) for a + booking with no destination rows. + +Also: **`CityGateMiddleware` was a no-op for customer bookings.** It sniffs the +body for `pickuppincode`, which the new request shape does not carry, so every +customer booking sailed past the operating-city gate. Now checked in the handler +via the exported `middlewares.PincodeInOperatingCity`. + +### Blockers and gaps — state these plainly if asked + +- **No SMS provider exists.** `internal/sms` is the seam (a `Sender` interface, + a logging sink, `sms.Register()`); until a gateway is plugged in, OTP codes go + to the application log and nowhere else. **This is the single blocker on real + customer sign-in** — and it is the same wall §9's E2E test hit. Staging has + `CX_STAGING_OTP` (refused when `ENV=production`), which unblocks automated + tests. +- **No integration test has hit any of these endpoints.** `go build`, `go vet` + and `go test ./...` pass; new unit tests cover the pure logic (stage rollup, + epoch conversion, phone normalisation, weight fallback). None of that proves + behaviour against a real DB/Redis/NATS. +- **The migration has not run against a real database.** Additive, so it should + be safe — but that is not the same as having run. +- **Failed delivery is invisible to the customer.** `MilerSkipDelivery` works + operationally, but there is no tenth stage key for it and an unknown key + renders as `booked`, so a failed attempt leaves the parcel showing "Out for + delivery". Needs product + a client release. +- **Latency (p95 ≤ 400ms) is unmeasured.** Reads are batched and pricing is + Redis-warmed, but that is an argument, not a measurement. +- **No retention policy** for parcel photos or PII — nothing prunes either. The + 30-minute signed-URL TTL limits link lifetime, not object lifetime. + +### New env vars + +`GEOCODER_URL`, `GEOCODER_EMAIL` (place proxy — the app is never handed a map +key, after the legacy rider app's key had to be revoked), `MILER_CALL_PROXY` +(masked calling; empty exposes the rider's real number — **set before launch**), +`CX_STAGING_OTP`, `CX_ALLOW_STAGE_OVERRIDE`. + +--- + ## 9. Current blockers & open work (whole-project level) **[carried forward]** diff --git a/config/config.go b/config/config.go index 2318c27..d5b6120 100644 --- a/config/config.go +++ b/config/config.go @@ -27,6 +27,17 @@ type Config struct { // stay unordered rather than assignment failing. RouteOptimizerURL string + // GeocoderURL is the Nominatim-compatible geocoding service the customer + // app'''s place search and reverse geocode are proxied through. Proxied on + // purpose: the legacy rider app shipped a Google Maps key inside the + // binary and it had to be revoked, so the customer app is never handed a + // key at all — it asks this service and this service asks the geocoder. + GeocoderURL string + // GeocoderEmail is the contact address Nominatim'''s usage policy asks + // callers to identify themselves with. Sent as the User-Agent contact; + // requests without one are throttled or blocked. + GeocoderEmail string + // TrustedProxies is a comma-separated list of reverse-proxy IPs/CIDRs that // are allowed to set X-Forwarded-For. Rate limiting keys on the client IP, // so behind a proxy this MUST be set — otherwise every request appears to @@ -60,6 +71,8 @@ func Load() *Config { AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"), RouteOptimizerURL: getEnv("ROUTE_OPTIMIZER_URL", "https://routes.workolik.com"), + GeocoderURL: getEnv("GEOCODER_URL", "https://nominatim.openstreetmap.org"), + GeocoderEmail: getEnv("GEOCODER_EMAIL", ""), TrustedProxies: getEnv("TRUSTED_PROXIES", ""), SMTPHost: getEnv("SMTP_HOST", ""), SMTPPort: getEnv("SMTP_PORT", "465"), diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..9c8e35a --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,92 @@ +package config + +import ( + "os" + "testing" +) + +// Config is read once at startup, so a mistyped env key fails silently: the +// service boots on a default and points at the wrong host, or ships with a +// security switch left off. Cheap to pin. + +func setEnv(t *testing.T, key, value string) { + t.Helper() + previous, had := os.LookupEnv(key) + if value == "" { + _ = os.Unsetenv(key) + } else { + _ = os.Setenv(key, value) + } + t.Cleanup(func() { + if had { + _ = os.Setenv(key, previous) + } else { + _ = os.Unsetenv(key) + } + }) +} + +// The geocoder settings added for the customer app. GEOCODER_URL is what the +// place search proxies through — the app is never handed a map key, after the +// legacy rider app's key had to be revoked. +func TestGeocoderDefaultsAndOverrides(t *testing.T) { + setEnv(t, "GEOCODER_URL", "") + setEnv(t, "GEOCODER_EMAIL", "") + + cfg := Load() + if cfg.GeocoderURL != "https://nominatim.openstreetmap.org" { + t.Errorf("GeocoderURL default = %q, want the Nominatim host", cfg.GeocoderURL) + } + if cfg.GeocoderEmail != "" { + t.Errorf("GeocoderEmail default = %q, want empty", cfg.GeocoderEmail) + } + + setEnv(t, "GEOCODER_URL", "https://nominatim.internal") + setEnv(t, "GEOCODER_EMAIL", "ops@doormile.com") + + cfg = Load() + if cfg.GeocoderURL != "https://nominatim.internal" { + t.Errorf("GEOCODER_URL was not read: got %q", cfg.GeocoderURL) + } + if cfg.GeocoderEmail != "ops@doormile.com" { + t.Errorf("GEOCODER_EMAIL was not read: got %q", cfg.GeocoderEmail) + } +} + +// Every env key the service reads must actually take effect. A typo in the key +// name means the override is ignored and the default is used — which is how a +// deploy silently points at the wrong database. +func TestEnvironmentOverridesAreRead(t *testing.T) { + cases := []struct { + key string + value string + read func(*Config) string + }{ + {"ENV", "production", func(c *Config) string { return c.Env }}, + {"APP_PORT", "9999", func(c *Config) string { return c.Port }}, + {"DB_HOST", "db.internal", func(c *Config) string { return c.DBHost }}, + {"DB_NAME", "logistics_staging", func(c *Config) string { return c.DBName }}, + {"DB_PORT", "6543", func(c *Config) string { return c.DBPort }}, + {"REDIS_HOST", "redis.internal", func(c *Config) string { return c.RedisHost }}, + {"JWT_SECRET_KEY", "a-different-secret", func(c *Config) string { return c.JWTSecret }}, + {"TRUSTED_PROXIES", "10.0.0.0/8", func(c *Config) string { return c.TrustedProxies }}, + {"AI_LAYER_BASE_URL", "http://ai.internal", func(c *Config) string { return c.AILayerBaseURL }}, + {"ROUTE_OPTIMIZER_URL", "http://routes.internal", func(c *Config) string { return c.RouteOptimizerURL }}, + } + + for _, tc := range cases { + setEnv(t, tc.key, tc.value) + if got := tc.read(Load()); got != tc.value { + t.Errorf("%s set to %q but Load() read %q", tc.key, tc.value, got) + } + } +} + +// An empty env var must fall through to the default rather than blanking the +// setting — an empty DB host is a service that cannot start with no clue why. +func TestEmptyEnvFallsBackToTheDefault(t *testing.T) { + setEnv(t, "DB_NAME", "") + if got := Load().DBName; got == "" { + t.Error("DBName is empty when DB_NAME is unset; a default is required") + } +} diff --git a/constants/constants.go b/constants/constants.go index 23573bc..b85d8f6 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -168,3 +168,74 @@ const ( ExceptionResolved = "Resolved" ExceptionClosed = "Closed" ) + +// Customer-app stages. Nine operational stages, spelt exactly as the customer +// client parses them: lowercase snake_case, on the wire verbatim. The client +// rolls these up into seven milestones itself and falls back to "booked" on an +// unknown key, silently — so adding a value here without an app release makes a +// parcel look un-started. Never rename one; add and coordinate. +// +// Stages 0-5 belong to the booking. Stages 6-8 belong to each order and may +// differ between destinations of the same booking. +const ( + CxStageBooked = "booked" // 0 — pickup requested + CxStageAssigned = "assigned" // 1 — a miler accepted it + CxStageOnTheWay = "on_the_way" // 2 — rider en route, distance/ETA live + CxStageArrived = "arrived" // 3 — rider at the door; LAST cancellable stage + CxStagePickedUp = "picked_up" // 4 — weighed, photographed, price settled + CxStageOrderCreated = "order_created" // 5 — one tracking number minted per destination + CxStageInTransit = "in_transit" // 6 — per order from here on + CxStageOutForDelivery = "out_for_delivery" // 7 — delivery agent carrying it + CxStageDelivered = "delivered" // 8 — handed over +) + +// Customer-facing booking status. Derived from the stage but sent explicitly, +// because a client that has to infer it will eventually infer it differently. +const ( + CxStatusActive = "active" + CxStatusCompleted = "completed" + CxStatusCancelled = "cancelled" +) + +// Who caused a stage transition. Recorded on every bookingstageevents row: the +// customer timeline is derived from that table, so it has to be real, and a +// cancellation the customer did not make is unexplainable without this. +const ( + CxActorMiler = "miler" + CxActorOps = "ops" + CxActorCustomer = "customer" + CxActorSystem = "system" +) + +// CxStageOrder is the rank of each stage, used to decide whether a transition +// moves forward and whether cancellation is still open. Cancellation closes +// after arrived, so anything at or past picked_up is refused. +var CxStageOrder = map[string]int{ + CxStageBooked: 0, + CxStageAssigned: 1, + CxStageOnTheWay: 2, + CxStageArrived: 3, + CxStagePickedUp: 4, + CxStageOrderCreated: 5, + CxStageInTransit: 6, + CxStageOutForDelivery: 7, + CxStageDelivered: 8, +} + +// CxStageRank returns the rank of a stage, or -1 when the stage is unknown or +// empty. A booking written before this surface existed has no stage at all, +// and -1 keeps it strictly behind every real stage rather than tying with +// "booked". +func CxStageRank(stage string) int { + if r, ok := CxStageOrder[stage]; ok { + return r + } + return -1 +} + +// CxCancellable reports whether a booking at this stage may still be cancelled. +// The UI mirrors this to hide the button, but the server re-checks on the +// cancel call — the button state is a hint, never the authority. +func CxCancellable(stage string) bool { + return CxStageRank(stage) <= CxStageOrder[CxStageArrived] +} diff --git a/constants/constants_test.go b/constants/constants_test.go new file mode 100644 index 0000000..fba53c4 --- /dev/null +++ b/constants/constants_test.go @@ -0,0 +1,116 @@ +package constants + +import "testing" + +// The nine stage keys are a wire contract. The customer client parses them +// verbatim and silently falls back to `booked` on anything it does not +// recognise — so a renamed or misspelt key here does not fail loudly, it makes +// a moving parcel look un-started on someone's tracking screen. + +func TestStageKeysAreSpeltExactlyAsTheClientParsesThem(t *testing.T) { + want := map[string]string{ + CxStageBooked: "booked", + CxStageAssigned: "assigned", + CxStageOnTheWay: "on_the_way", + CxStageArrived: "arrived", + CxStagePickedUp: "picked_up", + CxStageOrderCreated: "order_created", + CxStageInTransit: "in_transit", + CxStageOutForDelivery: "out_for_delivery", + CxStageDelivered: "delivered", + } + for got, expected := range want { + if got != expected { + t.Errorf("stage key = %q, want %q", got, expected) + } + } + if len(CxStageOrder) != 9 { + t.Errorf("CxStageOrder has %d entries, want 9 — every stage the client "+ + "knows must be rankable", len(CxStageOrder)) + } +} + +// Ranks must be strictly increasing in journey order. advanceBooking refuses to +// move a booking backwards by comparing these, so a tie or an inversion would +// let a stage silently fail to apply. +func TestStageRanksIncreaseInJourneyOrder(t *testing.T) { + ordered := []string{ + CxStageBooked, CxStageAssigned, CxStageOnTheWay, CxStageArrived, + CxStagePickedUp, CxStageOrderCreated, CxStageInTransit, + CxStageOutForDelivery, CxStageDelivered, + } + for i := 1; i < len(ordered); i++ { + if CxStageRank(ordered[i]) <= CxStageRank(ordered[i-1]) { + t.Errorf("%q ranks %d, not after %q at %d", + ordered[i], CxStageRank(ordered[i]), ordered[i-1], CxStageRank(ordered[i-1])) + } + } +} + +// An unknown or empty stage must rank BEHIND booked, not tie with it. A booking +// written before this surface existed has no stage at all, and a tie would stop +// it ever advancing off nothing. +func TestUnknownStageRanksBehindEverything(t *testing.T) { + if CxStageRank("") != -1 { + t.Errorf("empty stage ranks %d, want -1", CxStageRank("")) + } + if CxStageRank("teleported") != -1 { + t.Errorf("unknown stage ranks %d, want -1", CxStageRank("teleported")) + } + if CxStageRank("") >= CxStageRank(CxStageBooked) { + t.Error("an unknown stage does not rank behind booked") + } +} + +// Cancellation closes after `arrived` — the contract's rule, and money depends +// on it: cancelling after pickup would mean a parcel already collected and paid +// for is marked cancelled. +func TestCancellationWindowClosesAfterArrived(t *testing.T) { + open := []string{"", CxStageBooked, CxStageAssigned, CxStageOnTheWay, CxStageArrived} + for _, stage := range open { + if !CxCancellable(stage) { + t.Errorf("stage %q should still be cancellable", stage) + } + } + + closed := []string{ + CxStagePickedUp, CxStageOrderCreated, CxStageInTransit, + CxStageOutForDelivery, CxStageDelivered, + } + for _, stage := range closed { + if CxCancellable(stage) { + t.Errorf("stage %q must not be cancellable — the parcel is collected", stage) + } + } +} + +// The customer statuses and actor types are also on the wire. +func TestCustomerStatusAndActorValues(t *testing.T) { + pairs := map[string]string{ + CxStatusActive: "active", + CxStatusCompleted: "completed", + CxStatusCancelled: "cancelled", + CxActorMiler: "miler", + CxActorOps: "ops", + CxActorCustomer: "customer", + CxActorSystem: "system", + } + for got, want := range pairs { + if got != want { + t.Errorf("constant = %q, want %q", got, want) + } + } +} + +// Booking source decides whether a booking gets a customer projection at all — +// cxstage returns early for anything that is not Customer_App. The stored value +// predates this work and must not be "fixed". +func TestBookingSourceValuesAreTheStoredOnes(t *testing.T) { + if BookingSourceCustomerApp != "Customer_App" { + t.Errorf("BookingSourceCustomerApp = %q, want %q", BookingSourceCustomerApp, "Customer_App") + } + if BookingSourceExpress != "CRM_Console" { + t.Errorf("BookingSourceExpress = %q — this is an existing database value, "+ + "not a label to rename", BookingSourceExpress) + } +} diff --git a/controllers/adminController.go b/controllers/adminController.go index 2f683ac..70022d9 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -15,6 +15,7 @@ import ( "doormile/db" "doormile/dto" "doormile/internal/assignment" + "doormile/internal/cxstage" "doormile/internal/notify" "doormile/models" "doormile/utils" @@ -2770,6 +2771,18 @@ func AdminCancelBooking(c *fiber.Ctx) error { return utils.Internal(c, "failed to cancel booking") } + // Tell the customer's projection too. Without this the pickup keeps + // rendering as active and cancellable in the customer app, because + // customerstatus was written as "active" at booking time and nothing here + // ever moved it. Best-effort and outside the save above: an ops cancel that + // has already committed must not be reported as failed because the + // customer-side write did not land. No-op for console-created bookings. + if err := cxstage.Cancel(db.DB, booking.Bookingid, "Cancelled by Doormile operations", + constants.CxActorOps, opsActorID(c), "POST /admin/bookings/{id}/cancel"); err != nil { + utils.Error("AdminCancelBooking: could not update the customer projection", + "booking_id", booking.Bookingid, "error", err) + } + if booking.Assignedmileruserid != nil { db.DB.Model(&models.MilerProfile{}). Where("userid = ?", *booking.Assignedmileruserid). @@ -2849,6 +2862,15 @@ func AdminBulkCancelBookings(c *fiber.Ctx) error { continue } + // Same reason as AdminCancelBooking: without this the pickup keeps + // rendering as active and cancellable in the customer app. No-op for + // console-created bookings. + if err := cxstage.Cancel(db.DB, booking.Bookingid, "Cancelled by Doormile operations", + constants.CxActorOps, opsActorID(c), "POST /admin/bookings/bulk-cancel"); err != nil { + utils.Error("AdminBulkCancelBookings: could not update the customer projection", + "booking_id", booking.Bookingid, "error", err) + } + if booking.Assignedmileruserid != nil { db.DB.Model(&models.MilerProfile{}). Where("userid = ?", *booking.Assignedmileruserid). @@ -3895,3 +3917,13 @@ func InternalReassign(c *fiber.Ctx) error { "booking_id": booking.Bookingid, }) } + +// opsActorID returns the console user behind an ops action, for the customer's +// audit trail. Nil when the request carries no user id, which is a legitimate +// state for an internal caller rather than something to fail on. +func opsActorID(c *fiber.Ctx) *int { + if uid, ok := c.Locals("userid").(int); ok && uid != 0 { + return &uid + } + return nil +} diff --git a/controllers/booking_assignment_service.go b/controllers/booking_assignment_service.go index 044ea38..71c7e2a 100644 --- a/controllers/booking_assignment_service.go +++ b/controllers/booking_assignment_service.go @@ -7,6 +7,7 @@ import ( "doormile/constants" "doormile/db" + "doormile/internal/cxstage" "doormile/internal/notify" "doormile/internal/routing" "doormile/models" @@ -71,6 +72,21 @@ func assignMilerTx(tx *gorm.DB, bookingID, milerUserID int, assignedByUserID *in return nil, fmt.Errorf("failed to update miler availability: %w", err) } + // The customer's "Miler assigned" milestone, recorded where the assignment + // is actually created rather than where a rider taps Accept. A rider who + // never opens the app would otherwise leave the customer watching "finding + // a Miler" while ops has the booking down as assigned — two surfaces + // disagreeing about the same fact. + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: booking.Bookingid, + Stage: constants.CxStageAssigned, + ActorType: constants.CxActorOps, + ActorID: assignedByUserID, + Source: "assignMilerTx", + }); err != nil { + return nil, fmt.Errorf("failed to record the assigned stage: %w", err) + } + return &booking, nil } diff --git a/controllers/customerController.go b/controllers/customerController.go index 7dbadf6..213e63b 100644 --- a/controllers/customerController.go +++ b/controllers/customerController.go @@ -1,29 +1,26 @@ package controllers import ( - "crypto/rand" - "encoding/json" - "fmt" "math" "strconv" - "time" - "doormile/config" - "doormile/constants" "doormile/db" "doormile/dto" - "doormile/internal/assignment" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" ) -func generateBookingNo() string { - b := make([]byte, 4) - rand.Read(b) - return fmt.Sprintf("DM-BK-%X-%d", b, time.Now().Unix()%100000) -} +// Customer profile and saved addresses. +// +// The rest of the customer surface — auth, catalogue, estimate, bookings, +// tracking, places, devices — lives in the cx*Controller.go files and answers +// in the customer envelope (utils.CxOK / utils.CxFail). The PIN login, +// single-destination booking create/list/detail/cancel and the /customer/track +// read that used to live here were replaced by that surface, not moved: a +// customer books a pickup with 1..N destinations now, and there is no shape in +// which the old single-address request is still a valid booking. func calculateDistance(lat1, lon1, lat2, lon2 float64) float64 { const R = 6371.0 @@ -40,190 +37,15 @@ func calculateVolumetricWeight(length, width, height float64) float64 { return (length * width * height) / 5000.0 } -func RegisterCustomer(cfg *config.Config) fiber.Handler { - return func(c *fiber.Ctx) error { - req := new(dto.CustomerRegisterRequest) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") - } - - if req.Phone == "" || req.Firstname == "" || req.Pin == "" { - return utils.BadRequest(c, "phone, firstname, and pin are required") - } - - pinHash, err := utils.HashPassword(req.Pin) - if err != nil { - return utils.Internal(c, "failed to process registration") - } - - configID := req.Configid - if configID == 0 { - configID = 1001 - } - - var existing models.AppCustomer - if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&existing).Error; err == nil { - return utils.Conflict(c, "a customer with this phone number already exists") - } - - customer := models.AppCustomer{ - Firstname: req.Firstname, - Lastname: req.Lastname, - Phone: req.Phone, - Email: req.Email, - Loginpinhash: pinHash, - Status: "Active", - Configid: configID, - } - - if err := db.DB.Create(&customer).Error; err != nil { - return utils.Internal(c, "failed to register customer") - } - - token, err := utils.GenerateToken(customer.Appcustomerid, customer.Phone, 9, 0, customer.Configid, cfg.JWTSecret) - if err != nil { - return utils.Internal(c, "registration successful but failed to generate token") - } - - return c.Status(fiber.StatusCreated).JSON(fiber.Map{ - "success": true, - "token": token, - "user": customer, - }) - } -} - -func LoginCustomer(c *fiber.Ctx) error { - req := new(dto.CustomerLoginRequest) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") - } - - if req.Phone == "" { - return utils.BadRequest(c, "phone is required") - } - - configID := req.Configid - if configID == 0 { - configID = 1001 - } - - var customer models.AppCustomer - if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil { - return utils.NotFound(c, "no account found for this phone number") - } - - if customer.Status == "Blocked" { - return utils.Forbidden(c, "this account has been blocked") - } - - return c.JSON(fiber.Map{ - "success": true, - "message": "PIN verification required", - "phone": req.Phone, - }) -} - -func VerifyCustomerPin(cfg *config.Config) fiber.Handler { - return func(c *fiber.Ctx) error { - req := new(dto.CustomerPinVerifyRequest) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") - } - - if req.Phone == "" || req.Pin == "" { - return utils.BadRequest(c, "phone and pin are required") - } - - configID := req.Configid - if configID == 0 { - configID = 1001 - } - - var customer models.AppCustomer - if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil { - return utils.NotFound(c, "customer not found") - } - - if !utils.CheckPasswordHash(req.Pin, customer.Loginpinhash) { - return utils.Unauthorized(c, "incorrect PIN") - } - - now := time.Now() - customer.Lastloginat = &now - if req.DeviceToken != "" { - customer.Devicetoken = req.DeviceToken - } - db.DB.Save(&customer) - - token, err := utils.GenerateToken(customer.Appcustomerid, customer.Phone, 9, 0, customer.Configid, cfg.JWTSecret) - if err != nil { - return utils.Internal(c, "failed to generate token") - } - - return c.JSON(fiber.Map{ - "success": true, - "token": token, - "user": customer, - }) - } -} - -func ResetCustomerPin(c *fiber.Ctx) error { - req := new(dto.CustomerResetPinRequest) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") - } - - if req.Phone == "" || req.NewPin == "" { - return utils.BadRequest(c, "phone and new_pin are required") - } - - configID := req.Configid - if configID == 0 { - configID = 1001 - } - - var customer models.AppCustomer - if err := db.DB.Where("phone = ? AND configid = ?", req.Phone, configID).First(&customer).Error; err != nil { - return utils.NotFound(c, "customer not found") - } - - // Proof of identity is required before overwriting a login credential. - // Without it this endpoint reset any customer's PIN from their phone number - // alone — and phone numbers are the login identifier, not a secret — so - // reset-pin followed by verify-pin was a complete account takeover. - // The caller must first pass /customer/send-email-otp and - // /customer/verify-email-otp for this account's registered address. - if customer.Email == "" { - return utils.Forbidden(c, "this account has no registered email to verify against — contact support to reset the PIN") - } - if !ConsumeEmailVerification(customer.Email) { - return utils.Forbidden(c, "verify your registered email first via /customer/send-email-otp and /customer/verify-email-otp") - } - - pinHash, err := utils.HashPassword(req.NewPin) - if err != nil { - return utils.Internal(c, "failed to process PIN reset") - } - - customer.Loginpinhash = pinHash - if err := db.DB.Save(&customer).Error; err != nil { - return utils.Internal(c, "failed to reset PIN") - } - - return utils.Message(c, "PIN reset successfully") -} - func GetCustomerProfile(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) var customer models.AppCustomer if err := db.DB.First(&customer, customerID).Error; err != nil { - return utils.NotFound(c, "profile not found") + return utils.CxNotFound(c, "We could not find your profile") } - return utils.OK(c, customer) + return utils.CxOK(c, renderCustomer(&customer)) } func UpdateCustomerProfile(c *fiber.Ctx) error { @@ -231,76 +53,91 @@ func UpdateCustomerProfile(c *fiber.Ctx) error { var customer models.AppCustomer if err := db.DB.First(&customer, customerID).Error; err != nil { - return utils.NotFound(c, "profile not found") + return utils.CxNotFound(c, "We could not find your profile") } - type ProfileUpdate struct { - Firstname string `json:"firstname"` - Lastname string `json:"lastname"` - Email string `json:"email"` - Defaultlatitude float64 `json:"defaultlatitude"` - Defaultlongitude float64 `json:"defaultlongitude"` - Defaultpincode string `json:"defaultpincode"` + // Pointer fields: an omitted key leaves the stored value alone, an explicit + // value overwrites it. The previous version cleared email and lastname on + // every call that did not resend them, which quietly wiped a customer's + // email the first time they edited their name. + var req struct { + Name *string `json:"name"` + Email *string `json:"email"` + Defaultlatitude *float64 `json:"defaultLatitude"` + Defaultlongitude *float64 `json:"defaultLongitude"` + Defaultpincode *string `json:"defaultPincode"` + } + if err := c.BodyParser(&req); err != nil { + return utils.CxBadRequest(c, "We could not read that request") } - req := new(ProfileUpdate) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") + if req.Name != nil { + first, last := splitName(*req.Name) + if first == "" { + return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name") + } + customer.Firstname, customer.Lastname = first, last } - - if req.Firstname != "" { - customer.Firstname = req.Firstname + if req.Email != nil { + customer.Email = *req.Email } - customer.Lastname = req.Lastname - customer.Email = req.Email - if req.Defaultlatitude != 0 { - customer.Defaultlatitude = req.Defaultlatitude + if req.Defaultlatitude != nil { + customer.Defaultlatitude = *req.Defaultlatitude } - if req.Defaultlongitude != 0 { - customer.Defaultlongitude = req.Defaultlongitude + if req.Defaultlongitude != nil { + customer.Defaultlongitude = *req.Defaultlongitude } - if req.Defaultpincode != "" { - customer.Defaultpincode = req.Defaultpincode + if req.Defaultpincode != nil { + customer.Defaultpincode = *req.Defaultpincode } if err := db.DB.Save(&customer).Error; err != nil { - return utils.Internal(c, "failed to update profile") + utils.Error("UpdateCustomerProfile: save failed", "customer_id", customerID, "error", err) + return utils.CxInternal(c) } - return utils.OK(c, customer) + return utils.CxOK(c, renderCustomer(&customer)) } func GetCustomerLocations(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) var locations []models.AppCustomerLocation - if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active").Find(&locations).Error; err != nil { - return utils.Internal(c, "failed to fetch locations") + if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active"). + Order("isdefault DESC, appcustomerlocationid DESC").Find(&locations).Error; err != nil { + utils.Error("GetCustomerLocations: query failed", "customer_id", customerID, "error", err) + return utils.CxInternal(c) } - return utils.List(c, locations, int64(len(locations))) + out := make([]fiber.Map, 0, len(locations)) + for i := range locations { + out = append(out, renderSavedAddress(&locations[i])) + } + return utils.CxList(c, out, len(out), nil) } func CreateCustomerLocation(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) var count int64 - db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ? AND status = ?", customerID, "Active").Count(&count) + db.DB.Model(&models.AppCustomerLocation{}). + Where("appcustomerid = ? AND status = ?", customerID, "Active").Count(&count) if count >= 10 { - return utils.BadRequest(c, "maximum of 10 saved locations allowed") + return utils.CxBadRequest(c, "You can save up to 10 addresses") } req := new(dto.LocationCreateRequest) if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") + return utils.CxBadRequest(c, "We could not read that request") } if req.Address == "" || req.Pincode == "" || req.Latitude == 0 || req.Longitude == 0 { - return utils.BadRequest(c, "address, pincode, latitude, and longitude are required") + return utils.CxBadRequest(c, "An address needs a street, a pincode and a map location") } if req.Isdefault { - db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ?", customerID).Update("isdefault", false) + db.DB.Model(&models.AppCustomerLocation{}). + Where("appcustomerid = ?", customerID).Update("isdefault", false) } location := models.AppCustomerLocation{ @@ -320,27 +157,29 @@ func CreateCustomerLocation(c *fiber.Ctx) error { } if err := db.DB.Create(&location).Error; err != nil { - return utils.Internal(c, "failed to save location") + utils.Error("CreateCustomerLocation: insert failed", "customer_id", customerID, "error", err) + return utils.CxInternal(c) } - return utils.Created(c, location) + return utils.CxCreated(c, renderSavedAddress(&location)) } func UpdateCustomerLocation(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) locationID, err := strconv.Atoi(c.Params("id")) if err != nil { - return utils.BadRequest(c, "invalid location ID") + return utils.CxBadRequest(c, "That address could not be found") } var location models.AppCustomerLocation - if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).First(&location).Error; err != nil { - return utils.NotFound(c, "location not found") + if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID). + First(&location).Error; err != nil { + return utils.CxNotFound(c, "That address could not be found") } req := new(dto.LocationCreateRequest) if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") + return utils.CxBadRequest(c, "We could not read that request") } if req.Label != "" { @@ -366,392 +205,58 @@ func UpdateCustomerLocation(c *fiber.Ctx) error { location.Isdefault = req.Isdefault if req.Isdefault { - db.DB.Model(&models.AppCustomerLocation{}).Where("appcustomerid = ?", customerID).Update("isdefault", false) + db.DB.Model(&models.AppCustomerLocation{}). + Where("appcustomerid = ?", customerID).Update("isdefault", false) } if err := db.DB.Save(&location).Error; err != nil { - return utils.Internal(c, "failed to update location") + utils.Error("UpdateCustomerLocation: save failed", "location_id", locationID, "error", err) + return utils.CxInternal(c) } - return utils.OK(c, location) + return utils.CxOK(c, renderSavedAddress(&location)) } func DeleteCustomerLocation(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) locationID, err := strconv.Atoi(c.Params("id")) if err != nil { - return utils.BadRequest(c, "invalid location ID") + return utils.CxBadRequest(c, "That address could not be found") } var location models.AppCustomerLocation - if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID).First(&location).Error; err != nil { - return utils.NotFound(c, "location not found") + if err := db.DB.Where("appcustomerlocationid = ? AND appcustomerid = ?", locationID, customerID). + First(&location).Error; err != nil { + return utils.CxNotFound(c, "That address could not be found") } location.Status = "InActive" - db.DB.Save(&location) + if err := db.DB.Save(&location).Error; err != nil { + utils.Error("DeleteCustomerLocation: save failed", "location_id", locationID, "error", err) + return utils.CxInternal(c) + } - return utils.Message(c, "location deleted successfully") + return utils.CxOK(c, fiber.Map{"id": strconv.Itoa(location.Appcustomerlocationid), "deleted": true}) } -func CreateCustomerBooking(c *fiber.Ctx) error { - customerID := c.Locals("userid").(int) - - req := new(dto.PickupBookingRequest) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") +// renderSavedAddress is the one shape a saved address is returned in, matching +// the two-line title/sub the pickup search and the booking pickup block use — +// so an address picked from Saved and one picked from search are the same +// object to the client. +func renderSavedAddress(l *models.AppCustomerLocation) fiber.Map { + title := l.Label + if title == "" { + title = l.Address } - - if req.Pickupaddress == "" || req.Pickuppincode == "" { - return utils.BadRequest(c, "pickup address and pincode are required") - } - - if len(req.Parcels) == 0 { - return utils.BadRequest(c, "at least one parcel is required") - } - - // Geocode delivery pincode to lat/lon when the app doesn't supply coordinates. - if req.Deliverylatitude == 0 && req.Deliverylongitude == 0 && req.Deliverypincode != "" { - if lat, lon, ok := pincodeToLatLon(req.Deliverypincode); ok { - req.Deliverylatitude = lat - req.Deliverylongitude = lon - } - } - - tx := db.DB.Begin() - - booking := models.PickupBooking{ - Bookingno: generateBookingNo(), - Appcustomerid: customerID, - Pickuplocationid: req.Pickuplocationid, - Pickupaddress: req.Pickupaddress, - Pickuppincode: req.Pickuppincode, - Pickuplatitude: req.Pickuplatitude, - Pickuplongitude: req.Pickuplongitude, - Deliveryaddress: req.Deliveryaddress, - Deliverypincode: req.Deliverypincode, - Deliverylatitude: req.Deliverylatitude, - Deliverylongitude: req.Deliverylongitude, - Bookingsource: "Customer_App", - // A B2C booking is always collected at the sender's own door, so the source - // type is recorded rather than left blank — the rider app titles the stop - // with the sender's name and address instead of grouping it under the - // rider's own base. - Pickupsourcetype: constants.PickupSourceCustomer, - Status: constants.BookingPendingPickup, - Preferredpickupfrom: req.Preferredpickupfrom, - Preferredpickupto: req.Preferredpickupto, - } - - if err := tx.Create(&booking).Error; err != nil { - tx.Rollback() - return utils.Internal(c, "failed to create booking") - } - - var totalWeight float64 - var totalVolume float64 - var requiresLargeVehicle bool - for _, p := range req.Parcels { - volumetric := calculateVolumetricWeight(p.Length, p.Width, p.Height) - totalWeight += math.Max(p.Weight, volumetric) - totalVolume += p.Length * p.Width * p.Height - - parcel := models.BookingParcel{ - Bookingid: booking.Bookingid, - Itemcategory: p.Itemcategory, - Itemdescription: p.Itemdescription, - Declaredvalue: p.Declaredvalue, - Weight: p.Weight, - Length: p.Length, - Width: p.Width, - Height: p.Height, - Isfragile: p.Isfragile, - Needsinsurance: p.Needsinsurance, - Requireslargevehicle: p.Requireslargevehicle, - } - - if p.Requireslargevehicle { - requiresLargeVehicle = true - } - - if p.Needsinsurance { - parcel.Insuranceamount = p.Declaredvalue * 0.01 - } - - if err := tx.Create(&parcel).Error; err != nil { - tx.Rollback() - return utils.Internal(c, "failed to save parcel details") - } - } - - serviceType := req.ServiceOption - if serviceType == "" { - serviceType = "Normal" - } - - zone := resolveZone(req.Pickuppincode, req.Deliverypincode) - itemCategory := normalizePricingCategory(req.Parcels[0].Itemcategory) - - var estimatedPrice float64 - var pricingID *int - if price, pid, found := lookupDoormilePrice(zone, mapServiceTypeToPricing(serviceType), totalWeight, itemCategory); found { - estimatedPrice = price - pricingID = pid - } else { - var distance float64 - if booking.Deliverylatitude != 0 && booking.Deliverylongitude != 0 { - distance = calculateDistance(booking.Pickuplatitude, booking.Pickuplongitude, booking.Deliverylatitude, booking.Deliverylongitude) - } - estimatedPrice = 50.0 + (distance * 5.0) + (totalWeight * 10.0) - } - - now := time.Now() - estDelivery := now.Add(24 * time.Hour) - slaDue := now.Add(36 * time.Hour) - if serviceType == "Fast" { - estDelivery = now.Add(12 * time.Hour) - slaDue = now.Add(18 * time.Hour) - } else if serviceType == "Superfast" { - estDelivery = now.Add(6 * time.Hour) - slaDue = now.Add(9 * time.Hour) - } - - srvOption := models.BookingServiceOption{ - Bookingid: booking.Bookingid, - Servicetype: serviceType, - Estimatedprice: estimatedPrice, - Pricingid: pricingID, - Estimateddeliveryat: &estDelivery, - Sladueat: &slaDue, - } - - if err := tx.Create(&srvOption).Error; err != nil { - tx.Rollback() - return utils.Internal(c, "failed to save service option") - } - - if requiresLargeVehicle || totalVolume > 0 && totalWeight > 20.0 { - reqVeh := models.BookingVehicleRequirement{ - Bookingid: booking.Bookingid, - Requiredvehicletype: "truck", - Reason: "Oversized package / heavy weight", - Status: "Required", - } - if err := tx.Create(&reqVeh).Error; err != nil { - tx.Rollback() - return utils.Internal(c, "failed to save vehicle requirement") - } - } - - if err := tx.Commit().Error; err != nil { - return utils.Internal(c, "failed to create booking") - } - - go assignment.AssignCustomerMiler(booking.Bookingid) - - if db.Js != nil { - payload := map[string]interface{}{ - "booking_id": booking.Bookingid, - "booking_no": booking.Bookingno, - "customer_id": booking.Appcustomerid, - "pickup_address": booking.Pickupaddress, - "pickup_pincode": booking.Pickuppincode, - "delivery_address": booking.Deliveryaddress, - "delivery_pincode": booking.Deliverypincode, - "status": constants.BookingPendingPickup, - "created_at": time.Now().UnixMilli(), - } - if data, err := json.Marshal(payload); err == nil { - if _, err := db.Js.Publish("api.v1.bookings.create", data); err != nil { - utils.Warn("Failed to publish booking.create to NATS", "booking_id", booking.Bookingid, "error", err) - } - } - } - - db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, booking.Bookingid) - - return utils.Created(c, booking) -} - -// fillConsignmentFacts attaches the consignment's tracking number and live -// status to each booking that has one. -// -// A booking freezes at Converted_To_Consignment the moment it is collected, -// while the parcel keeps moving on the consignment — so without this a customer -// sees a status that stopped updating the instant their parcel was picked up. -// -// The tracking number matters more: GET /customer/track/:trackingno is keyed on -// it, and nothing else the customer app can read exposes it. Tracking was -// unreachable from the app not because the endpoint was missing but because the -// number never travelled to it. -// -// Batched — one query for the whole page, not one per row. -func fillConsignmentFacts(bookings []models.PickupBooking) { - ids := make([]int, 0, len(bookings)) - for _, b := range bookings { - if b.Consignmentid != nil { - ids = append(ids, *b.Consignmentid) - } - } - if len(ids) == 0 { - return - } - - var consignments []models.Consignment - if err := db.DB.Select("consignmentid, trackingno, status"). - Where("consignmentid IN ?", ids).Find(&consignments).Error; err != nil { - utils.Warn("fillConsignmentFacts: could not load consignments", "error", err) - return - } - - byID := make(map[int]models.Consignment, len(consignments)) - for _, cn := range consignments { - byID[cn.Consignmentid] = cn - } - for i := range bookings { - if bookings[i].Consignmentid == nil { - continue - } - if cn, ok := byID[*bookings[i].Consignmentid]; ok { - bookings[i].Trackingno = cn.Trackingno - bookings[i].Consignmentstatus = cn.Status - } + return fiber.Map{ + "id": strconv.Itoa(l.Appcustomerlocationid), + "label": l.Label, + "title": title, + "sub": joinNonEmpty(", ", l.Address, l.Landmark, l.City, l.Pincode), + "recipientName": l.Receivername, + "recipientPhone": l.Receiverphone, + "lat": l.Latitude, + "lng": l.Longitude, + "isDefault": l.Isdefault, } } - -func GetCustomerBookings(c *fiber.Ctx) error { - customerID := c.Locals("userid").(int) - - var bookings []models.PickupBooking - if err := db.DB.Preload("Parcels").Preload("ServiceOptions").Where("appcustomerid = ?", customerID).Order("createdat DESC").Find(&bookings).Error; err != nil { - return utils.Internal(c, "failed to fetch bookings") - } - - fillConsignmentFacts(bookings) - - return utils.List(c, bookings, int64(len(bookings))) -} - -func GetCustomerBookingDetails(c *fiber.Ctx) error { - customerID := c.Locals("userid").(int) - bookingID, err := strconv.Atoi(c.Params("bookingid")) - if err != nil { - return utils.BadRequest(c, "invalid booking ID") - } - - var booking models.PickupBooking - if err := db.DB.Preload("Parcels").Preload("ServiceOptions").Preload("Payments").Where("bookingid = ? AND appcustomerid = ?", bookingID, customerID).First(&booking).Error; err != nil { - return utils.NotFound(c, "booking not found") - } - - one := []models.PickupBooking{booking} - fillConsignmentFacts(one) - booking = one[0] - - return utils.OK(c, booking) -} - -func CancelCustomerBooking(c *fiber.Ctx) error { - customerID := c.Locals("userid").(int) - bookingID, err := strconv.Atoi(c.Params("bookingid")) - if err != nil { - return utils.BadRequest(c, "invalid booking ID") - } - - var booking models.PickupBooking - if err := db.DB.Where("bookingid = ? AND appcustomerid = ?", bookingID, customerID).First(&booking).Error; err != nil { - return utils.NotFound(c, "booking not found") - } - - if booking.Status == constants.BookingPickedUp || booking.Status == constants.BookingConvertedConsignment { - return utils.BadRequest(c, "booking cannot be cancelled after the package has been picked up") - } - - booking.Status = constants.BookingCancelled - booking.Updatedat = time.Now() - db.DB.Save(&booking) - - if db.Js != nil { - payload := map[string]interface{}{ - "booking_id": booking.Bookingid, - "booking_no": booking.Bookingno, - "customer_id": booking.Appcustomerid, - "status": "Cancelled", - "cancelled_at": time.Now().UnixMilli(), - } - if data, err := json.Marshal(payload); err == nil { - if _, err := db.Js.Publish("api.v1.bookings.cancel", data); err != nil { - utils.Warn("Failed to publish booking.cancel to NATS", "booking_id", booking.Bookingid, "error", err) - } - } - } - - return utils.OK(c, booking) -} - -func GetCustomerBookingQuote(c *fiber.Ctx) error { - customerID := c.Locals("userid").(int) - bookingID, err := strconv.Atoi(c.Params("bookingid")) - if err != nil { - return utils.BadRequest(c, "invalid booking ID") - } - - // Ownership is checked here as it is on the other booking routes — without - // it any signed-in customer could read the price quoted on anyone else's - // booking just by walking the id. - var booking models.PickupBooking - if err := db.DB.Select("bookingid"). - Where("bookingid = ? AND appcustomerid = ?", bookingID, customerID). - First(&booking).Error; err != nil { - return utils.NotFound(c, "booking not found") - } - - var serviceOpt models.BookingServiceOption - if err := db.DB.Where("bookingid = ?", bookingID).Order("createdat DESC").First(&serviceOpt).Error; err != nil { - return utils.NotFound(c, "price quote not found for this booking") - } - - return utils.OK(c, serviceOpt) -} - -func TrackConsignment(c *fiber.Ctx) error { - trackingNo := c.Params("trackingno") - if trackingNo == "" { - return utils.BadRequest(c, "tracking number is required") - } - - var consignment models.Consignment - if err := db.DB.Where("trackingno = ?", trackingNo).First(&consignment).Error; err != nil { - return utils.NotFound(c, "no shipment found for this tracking number") - } - - var history []models.ConsignmentHistory - db.DB.Where("consignmentid = ?", consignment.Consignmentid).Order("createdat DESC").Find(&history) - - return utils.OK(c, fiber.Map{ - "consignment": consignment, - "history": history, - }) -} - -func SaveCustomerDeviceToken(c *fiber.Ctx) error { - customerID := c.Locals("userid").(int) - - var req struct { - DeviceToken string `json:"device_token"` - } - if err := c.BodyParser(&req); err != nil { - return utils.BadRequest(c, "invalid request body") - } - if req.DeviceToken == "" { - return utils.BadRequest(c, "device_token is required") - } - - if err := db.DB.Model(&models.AppCustomer{}). - Where("appcustomerid = ?", customerID). - Update("device_token", req.DeviceToken).Error; err != nil { - return utils.Internal(c, "failed to save device token") - } - - return utils.Message(c, "device token saved") -} diff --git a/controllers/cxAuthController.go b/controllers/cxAuthController.go new file mode 100644 index 0000000..9fa56cd --- /dev/null +++ b/controllers/cxAuthController.go @@ -0,0 +1,614 @@ +package controllers + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "math/big" + "strings" + "time" + + "doormile/config" + "doormile/constants" + "doormile/db" + "doormile/internal/mail" + "doormile/internal/sms" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" + goredis "github.com/redis/go-redis/v9" +) + +// Customer authentication — §4 of the contract. +// +// A 4-digit code to a phone or an email address, and no password anywhere. This +// is deliberately NOT the miler's phone+PIN flow: /miler/verify-pin exists and +// is not reused. A PIN is a stored secret a rider sets once and a console can +// reset, which is appropriate for a fleet of known employees; a customer base is +// not that, and the previous customer PIN flow shipped a reset endpoint that +// took over any account from a phone number alone. +// +// Every response here goes in `data`, auth included. /miler/verify-pin returns +// its payload outside the envelope and that inconsistency cost the miler client +// a release to discover — it is not repeated. + +const ( + cxOtpTTL = 5 * time.Minute + cxOtpLength = 4 + cxResendWait = 30 * time.Second + // cxOtpMaxVerify is attempts per issued code. Three, then the code dies — + // a 4-digit code is 10,000 combinations and generous retries make it + // walkable. + cxOtpMaxVerify = 3 + // cxOtpMaxRequests is codes per identifier per hour, so an attacker cannot + // mint fresh codes to reset the attempt counter, and a victim cannot be + // flooded with texts. + cxOtpMaxRequests = 5 + cxOtpRequestWin = time.Hour + + cxAccessTTL = time.Hour + cxRefreshTTL = 60 * 24 * time.Hour + + // cxCustomerRoleID is role 9 across this codebase. + cxCustomerRoleID = 9 + cxDefaultConfig = 1001 +) + +// ── Identifier handling ────────────────────────────────────────────────────── + +// normalizeIdentifier canonicalises what the customer typed into either an +// E.164 phone number or a lowercased email address. +// +// The app currently sends "+91 98765 43210" with spaces and is being tightened +// to send E.164; both are accepted, and both must land on the SAME stored +// value, or a customer signing in from a newer build gets a second account. +func normalizeIdentifier(raw string) (identifier, kind string, ok bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", false + } + if strings.Contains(raw, "@") { + email := strings.ToLower(raw) + // Cheap structural check only. Deliverability is proven by the code + // arriving, not by a regex. + at := strings.Index(email, "@") + if at < 1 || at == len(email)-1 || !strings.Contains(email[at:], ".") { + return "", "", false + } + return email, "email", true + } + return normalizePhone(raw) +} + +// normalizePhone reduces any of the shapes the app and support staff use to +// E.164 for India. +func normalizePhone(raw string) (phone, kind string, ok bool) { + var digits strings.Builder + plus := strings.HasPrefix(strings.TrimSpace(raw), "+") + for _, r := range raw { + if r >= '0' && r <= '9' { + digits.WriteRune(r) + } + } + d := digits.String() + + switch { + case plus && len(d) >= 11 && len(d) <= 15: + // Already international, spaces and dashes removed. + return "+" + d, "phone", true + case len(d) == 10: + // Bare national number, the common case from the keypad. + return "+91" + d, "phone", true + case len(d) == 12 && strings.HasPrefix(d, "91"): + return "+" + d, "phone", true + case len(d) == 11 && strings.HasPrefix(d, "0"): + return "+91" + d[1:], "phone", true + } + return "", "", false +} + +// splitName turns the single name field the app collects into the first/last +// columns appcustomers already has. A one-word name keeps an empty last name +// rather than being rejected — plenty of people have one. +func splitName(full string) (first, last string) { + full = strings.Join(strings.Fields(full), " ") + if len([]rune(full)) < 2 { + return "", "" + } + if i := strings.LastIndex(full, " "); i > 0 { + return full[:i], full[i+1:] + } + return full, "" +} + +func fullName(c *models.AppCustomer) string { + return strings.TrimSpace(c.Firstname + " " + c.Lastname) +} + +// renderCustomer is the one shape the customer object is returned in — from +// verify, from refresh and from /auth/me — so a cold-start session restore +// cannot disagree with what sign-in returned. +// +// email is never null. The client types it as a non-nullable String and a null +// throws in the parser; an unknown address is the empty string. +func renderCustomer(c *models.AppCustomer) fiber.Map { + return fiber.Map{ + "id": fmt.Sprintf("cust_%d", c.Appcustomerid), + "name": fullName(c), + "phone": c.Phone, + "email": c.Email, + } +} + +// ── OTP storage ────────────────────────────────────────────────────────────── + +func cxOtpKey(id string) string { return "cx:otp:" + id } +func cxOtpTriesKey(id string) string { return "cx:otp:" + id + ":tries" } +func cxOtpSentKey(id string) string { return "cx:otp:" + id + ":sent" } +func cxOtpRateKey(id string) string { return "cx:otp:" + id + ":requests" } + +func cxGenerateCode() string { + max := big.NewInt(1) + for i := 0; i < cxOtpLength; i++ { + max.Mul(max, big.NewInt(10)) + } + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "" + } + return fmt.Sprintf("%0*d", cxOtpLength, n.Int64()) +} + +// issueCxOtp mints, stores and delivers a code, enforcing both the per-hour +// request cap and the resend cooldown. Returns the seconds the client must wait +// before it may ask again — the countdown is server-driven so it can be changed +// without an app release. +func issueCxOtp(cfg *config.Config, identifier, kind string) (resendAfter int, err error) { + if db.Rdb == nil { + return 0, fmt.Errorf("verification service unavailable") + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + // Cooldown first: a customer hammering Resend should be told to wait, not + // spend one of their five hourly codes on a request that sends nothing. + if ttl, terr := db.Rdb.TTL(ctx, cxOtpSentKey(identifier)).Result(); terr == nil && ttl > 0 { + return int(ttl.Seconds()) + 1, errCxResendTooSoon + } + + count, ierr := db.Rdb.Incr(ctx, cxOtpRateKey(identifier)).Result() + if ierr == nil && count == 1 { + db.Rdb.Expire(ctx, cxOtpRateKey(identifier), cxOtpRequestWin) + } + if count > cxOtpMaxRequests { + return int(cxOtpRequestWin.Seconds()), errCxTooManyRequests + } + + code := sms.StagingCode() + if code == "" { + code = cxGenerateCode() + } + if code == "" { + return 0, fmt.Errorf("could not generate a verification code") + } + + if serr := db.Rdb.Set(ctx, cxOtpKey(identifier), code, cxOtpTTL).Err(); serr != nil { + return 0, serr + } + db.Rdb.Del(ctx, cxOtpTriesKey(identifier)) + db.Rdb.Set(ctx, cxOtpSentKey(identifier), "1", cxResendWait) + + if kind == "email" { + if merr := mail.SendOTPEmail(cfg, identifier, code); merr != nil { + utils.Warn("cx auth: failed to send OTP email", "error", merr) + return 0, merr + } + } else { + if serr := sms.SendOTP(identifier, code); serr != nil { + utils.Warn("cx auth: failed to send OTP sms", "error", serr) + return 0, serr + } + } + + return int(cxResendWait.Seconds()), nil +} + +var ( + errCxResendTooSoon = fmt.Errorf("resend too soon") + errCxTooManyRequests = fmt.Errorf("too many requests") +) + +// consumeCxOtp checks a submitted code and burns it. A code is single-use, and +// a wrong answer costs one of three attempts before the code is destroyed +// outright — otherwise a 4-digit space is walkable. +func consumeCxOtp(identifier, submitted string) bool { + if db.Rdb == nil { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + stored, err := db.Rdb.Get(ctx, cxOtpKey(identifier)).Result() + if err == goredis.Nil || err != nil { + return false + } + if stored != submitted { + tries, _ := db.Rdb.Incr(ctx, cxOtpTriesKey(identifier)).Result() + db.Rdb.Expire(ctx, cxOtpTriesKey(identifier), cxOtpTTL) + if tries >= cxOtpMaxVerify { + db.Rdb.Del(ctx, cxOtpKey(identifier), cxOtpTriesKey(identifier)) + } + return false + } + db.Rdb.Del(ctx, cxOtpKey(identifier), cxOtpTriesKey(identifier)) + return true +} + +// ── Handlers ───────────────────────────────────────────────────────────────── + +// CxRequestOtp sends a sign-in code to a phone or an email address. +// +// It answers the same way whether or not the identifier has an account. Telling +// an anonymous caller "no account found" — which the old /customer/login did — +// turns this endpoint into a directory of who is registered. +func CxRequestOtp(cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + var req struct { + Identifier string `json:"identifier"` + } + if err := c.BodyParser(&req); err != nil { + return utils.CxBadRequest(c, "We could not read that request") + } + + identifier, kind, ok := normalizeIdentifier(req.Identifier) + if !ok { + return utils.CxBadRequest(c, "Enter a valid phone number or email address") + } + + resendAfter, err := issueCxOtp(cfg, identifier, kind) + switch { + case err == errCxResendTooSoon: + // Not an error to the customer — they simply have to wait, and the + // screen already renders a countdown. + return utils.CxOK(c, fiber.Map{ + "sent": false, + "resendAfterSeconds": resendAfter, + "codeLength": cxOtpLength, + }) + case err == errCxTooManyRequests: + c.Set("Retry-After", fmt.Sprintf("%d", resendAfter)) + return utils.CxFail(c, fiber.StatusTooManyRequests, utils.CxErrRateLimited, + "Too many attempts. Try again in a minute") + case err != nil: + utils.Error("CxRequestOtp: could not issue code", "error", err) + return utils.CxInternal(c) + } + + return utils.CxOK(c, fiber.Map{ + "sent": true, + "resendAfterSeconds": resendAfter, + "codeLength": cxOtpLength, + }) + } +} + +// CxSignup creates the account and sends the code in one call. +// +// An existing phone number is NOT an error: it is treated as a sign-in and a +// code is sent. The app has no "account already exists" screen, and inventing +// one here would strand a returning customer who tapped Sign up out of habit. +func CxSignup(cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + var req struct { + Name string `json:"name"` + Phone string `json:"phone"` + Email string `json:"email"` + } + if err := c.BodyParser(&req); err != nil { + return utils.CxBadRequest(c, "We could not read that request") + } + + first, last := splitName(req.Name) + if first == "" { + return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name") + } + + phone, _, ok := normalizePhone(req.Phone) + if !ok { + return utils.CxBadRequest(c, "Enter a valid phone number") + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + + var existing models.AppCustomer + err := db.DB.Where("phone = ?", phone).First(&existing).Error + if err != nil { + // New account. It is created unverified in the sense that nothing + // is signed in yet — the token is only issued once the code comes + // back, so an unfinished signup leaves a row and no session. + customer := models.AppCustomer{ + Firstname: first, + Lastname: last, + Phone: phone, + Email: email, + Status: constants.CustomerStatusActive, + Configid: cxDefaultConfig, + } + if cerr := db.DB.Create(&customer).Error; cerr != nil { + utils.Error("CxSignup: could not create customer", "error", cerr) + return utils.CxInternal(c) + } + } else if existing.Status == constants.CustomerStatusBlocked { + return utils.CxForbidden(c, "You do not have access to this") + } + + resendAfter, ierr := issueCxOtp(cfg, phone, "phone") + switch { + case ierr == errCxResendTooSoon: + return utils.CxOK(c, fiber.Map{"sent": false, "resendAfterSeconds": resendAfter}) + case ierr == errCxTooManyRequests: + c.Set("Retry-After", fmt.Sprintf("%d", resendAfter)) + return utils.CxFail(c, fiber.StatusTooManyRequests, utils.CxErrRateLimited, + "Too many attempts. Try again in a minute") + case ierr != nil: + utils.Error("CxSignup: could not issue code", "error", ierr) + return utils.CxInternal(c) + } + + return utils.CxOK(c, fiber.Map{"sent": true, "resendAfterSeconds": resendAfter}) + } +} + +// CxVerifyOtp exchanges a code for a session. +func CxVerifyOtp(cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + var req struct { + Identifier string `json:"identifier"` + Code string `json:"code"` + Name string `json:"name"` + } + if err := c.BodyParser(&req); err != nil { + return utils.CxBadRequest(c, "We could not read that request") + } + + identifier, kind, ok := normalizeIdentifier(req.Identifier) + if !ok || strings.TrimSpace(req.Code) == "" { + return utils.CxBadRequest(c, "Enter the code we sent you") + } + + if !consumeCxOtp(identifier, strings.TrimSpace(req.Code)) { + return utils.CxFail(c, fiber.StatusUnauthorized, utils.CxErrInvalidOtp, "That code did not match") + } + + var customer models.AppCustomer + column := "phone" + if kind == "email" { + column = "email" + } + lookupErr := db.DB.Where(column+" = ?", identifier).First(&customer).Error + + if lookupErr != nil { + // Verified an identifier with no account behind it. That is a + // signup completing, and it needs a name — the account is worth + // nothing without one and the app collects it on the same screen. + if kind != "phone" { + return utils.CxNotFound(c, "We could not find an account for that address") + } + first, last := splitName(req.Name) + if first == "" { + return utils.CxFail(c, fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name") + } + customer = models.AppCustomer{ + Firstname: first, + Lastname: last, + Phone: identifier, + Status: constants.CustomerStatusActive, + Configid: cxDefaultConfig, + } + if cerr := db.DB.Create(&customer).Error; cerr != nil { + utils.Error("CxVerifyOtp: could not create customer", "error", cerr) + return utils.CxInternal(c) + } + } + + if customer.Status == constants.CustomerStatusBlocked { + return utils.CxForbidden(c, "You do not have access to this") + } + + // A name supplied on a verify for an existing account that has none + // (possible for a row created by ops or migrated in) is accepted; it is + // never allowed to overwrite a name already on file from a request that + // only proves possession of the phone. + if first, last := splitName(req.Name); first != "" && customer.Firstname == "" { + customer.Firstname, customer.Lastname = first, last + } + now := time.Now() + customer.Lastloginat = &now + if err := db.DB.Save(&customer).Error; err != nil { + utils.Warn("CxVerifyOtp: could not stamp last login", "error", err) + } + + return issueCxSession(c, cfg, &customer) + } +} + +// CxRefresh rotates a refresh token for a new pair. +// +// Rotation, not reuse: the presented token is revoked and a new one issued, so +// a token captured from an old device stops working the moment the real device +// refreshes. The chain is recorded via Replacedbyid, which is what makes a +// replayed old token identifiable rather than merely rejected. +func CxRefresh(cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + var req struct { + RefreshToken string `json:"refreshToken"` + } + if err := c.BodyParser(&req); err != nil { + return utils.CxBadRequest(c, "We could not read that request") + } + presented := strings.TrimSpace(req.RefreshToken) + if presented == "" { + return utils.CxUnauthorized(c, "Please sign in again") + } + + var row models.CustomerRefreshToken + if err := db.DB.Where("tokenhash = ?", hashToken(presented)).First(&row).Error; err != nil { + return utils.CxUnauthorized(c, "Please sign in again") + } + if row.Revokedat != nil { + // A revoked token coming back means either a stale client or a + // stolen one, and there is no way to tell them apart. Killing the + // whole chain costs the honest customer one sign-in and costs an + // attacker the session. + utils.Warn("cx auth: revoked refresh token replayed — revoking the customer's sessions", + "customer_id", row.Appcustomerid) + revokeCxSessions(row.Appcustomerid) + return utils.CxUnauthorized(c, "Please sign in again") + } + if utils.IST(row.Expiresat).Before(time.Now()) { + return utils.CxUnauthorized(c, "Please sign in again") + } + + var customer models.AppCustomer + if err := db.DB.First(&customer, row.Appcustomerid).Error; err != nil { + return utils.CxUnauthorized(c, "Please sign in again") + } + if customer.Status == constants.CustomerStatusBlocked { + return utils.CxForbidden(c, "You do not have access to this") + } + + revoked := time.Now() + row.Revokedat = &revoked + if err := db.DB.Save(&row).Error; err != nil { + utils.Error("CxRefresh: could not revoke the presented token", "error", err) + return utils.CxInternal(c) + } + + return issueCxSession(c, cfg, &customer) + } +} + +// CxLogout revokes the session and unregisters the device's push token, so a +// signed-out phone stops receiving another person's parcel updates. +func CxLogout(c *fiber.Ctx) error { + customerID := c.Locals("userid").(int) + + var req struct { + RefreshToken string `json:"refreshToken"` + DeviceToken string `json:"deviceToken"` + } + _ = c.BodyParser(&req) + + now := time.Now() + if strings.TrimSpace(req.RefreshToken) != "" { + db.DB.Model(&models.CustomerRefreshToken{}). + Where("tokenhash = ? AND appcustomerid = ?", hashToken(req.RefreshToken), customerID). + Update("revokedat", now) + } else { + // No token supplied — sign out everywhere rather than leave a session + // the customer believes they ended. + revokeCxSessions(customerID) + } + + if strings.TrimSpace(req.DeviceToken) != "" { + db.DB.Where("appcustomerid = ? AND token = ?", customerID, req.DeviceToken). + Delete(&models.CustomerDevice{}) + } + + return utils.CxOK(c, fiber.Map{"signedOut": true}) +} + +// CxMe returns the signed-in customer, for cold-start session restore. +func CxMe(c *fiber.Ctx) error { + customerID := c.Locals("userid").(int) + + var customer models.AppCustomer + if err := db.DB.First(&customer, customerID).Error; err != nil { + return utils.CxUnauthorized(c, "Please sign in again") + } + if customer.Status == constants.CustomerStatusBlocked { + return utils.CxForbidden(c, "You do not have access to this") + } + return utils.CxOK(c, renderCustomer(&customer)) +} + +// ── Session issuing ────────────────────────────────────────────────────────── + +// issueCxSession mints the access/refresh pair and answers in the shape verify +// and refresh both promise. +func issueCxSession(c *fiber.Ctx, cfg *config.Config, customer *models.AppCustomer) error { + // The JWT carries tenantid like every other token in this system. B2C + // bookings are not attributed to a tenant yet (that is an open business + // decision, not something to guess), so it is 0 here — but the claim is + // present, and every customer read is scoped by appcustomerid regardless. + access, err := utils.GenerateTokenWithTTL( + customer.Appcustomerid, customer.Phone, cxCustomerRoleID, 0, + customer.Configid, cfg.JWTSecret, cxAccessTTL) + if err != nil { + utils.Error("issueCxSession: could not mint access token", "error", err) + return utils.CxInternal(c) + } + + refresh, err := newRefreshToken() + if err != nil { + utils.Error("issueCxSession: could not mint refresh token", "error", err) + return utils.CxInternal(c) + } + row := models.CustomerRefreshToken{ + Appcustomerid: customer.Appcustomerid, + Tokenhash: hashToken(refresh), + Expiresat: utils.DBNow().Add(cxRefreshTTL), + } + if err := db.DB.Create(&row).Error; err != nil { + utils.Error("issueCxSession: could not store refresh token", "error", err) + return utils.CxInternal(c) + } + + return utils.CxOK(c, fiber.Map{ + "accessToken": access, + "refreshToken": refresh, + "expiresIn": int(cxAccessTTL.Seconds()), + "customer": renderCustomer(customer), + }) +} + +// newRefreshToken returns 32 bytes of entropy, hex encoded. Long enough that +// guessing is not a threat model. +func newRefreshToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// hashToken is what actually lands in the database. A refresh token is a +// bearer credential valid for sixty days; storing it in plaintext would make a +// database read equivalent to sixty days of account access. +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func revokeCxSessions(customerID int) { + if err := db.DB.Model(&models.CustomerRefreshToken{}). + Where("appcustomerid = ? AND revokedat IS NULL", customerID). + Update("revokedat", time.Now()).Error; err != nil { + utils.Error("revokeCxSessions: failed", "customer_id", customerID, "error", err) + } +} + +// joinNonEmpty builds a display line from the parts that actually exist, so a +// missing landmark does not leave ", , " in the middle of an address. +func joinNonEmpty(sep string, parts ...string) string { + kept := make([]string, 0, len(parts)) + for _, p := range parts { + if s := strings.TrimSpace(p); s != "" { + kept = append(kept, s) + } + } + return strings.Join(kept, sep) +} diff --git a/controllers/cxBookingController.go b/controllers/cxBookingController.go new file mode 100644 index 0000000..dae8706 --- /dev/null +++ b/controllers/cxBookingController.go @@ -0,0 +1,895 @@ +package controllers + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + "doormile/constants" + "doormile/db" + "doormile/internal/assignment" + "doormile/internal/cxstage" + "doormile/middlewares" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// Bookings — §9 of the contract. +// +// A customer books a PICKUP: one visit, 1..N destinations, and no tracking +// number anywhere in this file. Tracking numbers are minted per destination +// when the miler completes the pickup, because until the parcels are in +// someone's hands there is no shipment to track — only an intention to collect +// one. + +const ( + cxDefaultPageSize = 20 + cxMaxPageSize = 50 + + // cxAbsoluteMaxDestinations is a hard ceiling checked BEFORE any database + // work, independent of the configured per-city cap. + // + // The configured cap (customerbookinglimits.maxdestinations) is the real + // policy and stays authoritative — but reading it costs two queries, and + // the district lookup above it builds a `WHERE districtcode IN (...)` from + // however many entries the caller sent. Without a ceiling, a request + // carrying ten thousand destinations does all of that work before anything + // says no. + // + // Set well above any plausible policy so it never masks the real cap: this + // is a sanity guard on unbounded input, not a product limit. + cxAbsoluteMaxDestinations = 25 +) + +type cxDetailsInput struct { + Street *string `json:"street"` + Building *string `json:"building"` + Landmark *string `json:"landmark"` + RecipientName *string `json:"recipientName"` + RecipientPhone *string `json:"recipientPhone"` + Instructions *string `json:"instructions"` + Pin *struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + } `json:"pin"` + // CodAmount is money the customer wants collected at this door on their + // behalf. Doormile is the carrier, not the seller. + CodAmount *float64 `json:"codAmount"` +} + +type cxCreateBookingRequest struct { + Pickup struct { + Title string `json:"title"` + Sub string `json:"sub"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + } `json:"pickup"` + SlotID string `json:"slotId"` + Destinations []struct { + StateCode string `json:"stateCode"` + DistrictCode string `json:"districtCode"` + PackageCount int `json:"packageCount"` + Details *cxDetailsInput `json:"details"` + } `json:"destinations"` + // Estimate is what the customer was shown on Review. Recorded for dispute + // audit — when the settled price is questioned months later, the number on + // the screen is the fact that matters, not a re-run of today's pricing. + Estimate *struct { + Min int `json:"min"` + Max int `json:"max"` + } `json:"estimate"` +} + +// CreateCxBooking creates the pickup. +func CreateCxBooking(c *fiber.Ctx) error { + customerID := c.Locals("userid").(int) + + var req cxCreateBookingRequest + if err := c.BodyParser(&req); err != nil { + return utils.CxBadRequest(c, "We could not read that request") + } + + // "No destinations at all" and "a destination is missing its state or + // district" are different problems with different fixes, and the customer + // is shown this message verbatim. Telling someone who added nothing that + // "every destination needs a serviceable state and district" describes a + // problem they do not have and hides the one they do — they need to add a + // destination, not correct one. The estimate endpoint already says this + // correctly; these two now agree. + if len(req.Destinations) == 0 { + return utils.CxBadRequest(c, "Add at least one destination") + } + if strings.TrimSpace(req.SlotID) == "" { + return utils.CxBadRequest(c, "Pick a pickup slot") + } + // Cheapest validation first, before anything reaches the database. A slot id + // carries its own date, so a stale one is provably stale without a query — + // and this is the case an app left open across midnight actually hits. + if CxSlotDateIsPast(req.SlotID) { + return utils.CxBadRequest(c, "That pickup time has passed — pick a new slot") + } + // Unbounded input, bounded before it costs anything. The configured cap + // below is the real policy; this only stops a caller making the server do + // three queries and build an arbitrarily long IN clause to be told no. + if len(req.Destinations) > cxAbsoluteMaxDestinations { + return utils.CxBadRequest(c, "That is more destinations than one pickup can carry") + } + + // The pickup point has to be somewhere Doormile actually collects from. + // CityGateMiddleware sniffs the body for a `pickuppincode`, which this + // request shape does not have — its pickup is a title/sub/lat/lng from the + // place search — so it waves every customer booking through. The check is + // done here, against the pincode resolved from the coordinates. + pickupPincode := pincodeForPoint(req.Pickup.Lat, req.Pickup.Lng) + if _, served := middlewares.PincodeInOperatingCity(pickupPincode); !served { + return utils.CxFail(c, fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable, + "We are not collecting from that area yet") + } + + appLocationID := appLocationForPoint(req.Pickup.Lat, req.Pickup.Lng) + maxPackages, maxDestinations := CxBookingLimits(appLocationID) + if len(req.Destinations) > maxDestinations { + return utils.CxBadRequest(c, "Up to "+strconv.Itoa(maxDestinations)+" destinations per pickup") + } + + // Resolve every district in one query, then validate. Names are copied onto + // the destination rather than joined at read time — the client renders + // "Chennai, Tamil Nadu" straight from the booking. + codes := make([]string, 0, len(req.Destinations)) + for _, d := range req.Destinations { + codes = append(codes, strings.ToUpper(strings.TrimSpace(d.DistrictCode))) + } + var districtRows []models.ServiceableDistrict + if err := db.DB.Where("districtcode IN ?", codes).Find(&districtRows).Error; err != nil { + utils.Error("CreateCxBooking: district lookup failed", "error", err) + return utils.CxInternal(c) + } + districts := make(map[string]models.ServiceableDistrict, len(districtRows)) + for _, d := range districtRows { + districts[d.Districtcode] = d + } + + stateNames, err := cxStateNames(districtRows) + if err != nil { + utils.Error("CreateCxBooking: state lookup failed", "error", err) + return utils.CxInternal(c) + } + + totalPackages := 0 + for _, d := range req.Destinations { + code := strings.ToUpper(strings.TrimSpace(d.DistrictCode)) + district, ok := districts[code] + if !ok || strings.TrimSpace(d.StateCode) == "" { + return utils.CxBadRequest(c, "Every destination needs a serviceable state and district") + } + if !district.Available { + // The district was open when the customer picked it and closed + // before they confirmed. A distinct code, because the app has a + // specific recovery for it: send them back to change that one + // destination rather than to a generic retry. + return utils.CxFail(c, fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable, + "That district is no longer available") + } + packages := d.PackageCount + if packages < 1 { + packages = 1 + } + totalPackages += packages + } + if totalPackages > maxPackages { + return utils.CxBadRequest(c, "Up to "+strconv.Itoa(maxPackages)+" packages per pickup") + } + + slotFrom, slotTo, slotOK := ResolveCxSlot(req.SlotID) + if !slotOK { + return utils.CxBadRequest(c, "Pick a pickup slot") + } + + // An EXPIRED slot and a FULL slot are different failures and must not share + // a message. A slot id encodes its own date, so an app left open across + // midnight — or one that cached the slot list for a session — sends + // yesterday's window in good faith. Telling that customer the window "just + // filled up" is untrue and points them at the wrong recovery: they need to + // re-fetch the slot list, not try again for a place in a queue. + // + // 400 rather than 409 for the same reason. The contract maps 400/invalid to + // "Pick a pickup slot", which is exactly the action required, while 409 is + // the capacity race below. + if !slotFrom.After(utils.ISTNow()) { + return utils.CxBadRequest(c, "That pickup time has passed — pick a new slot") + } + + if !CxSlotHasCapacity(req.SlotID, req.Pickup.Lat, req.Pickup.Lng) { + return utils.CxConflict(c, "That pickup window just filled up") + } + + // Price the pickup as one visit. A failed estimate must never block a + // booking, so a zero range is stored rather than an error returned — the + // receipt settles on what the miler weighs regardless. + estimateDestinations := make([]cxEstimateDestination, 0, len(req.Destinations)) + for _, d := range req.Destinations { + estimateDestinations = append(estimateDestinations, cxEstimateDestination{ + StateCode: d.StateCode, + DistrictCode: d.DistrictCode, + PackageCount: d.PackageCount, + }) + } + quote := quoteCxPickup(req.Pickup.Lat, req.Pickup.Lng, estimateDestinations) + estimateMin, estimateMax := quote.Min, quote.Max + if req.Estimate != nil && req.Estimate.Max > 0 { + // The customer's number wins. They agreed to what was on their screen, + // and re-pricing at confirm time would quietly change the deal. + estimateMin, estimateMax = req.Estimate.Min, req.Estimate.Max + } + + now := utils.DBNow() + pickupFromDB := cxToDBTime(slotFrom) + pickupToDB := cxToDBTime(slotTo) + + first := req.Destinations[0] + firstDistrict := districts[strings.ToUpper(strings.TrimSpace(first.DistrictCode))] + + booking := models.PickupBooking{ + Bookingno: generateBookingNo(), + Appcustomerid: customerID, + Pickupaddress: joinNonEmpty(", ", req.Pickup.Title, req.Pickup.Sub), + Pickuptitle: req.Pickup.Title, + Pickupsub: req.Pickup.Sub, + Pickuppincode: pickupPincode, + + Pickuplatitude: req.Pickup.Lat, + Pickuplongitude: req.Pickup.Lng, + + // The flat delivery columns mirror destination 0. They are NOT the + // destination list — that lives in bookingdestinations — but the miler + // app, the hub console, the routing code and the hyperlocal check all + // read them, and leaving them empty would make a customer-app booking + // invisible to every one of those. Destination 0 is the one the rider + // is told about first, so it is the one that mirrors. + Deliveryaddress: cxDestinationAddress(first.Details, firstDistrict), + Deliverypincode: firstDistrict.Pincodeprefix, + Deliverylatitude: firstDistrict.Centrelatitude, + Deliverylongitude: firstDistrict.Centrelongitude, + Deliverycity: firstDistrict.Districtname, + + Bookingsource: constants.BookingSourceCustomerApp, + Pickupsourcetype: constants.PickupSourceCustomer, + Status: constants.BookingPendingPickup, + + Preferredpickupfrom: &pickupFromDB, + Preferredpickupto: &pickupToDB, + + Slotid: req.SlotID, + Customerstage: constants.CxStageBooked, + Customerstatus: constants.CxStatusActive, + Estimateminrupees: estimateMin, + Estimatemaxrupees: estimateMax, + Routekm: quote.RouteKM, + + Createdat: now, + Updatedat: now, + } + + if pin := cxFirstPin(first.Details); pin != nil { + booking.Deliverylatitude, booking.Deliverylongitude = pin[0], pin[1] + } + + tx := db.DB.Begin() + if err := tx.Create(&booking).Error; err != nil { + tx.Rollback() + utils.Error("CreateCxBooking: could not create booking", "customer_id", customerID, "error", err) + return utils.CxInternal(c) + } + + for i, d := range req.Destinations { + code := strings.ToUpper(strings.TrimSpace(d.DistrictCode)) + district := districts[code] + packages := d.PackageCount + if packages < 1 { + packages = 1 + } + + dest := models.BookingDestination{ + Bookingid: booking.Bookingid, + Seq: i, + Statecode: district.Statecode, + Statename: stateNames[district.Statecode], + Districtcode: district.Districtcode, + Districtname: district.Districtname, + Packagecount: packages, + Pincode: district.Pincodeprefix, + Createdat: now, + Updatedat: now, + } + applyCxDetails(&dest, d.Details) + + if err := tx.Create(&dest).Error; err != nil { + tx.Rollback() + utils.Error("CreateCxBooking: could not create destination", "booking_id", booking.Bookingid, "error", err) + return utils.CxInternal(c) + } + + // One parcel row per package, linked to its destination. The miler + // weighs and photographs each package at the door, so each needs a row + // to be weighed into — and each has to know which order it belongs to. + // Weight is deliberately left at zero: it is never collected from the + // customer, and a guess here would look like a measurement on the + // receipt. + for p := 0; p < packages; p++ { + parcel := models.BookingParcel{ + Bookingid: booking.Bookingid, + Bookingdestinationid: &dest.Bookingdestinationid, + Itemcategory: "General", + Createdat: now, + Updatedat: now, + } + if err := tx.Create(&parcel).Error; err != nil { + tx.Rollback() + utils.Error("CreateCxBooking: could not create parcel", "booking_id", booking.Bookingid, "error", err) + return utils.CxInternal(c) + } + } + } + + // The service option is what the REST of the platform reads a booking's + // value from, and it is not optional just because the customer surface + // keeps its own estimate columns: + // + // * MilerDeliverConsignment and MilerInwardConsignmentAtHub copy + // Estimatedprice onto BookingAssignment.ridercharges when a leg closes, + // and GET /miler/earnings sums that column — so with no row here every + // customer-app job a rider completes would show ₹0 on their Earnings + // screen. + // * The admin console's Orders list renders serviceoptions[0].estimatedprice + // as the Price column, which would read "N/A" for every customer booking. + // + // The midpoint of the band the customer was shown is the honest figure + // before the miler weighs anything, and it is what lookupDoormilePrice + // returns everywhere else in this codebase. + slaDue := cxToDBTime(slotTo.Add(48 * time.Hour)) + estimatedDelivery := cxToDBTime(slotTo.Add(24 * time.Hour)) + srvOption := models.BookingServiceOption{ + Bookingid: booking.Bookingid, + Servicetype: "Normal", + Estimatedprice: float64(estimateMin+estimateMax) / 2, + Estimateddeliveryat: &estimatedDelivery, + Sladueat: &slaDue, + Createdat: now, + } + if err := tx.Create(&srvOption).Error; err != nil { + tx.Rollback() + utils.Error("CreateCxBooking: could not create service option", "booking_id", booking.Bookingid, "error", err) + return utils.CxInternal(c) + } + + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: booking.Bookingid, + Stage: constants.CxStageBooked, + ActorType: constants.CxActorCustomer, + ActorID: &customerID, + Source: "POST /customer/bookings", + At: now, + }); err != nil { + tx.Rollback() + utils.Error("CreateCxBooking: could not record booked stage", "booking_id", booking.Bookingid, "error", err) + return utils.CxInternal(c) + } + + if err := tx.Commit().Error; err != nil { + utils.Error("CreateCxBooking: commit failed", "customer_id", customerID, "error", err) + return utils.CxInternal(c) + } + + // Fire-and-forget, after commit, exactly as the express path does. + go assignment.AssignCustomerMiler(booking.Bookingid) + publishCxBookingCreated(&booking, len(req.Destinations)) + + return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusCreated) +} + +// GetCxBookings backs the Orders tabs, Home's recent list and pull-to-refresh. +func GetCxBookings(c *fiber.Ctx) error { + customerID := c.Locals("userid").(int) + + limit := cxDefaultPageSize + if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 { + limit = v + } + if limit > cxMaxPageSize { + limit = cxMaxPageSize + } + + // One filter, applied to both the page and the count, so `total` is the size + // of the tab the customer is actually looking at. Counting every booking + // they have ever made would put "48" above a Cancelled tab holding two rows. + status := strings.ToLower(strings.TrimSpace(c.Query("status"))) + scoped := func() *gorm.DB { + q := db.DB.Model(&models.PickupBooking{}).Where("appcustomerid = ?", customerID) + switch status { + case constants.CxStatusActive: + // Rows written before this surface existed carry no customerstatus. + // Treating a null as active keeps a live booking visible rather + // than hiding it from the customer who is waiting on it. + // Parenthesised explicitly rather than relying on AND binding + // tighter than OR — the two readings differ by "shows every + // cancelled booking in the active tab", which is not a thing to + // leave to operator precedence. + q = q.Where("(customerstatus = ?) OR ((customerstatus IS NULL OR customerstatus = '') AND status <> ?)", + constants.CxStatusActive, constants.BookingCancelled) + case constants.CxStatusCompleted: + q = q.Where("customerstatus = ?", constants.CxStatusCompleted) + case constants.CxStatusCancelled: + q = q.Where("customerstatus = ? OR status = ?", constants.CxStatusCancelled, constants.BookingCancelled) + } + return q + } + + q := scoped() + + // Keyset pagination on the primary key. Offsets drift when a new booking + // lands mid-scroll, which shows the customer the same row twice. + if cursor := strings.TrimSpace(c.Query("cursor")); cursor != "" { + if after, err := strconv.Atoi(cursor); err == nil { + q = q.Where("bookingid < ?", after) + } + } + + var bookings []models.PickupBooking + if err := q.Order("bookingid DESC").Limit(limit + 1).Find(&bookings).Error; err != nil { + utils.Error("GetCxBookings: query failed", "customer_id", customerID, "error", err) + return utils.CxInternal(c) + } + + var nextCursor *string + if len(bookings) > limit { + bookings = bookings[:limit] + next := strconv.Itoa(bookings[len(bookings)-1].Bookingid) + nextCursor = &next + } + + bundle := loadCxBundle(bookings) + out := make([]fiber.Map, 0, len(bookings)) + for i := range bookings { + out = append(out, renderCxBooking(&bookings[i], bundle)) + } + + var total int64 + if err := scoped().Count(&total).Error; err != nil { + utils.Warn("GetCxBookings: count failed, reporting the page size", "customer_id", customerID, "error", err) + total = int64(len(out)) + } + + return utils.CxList(c, out, int(total), nextCursor) +} + +// GetCxBookingDetail is the canonical read — the tracking screen and the +// receipt are both rendered from it, and it is polled while tracking is open. +func GetCxBookingDetail(c *fiber.Ctx) error { + customerID := c.Locals("userid").(int) + reference := strings.TrimSpace(c.Params("reference")) + + booking, err := cxLoadBooking(customerID, reference) + if err != nil { + return utils.CxNotFound(c, "We could not find that pickup") + } + + bundle := loadCxBundle([]models.PickupBooking{*booking}) + payload := renderCxBooking(booking, bundle) + + // Polled every few seconds while the tracking screen is open. A 304 turns + // most of those polls into a header exchange instead of a full render. + if served := serveIfNotModified(c, payload); served { + return nil + } + c.Set("Cache-Control", "no-cache") + return utils.CxOK(c, payload) +} + +// GetCxOrder returns one order by tracking number, for push deep links. +// +// It answers with the whole booking object rather than a slimmer order shape — +// the client already parses this one, and a second shape for the same data is +// a second parser to keep in step. +func GetCxOrder(c *fiber.Ctx) error { + customerID := c.Locals("userid").(int) + trackingID := strings.TrimSpace(c.Params("trackingId")) + if trackingID == "" { + return utils.CxNotFound(c, "We could not find that order") + } + + var dest models.BookingDestination + if err := db.DB.Where("trackingno = ?", trackingID).First(&dest).Error; err != nil { + return utils.CxNotFound(c, "We could not find that order") + } + + var booking models.PickupBooking + if err := db.DB.Where("bookingid = ? AND appcustomerid = ?", dest.Bookingid, customerID). + First(&booking).Error; err != nil { + // The tracking number exists but belongs to someone else. Answered as + // not-found rather than forbidden: confirming that a tracking number is + // real tells an enumerating caller something they should not learn. + return utils.CxNotFound(c, "We could not find that order") + } + + bundle := loadCxBundle([]models.PickupBooking{booking}) + return utils.CxOK(c, renderCxBooking(&booking, bundle)) +} + +// CancelCxBooking cancels the whole pickup. +// +// Allowed through arrived and refused from picked_up onward. `cancellable` on +// the booking mirrors the same policy so the UI can hide the button, but this +// re-checks — the button state is a hint the client renders from a response +// that may be seconds old, never the authority. +func CancelCxBooking(c *fiber.Ctx) error { + customerID := c.Locals("userid").(int) + reference := strings.TrimSpace(c.Params("reference")) + + var req struct { + Reason string `json:"reason"` + } + _ = c.BodyParser(&req) + + booking, err := cxLoadBooking(customerID, reference) + if err != nil { + return utils.CxNotFound(c, "We could not find that pickup") + } + + if booking.Customerstatus == constants.CxStatusCancelled || + booking.Status == constants.BookingCancelled { + // Already cancelled. Answered as success rather than as a conflict: the + // customer asked for a state the booking is already in, and a retry + // over a flaky network must not read as a failure. + return utils.CxOK(c, fiber.Map{ + "reference": booking.Bookingno, + "status": constants.CxStatusCancelled, + "cancelReason": booking.Cancelreason, + }) + } + + stage := booking.Customerstage + if stage == "" { + stage = deriveStageFromStatus(booking) + } + if !constants.CxCancellable(stage) { + return utils.CxConflict(c, "This pickup can no longer be cancelled") + } + + reason := strings.TrimSpace(req.Reason) + + tx := db.DB.Begin() + if err := cxstage.Cancel(tx, booking.Bookingid, reason, + constants.CxActorCustomer, &customerID, "POST /customer/bookings/{reference}/cancel"); err != nil { + tx.Rollback() + utils.Error("CancelCxBooking: cancel failed", "booking_id", booking.Bookingid, "error", err) + return utils.CxInternal(c) + } + + // Release the rider. Without this the assignment stays open, the rider + // keeps a stop they must not attempt, and MilerEndDuty refuses to let them + // go off duty while any assignment is still Assigned or Accepted. + if err := tx.Model(&models.BookingAssignment{}). + Where("bookingid = ? AND assignmentstatus IN ?", booking.Bookingid, + []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). + Updates(map[string]interface{}{ + "assignmentstatus": constants.AssignmentCancelled, + "remarks": "cancelled by customer", + }).Error; err != nil { + tx.Rollback() + utils.Error("CancelCxBooking: could not release assignment", "booking_id", booking.Bookingid, "error", err) + return utils.CxInternal(c) + } + if booking.Assignedmileruserid != nil { + if err := tx.Model(&models.MilerProfile{}). + Where("userid = ? AND availabilitystatus IN ?", *booking.Assignedmileruserid, + []string{constants.MilerAssigned, constants.MilerOnPickup, constants.MilerAtCustomer}). + Update("availabilitystatus", constants.MilerAvailable).Error; err != nil { + tx.Rollback() + utils.Error("CancelCxBooking: could not free the rider", "booking_id", booking.Bookingid, "error", err) + return utils.CxInternal(c) + } + } + + if err := tx.Commit().Error; err != nil { + utils.Error("CancelCxBooking: commit failed", "booking_id", booking.Bookingid, "error", err) + return utils.CxInternal(c) + } + + publishCxBookingCancelled(booking, reason) + + return utils.CxOK(c, fiber.Map{ + "reference": booking.Bookingno, + "status": constants.CxStatusCancelled, + "cancelReason": reason, + }) +} + +// PatchCxDestination fills in the parts of an address the customer left out. +// +// Accepted until the parcels are collected. After that the shipment's addresses +// are frozen on the consignment and an edit here would change what the customer +// sees without changing where the parcel is going — which is worse than +// refusing. +// +// Writes land on the same booking row the miler app reads addresses from, so a +// correction made while the rider is on their way reaches them. +func PatchCxDestination(c *fiber.Ctx) error { + customerID := c.Locals("userid").(int) + reference := strings.TrimSpace(c.Params("reference")) + + index, err := strconv.Atoi(c.Params("index")) + if err != nil || index < 0 { + return utils.CxNotFound(c, "We could not find that destination") + } + + booking, err := cxLoadBooking(customerID, reference) + if err != nil { + return utils.CxNotFound(c, "We could not find that pickup") + } + + stage := booking.Customerstage + if stage == "" { + stage = deriveStageFromStatus(booking) + } + if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStagePickedUp] { + return utils.CxConflict(c, "Your packages have been collected — these details can no longer be changed") + } + if booking.Customerstatus == constants.CxStatusCancelled || booking.Status == constants.BookingCancelled { + return utils.CxConflict(c, "This pickup was cancelled") + } + + var dest models.BookingDestination + if err := db.DB.Where("bookingid = ? AND seq = ?", booking.Bookingid, index). + First(&dest).Error; err != nil { + return utils.CxNotFound(c, "We could not find that destination") + } + + var req cxDetailsInput + if err := c.BodyParser(&req); err != nil { + return utils.CxBadRequest(c, "We could not read that request") + } + + applyCxDetails(&dest, &req) + dest.Updatedat = utils.DBNow() + + tx := db.DB.Begin() + if err := tx.Save(&dest).Error; err != nil { + tx.Rollback() + utils.Error("PatchCxDestination: save failed", "destination_id", dest.Bookingdestinationid, "error", err) + return utils.CxInternal(c) + } + + // Destination 0 mirrors onto the booking's flat delivery columns, which is + // where the miler app and the routing code look. An edit that only landed + // on bookingdestinations would be invisible to the rider standing at the + // door, which is precisely who it was made for. + if dest.Seq == 0 { + updates := map[string]interface{}{ + "deliveryaddress": cxDestinationAddressFromRow(&dest), + "updatedat": utils.DBNow(), + } + if dest.Pinlatitude != nil && dest.Pinlongitude != nil { + updates["deliverylatitude"] = *dest.Pinlatitude + updates["deliverylongitude"] = *dest.Pinlongitude + } + if err := tx.Model(&models.PickupBooking{}). + Where("bookingid = ?", booking.Bookingid).Updates(updates).Error; err != nil { + tx.Rollback() + utils.Error("PatchCxDestination: could not mirror onto booking", "booking_id", booking.Bookingid, "error", err) + return utils.CxInternal(c) + } + } + + if err := tx.Commit().Error; err != nil { + utils.Error("PatchCxDestination: commit failed", "booking_id", booking.Bookingid, "error", err) + return utils.CxInternal(c) + } + + return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusOK) +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +// cxLoadBooking finds a booking by its customer-facing reference, scoped to the +// caller. Ownership is part of the lookup, not a check afterwards — a query +// that can return someone else's row is one forgotten `if` away from leaking it. +func cxLoadBooking(customerID int, reference string) (*models.PickupBooking, error) { + if reference == "" { + return nil, gorm.ErrRecordNotFound + } + var booking models.PickupBooking + if err := db.DB.Where("bookingno = ? AND appcustomerid = ?", reference, customerID). + First(&booking).Error; err != nil { + return nil, err + } + return &booking, nil +} + +// cxRespondWithBooking re-reads and renders, so a create or a patch answers in +// exactly the shape a later GET will. +func cxRespondWithBooking(c *fiber.Ctx, bookingID, status int) error { + var booking models.PickupBooking + if err := db.DB.First(&booking, bookingID).Error; err != nil { + utils.Error("cxRespondWithBooking: reload failed", "booking_id", bookingID, "error", err) + return utils.CxInternal(c) + } + bundle := loadCxBundle([]models.PickupBooking{booking}) + payload := renderCxBooking(&booking, bundle) + if status == fiber.StatusCreated { + return utils.CxCreated(c, payload) + } + return utils.CxOK(c, payload) +} + +// applyCxDetails writes only the fields actually present in the request. An +// omitted key leaves the stored value alone; an explicit null clears it, which +// is how the customer removes a landmark they no longer want the rider to use. +func applyCxDetails(dest *models.BookingDestination, in *cxDetailsInput) { + if in == nil { + return + } + if in.Street != nil { + dest.Street = strings.TrimSpace(*in.Street) + } + if in.Building != nil { + dest.Building = strings.TrimSpace(*in.Building) + } + if in.Landmark != nil { + dest.Landmark = strings.TrimSpace(*in.Landmark) + } + if in.RecipientName != nil { + dest.Recipientname = strings.TrimSpace(*in.RecipientName) + } + if in.RecipientPhone != nil { + if phone, _, ok := normalizePhone(*in.RecipientPhone); ok { + dest.Recipientphone = phone + } else { + dest.Recipientphone = strings.TrimSpace(*in.RecipientPhone) + } + } + if in.Instructions != nil { + dest.Instructions = strings.TrimSpace(*in.Instructions) + } + if in.Pin != nil { + lat, lng := in.Pin.Lat, in.Pin.Lng + if lat == 0 && lng == 0 { + dest.Pinlatitude, dest.Pinlongitude = nil, nil + } else { + dest.Pinlatitude, dest.Pinlongitude = &lat, &lng + } + } + if in.CodAmount != nil { + dest.Codamount = *in.CodAmount + } +} + +func cxFirstPin(in *cxDetailsInput) *[2]float64 { + if in == nil || in.Pin == nil { + return nil + } + if in.Pin.Lat == 0 && in.Pin.Lng == 0 { + return nil + } + return &[2]float64{in.Pin.Lat, in.Pin.Lng} +} + +// cxDestinationAddress builds the flat address string the rest of the system +// stores, from whatever the customer supplied. It always resolves to something +// non-empty — pickupbookings.deliveryaddress is NOT NULL, and a booking with +// only a state and a district is a legitimate booking. +func cxDestinationAddress(in *cxDetailsInput, district models.ServiceableDistrict) string { + parts := []string{} + if in != nil { + if in.Building != nil { + parts = append(parts, *in.Building) + } + if in.Street != nil { + parts = append(parts, *in.Street) + } + if in.Landmark != nil { + parts = append(parts, *in.Landmark) + } + } + parts = append(parts, district.Districtname) + address := joinNonEmpty(", ", parts...) + if address == "" { + return district.Districtcode + } + return address +} + +func cxDestinationAddressFromRow(d *models.BookingDestination) string { + address := joinNonEmpty(", ", d.Building, d.Street, d.Landmark, d.Districtname) + if address == "" { + return d.Districtcode + } + return address +} + +// cxStateNames resolves display names for the states a set of districts belong +// to, in one query. +func cxStateNames(districts []models.ServiceableDistrict) (map[string]string, error) { + codes := make([]string, 0, len(districts)) + seen := map[string]bool{} + for _, d := range districts { + if d.Statecode != "" && !seen[d.Statecode] { + seen[d.Statecode] = true + codes = append(codes, d.Statecode) + } + } + names := make(map[string]string, len(codes)) + if len(codes) == 0 { + return names, nil + } + var states []models.ServiceableState + if err := db.DB.Where("statecode IN ?", codes).Find(&states).Error; err != nil { + return names, err + } + for _, s := range states { + names[s.Statecode] = s.Statename + } + return names, nil +} + +// cxToDBTime converts an IST wall clock into the shape this database stores — +// the same digits, tagged UTC so the driver writes them verbatim. See +// utils.DBNow: comparing a container's UTC clock against IST-stamped rows is +// what made date-range reports undercount. +func cxToDBTime(t time.Time) time.Time { + ist := t.In(utils.ISTLocation()) + return time.Date(ist.Year(), ist.Month(), ist.Day(), ist.Hour(), ist.Minute(), ist.Second(), 0, time.UTC) +} + +// ── Events ─────────────────────────────────────────────────────────────────── + +// publishCxBookingCreated and publishCxBookingCancelled mirror the existing +// booking events onto NATS. Best-effort and nil-checked, like every other +// publish in this codebase: the event bus is never allowed to fail a booking. +func publishCxBookingCreated(b *models.PickupBooking, destinationCount int) { + if db.Js == nil { + return + } + payload := map[string]interface{}{ + "booking_id": b.Bookingid, + "booking_no": b.Bookingno, + "customer_id": b.Appcustomerid, + "pickup_address": b.Pickupaddress, + "pickup_pincode": b.Pickuppincode, + "destination_count": destinationCount, + "slot_id": b.Slotid, + "status": constants.BookingPendingPickup, + "created_at": utils.EpochMillis(b.Createdat), + } + data, err := json.Marshal(payload) + if err != nil { + return + } + if _, err := db.Js.Publish("api.v1.bookings.create", data); err != nil { + utils.Warn("Failed to publish booking.create to NATS", "booking_id", b.Bookingid, "error", err) + } +} + +func publishCxBookingCancelled(b *models.PickupBooking, reason string) { + if db.Js == nil { + return + } + payload := map[string]interface{}{ + "booking_id": b.Bookingid, + "booking_no": b.Bookingno, + "customer_id": b.Appcustomerid, + "status": "Cancelled", + "reason": reason, + "cancelled_at": time.Now().UnixMilli(), + } + data, err := json.Marshal(payload) + if err != nil { + return + } + if _, err := db.Js.Publish("api.v1.bookings.cancel", data); err != nil { + utils.Warn("Failed to publish booking.cancel to NATS", "booking_id", b.Bookingid, "error", err) + } +} diff --git a/controllers/cxBookingView.go b/controllers/cxBookingView.go new file mode 100644 index 0000000..ba68500 --- /dev/null +++ b/controllers/cxBookingView.go @@ -0,0 +1,760 @@ +package controllers + +import ( + "context" + "os" + "strconv" + "strings" + "time" + + "doormile/constants" + "doormile/db" + "doormile/internal/storage" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" +) + +// The canonical booking object — §9.3. +// +// The tracking screen and the receipt are both rendered from this one shape, so +// it is built in exactly one place and every read that returns a booking goes +// through it. A list row and a detail read differing in shape is how a client +// ends up with two parsers for one object. +// +// Two rules the client's type declarations depend on, and which will throw in +// its parser if broken: +// - pickup and slotId are present on EVERY booking, cancelled ones included. +// - destinations[].stateName / districtName are always populated; the client +// renders "Chennai, Tamil Nadu" from them and never looks a code up. + +// cxPhotoTTL is how long a parcel-photo link stays valid. Long enough to open +// the receipt, read it and come back; short enough that a link forwarded on is +// dead by the time it is opened. +const cxPhotoTTL = 30 * time.Minute + +// cxBookingBundle is everything one or more bookings need in order to render, +// loaded in batch. Building it per booking would put six queries behind a +// tracking poll that runs every few seconds. +type cxBookingBundle struct { + destinations map[int][]models.BookingDestination + events map[int][]models.BookingStageEvent + photos map[int][]models.BookingParcelPhoto // keyed by destination id + milers map[int]cxAgent // keyed by miler user id + assignedTo map[int]int // booking id -> miler user id + deliveryAgent map[int]int // destination id -> agent user id + payments map[int]float64 // booking id -> settled rupees + districts map[string]models.ServiceableDistrict + hubNames map[int]string + // single marks a one-booking read (the tracking screen or the receipt), as + // opposed to a page of them. A couple of fields are worth a live lookup for + // one booking and are not worth twenty of them for a list. + single bool +} + +type cxAgent struct { + Name string + Vehicle string + Phone string + Rating float64 + Trips int + VehicleType string + Lat, Lng float64 +} + +// renderCxBooking projects one booking into the customer contract. +func renderCxBooking(b *models.PickupBooking, bundle *cxBookingBundle) fiber.Map { + destinations := bundle.destinations[b.Bookingid] + + stage := b.Customerstage + if stage == "" { + // A booking written before this surface existed, or one created through + // the console. Deriving a stage from the operational status is honest + // about where the parcel is; inventing history for it is not, so its + // timeline stays as short as the events actually recorded. + stage = deriveStageFromStatus(b) + } + status := b.Customerstatus + if status == "" { + status = deriveStatus(b, stage) + } + // The OPERATIONAL status is the authority on cancellation, whatever the + // stored customer status says. + // + // Three console paths cancel a booking by writing pickupbookings.status + // directly — AdminCancelBooking, AdminBulkCancelBookings and + // AdminUpdateBookingStatus (which accepts an arbitrary status string). None + // of them knows this projection exists. Without this line a customer whose + // pickup ops cancelled would keep seeing it as active and cancellable + // forever, because customerstatus was written as "active" at booking time + // and the empty-string fallback above never fires. + // + // Done here rather than only at those call sites so that a cancel path + // added later cannot reintroduce the same divergence. + if b.Status == constants.BookingCancelled { + status = constants.CxStatusCancelled + } + + out := fiber.Map{ + "reference": b.Bookingno, + "stage": stage, + "status": status, + "cancellable": status == constants.CxStatusActive && constants.CxCancellable(stage), + "createdAt": utils.EpochMillis(b.Createdat), + + "pickup": fiber.Map{ + "title": cxPickupTitle(b), + "sub": cxPickupSub(b), + "lat": b.Pickuplatitude, + "lng": b.Pickuplongitude, + }, + "slotId": b.Slotid, + + "destinations": renderCxDestinations(destinations, bundle), + + "miler": nil, + "deliveryAgent": nil, + + "milerDistanceKm": nil, + "milerEtaMinutes": nil, + "milersInZone": 0, + + "routeKm": b.Routekm, + "expectedDelivery": cxExpectedDelivery(destinations), + + "fare": fiber.Map{ + "min": b.Estimateminrupees, + "max": b.Estimatemaxrupees, + "paymentMethod": "UPI · Cash at doorstep", + "parcel": describeParcels(totalPackages(destinations)), + }, + "amountPaid": nil, + "deliveredAt": nil, + "cancelReason": nil, + + "history": renderCxHistory(bundle.events[b.Bookingid]), + } + + if b.Cancelreason != "" { + out["cancelReason"] = b.Cancelreason + } + + // The assigned miler, from the stage they are assigned onward. + if milerUserID, ok := bundle.assignedTo[b.Bookingid]; ok { + if agent, found := bundle.milers[milerUserID]; found { + out["miler"] = renderCxAgent(agent) + + // Distance and ETA are live facts about a rider en route, so they + // are computed from the rider's current position rather than + // stored. Only meaningful while they are actually coming: after + // pickup the number would describe a journey that already ended. + if stage == constants.CxStageOnTheWay || stage == constants.CxStageArrived { + km, eta := cxRiderApproach(agent, b, stage) + out["milerDistanceKm"] = km + out["milerEtaMinutes"] = eta + } + } + } else if stage == constants.CxStageBooked && bundle.single { + // Nobody assigned yet — the tracking screen shows how many riders are + // in the zone instead, which is the only honest thing to say while + // searching. + // + // Only on a single-booking read. This is a Redis GEOSEARCH per booking, + // and running it across a 20-row Orders list would put twenty of them + // behind one page load to fill a line the list does not render. + out["milersInZone"] = milersWithin(b.Pickuplatitude, b.Pickuplongitude, cxMilersNearbyRadiusKM) + } + + // The delivering rider, once any order is out for delivery. Taken from the + // first destination that has one, since the booking-level field describes + // the leg the customer is currently watching. + for _, d := range destinations { + if agentUserID, ok := bundle.deliveryAgent[d.Bookingdestinationid]; ok { + if agent, found := bundle.milers[agentUserID]; found { + out["deliveryAgent"] = renderCxAgent(agent) + break + } + } + } + + // amountPaid is present from picked_up. The fare block stays on the booking + // forever alongside it — the receipt renders amountPaid − fare.min as the + // weight adjustment, so losing the original estimate would lose the + // explanation for the difference. + if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStagePickedUp] { + if paid, ok := bundle.payments[b.Bookingid]; ok { + out["amountPaid"] = int(paid) + } + } + + if delivered := cxAllDeliveredAt(destinations); delivered != nil { + out["deliveredAt"] = utils.EpochMillis(*delivered) + } + + return out +} + +func renderCxAgent(a cxAgent) fiber.Map { + return fiber.Map{ + "name": a.Name, + "vehicle": a.Vehicle, + "phone": a.Phone, + "rating": a.Rating, + "trips": a.Trips, + "vehicleType": a.VehicleType, + } +} + +func renderCxDestinations(destinations []models.BookingDestination, bundle *cxBookingBundle) []fiber.Map { + out := make([]fiber.Map, 0, len(destinations)) + for i := range destinations { + d := destinations[i] + + row := fiber.Map{ + "stateCode": d.Statecode, + "stateName": d.Statename, + "districtCode": d.Districtcode, + "districtName": d.Districtname, + "packageCount": d.Packagecount, + "district": nil, + "details": renderCxDetails(&d), + "trackingId": nil, + "stage": nil, + "verification": nil, + } + + if district, ok := bundle.districts[d.Districtcode]; ok { + card := fiber.Map{ + "code": district.Districtcode, + "name": district.Districtname, + "available": district.Available, + } + if district.Hubid != nil { + if name, found := bundle.hubNames[*district.Hubid]; found { + card["hub"] = name + } + } + if district.Promise != "" { + card["promise"] = district.Promise + } + row["district"] = card + } + + // Null until order_created — there is no order to track before the + // parcels have actually been collected. + if d.Trackingno != "" { + row["trackingId"] = d.Trackingno + } + if d.Stage != "" { + row["stage"] = d.Stage + } + + // Null until picked_up: the weight and the photographs are what the + // miler recorded at the door and cannot exist before they were there. + if d.Verifiedweightkg != nil && d.Verifiedat != nil { + verification := fiber.Map{ + "weightKg": *d.Verifiedweightkg, + "photos": cxPhotoURLs(bundle.photos[d.Bookingdestinationid]), + "capturedAt": utils.EpochMillis(*d.Verifiedat), + "capturedBy": "", + } + if d.Verifiedbyuserid != nil { + if agent, ok := bundle.milers[*d.Verifiedbyuserid]; ok { + verification["capturedBy"] = agent.Name + } + } + row["verification"] = verification + } + + out = append(out, row) + } + return out +} + +// renderCxDetails returns only the fields that were actually filled in. The UI +// renders a missing one as "Not added — the Miler can confirm this at pickup", +// which is a real state and not an error: a customer may legitimately book with +// nothing but a state and a district. +func renderCxDetails(d *models.BookingDestination) fiber.Map { + details := fiber.Map{} + if d.Street != "" { + details["street"] = d.Street + } + if d.Building != "" { + details["building"] = d.Building + } + if d.Landmark != "" { + details["landmark"] = d.Landmark + } + if d.Recipientname != "" { + details["recipientName"] = d.Recipientname + } + if d.Recipientphone != "" { + details["recipientPhone"] = d.Recipientphone + } + if d.Instructions != "" { + details["instructions"] = d.Instructions + } + if d.Pinlatitude != nil && d.Pinlongitude != nil { + details["pin"] = fiber.Map{"lat": *d.Pinlatitude, "lng": *d.Pinlongitude} + } + return details +} + +// cxPhotoURLs signs each parcel photograph for the length of a receipt view. +func cxPhotoURLs(photos []models.BookingParcelPhoto) []string { + urls := make([]string, 0, len(photos)) + for _, p := range photos { + url, err := storage.PresignGet(p.Objectkey, cxPhotoTTL) + if err != nil { + utils.Warn("cxPhotoURLs: could not sign parcel photo", "key", p.Objectkey, "error", err) + continue + } + urls = append(urls, url) + } + return urls +} + +// renderCxHistory turns the event log into the timeline. Ordered oldest-first +// and carrying only real timestamps — every entry on the customer's timeline +// comes from a row that a real write created. +func renderCxHistory(events []models.BookingStageEvent) []fiber.Map { + // One entry per stage. A multi-destination pickup emits a per-order stage + // once per destination, so those have to collapse — and WHICH of them the + // timeline shows is not arbitrary: + // + // * Booking-level stages (booked..order_created) happen once for the whole + // pickup, so the first event is the only event. + // * Per-order stages (in_transit..delivered) are reached by the booking + // when its SLOWEST order gets there, matching how cxstage rolls the + // booking up. Taking the first would timestamp "Delivered" at the moment + // the earliest parcel landed while deliveredAt reports the last one — + // the same screen contradicting itself. + at := map[string]time.Time{} + order := make([]string, 0, len(events)) + + for _, e := range events { + // Cancellation and release rows are audit records rather than progress — + // they carry a stage key only because the table needs one. Putting them + // on the timeline would show the customer "Pickup booked" a second time + // when a rider handed their pickup back. + if strings.HasPrefix(e.Remarks, "cancelled:") || strings.HasPrefix(e.Remarks, "released:") { + continue + } + + existing, seen := at[e.Stage] + if !seen { + at[e.Stage] = e.Occurredat + order = append(order, e.Stage) + continue + } + if constants.CxStageRank(e.Stage) >= constants.CxStageOrder[constants.CxStageInTransit] && + e.Occurredat.After(existing) { + at[e.Stage] = e.Occurredat + } + } + + out := make([]fiber.Map, 0, len(order)) + for _, stage := range order { + out = append(out, fiber.Map{ + "stage": stage, + "at": utils.EpochMillis(at[stage]), + }) + } + return out +} + +// ── Derivations ────────────────────────────────────────────────────────────── + +// deriveStageFromStatus gives a stage to a booking that has none: rows written +// before this surface existed, and console-created express bookings that never +// went through the customer flow. +func deriveStageFromStatus(b *models.PickupBooking) string { + switch b.Status { + case constants.BookingCancelled: + return constants.CxStageBooked + case constants.BookingPickedUp: + return constants.CxStagePickedUp + case constants.BookingConvertedConsignment: + return constants.CxStageOrderCreated + case constants.BookingMilerAssigned, constants.BookingPickupScheduled: + // reachedat is the only durable record that the rider actually got + // there — the operational flow records arrival as a fact rather than a + // status, so this is the one place it can be read from. + if b.Arrivedat != nil { + return constants.CxStageArrived + } + return constants.CxStageAssigned + default: + return constants.CxStageBooked + } +} + +func deriveStatus(b *models.PickupBooking, stage string) string { + if b.Status == constants.BookingCancelled { + return constants.CxStatusCancelled + } + if stage == constants.CxStageDelivered { + return constants.CxStatusCompleted + } + return constants.CxStatusActive +} + +// cxPickupTitle / cxPickupSub give the pickup block its two lines. The stored +// title/sub pair is used when the booking came through the customer app; a +// console-created booking only has one flat address string, so it is split +// rather than left half-empty — the client types both as non-nullable. +func cxPickupTitle(b *models.PickupBooking) string { + if b.Pickuptitle != "" { + return b.Pickuptitle + } + address := strings.TrimSpace(b.Pickupaddress) + if address == "" { + return "Pickup address" + } + if i := strings.Index(address, ","); i > 0 { + return strings.TrimSpace(address[:i]) + } + if len([]rune(address)) > 32 { + return string([]rune(address)[:32]) + } + return address +} + +func cxPickupSub(b *models.PickupBooking) string { + if b.Pickupsub != "" { + return b.Pickupsub + } + sub := joinNonEmpty(", ", strings.TrimSpace(b.Pickupaddress), b.Pickuppincode) + if sub == "" { + return "Address not recorded" + } + return sub +} + +// cxExpectedDelivery is a display string, formatted server-side in IST, so the +// client never has to know the operating timezone. The latest promise across +// the destinations is the one shown: a booking is not fully delivered until its +// last parcel is. +func cxExpectedDelivery(destinations []models.BookingDestination) string { + var latest *time.Time + for i := range destinations { + d := destinations[i] + if d.Expecteddeliveryat == nil { + continue + } + if latest == nil || d.Expecteddeliveryat.After(*latest) { + latest = d.Expecteddeliveryat + } + } + if latest == nil { + return "" + } + return utils.FormatISTDate(*latest) +} + +// cxAllDeliveredAt returns when the LAST parcel landed, or nil while any is +// still moving. +func cxAllDeliveredAt(destinations []models.BookingDestination) *time.Time { + if len(destinations) == 0 { + return nil + } + var latest *time.Time + for i := range destinations { + d := destinations[i] + if d.Deliveredat == nil { + return nil + } + if latest == nil || d.Deliveredat.After(*latest) { + latest = d.Deliveredat + } + } + return latest +} + +func totalPackages(destinations []models.BookingDestination) int { + n := 0 + for _, d := range destinations { + n += d.Packagecount + } + if n == 0 { + return 1 + } + return n +} + +// cxRiderApproach reports how far the rider still is and roughly how long that +// takes. At the door both are zero — a rider standing at the address is not +// "0.4 km away", and the screen says Arrived. +func cxRiderApproach(agent cxAgent, b *models.PickupBooking, stage string) (float64, int) { + if stage == constants.CxStageArrived { + return 0, 0 + } + lat, lng := agent.Lat, agent.Lng + if lat == 0 && lng == 0 { + return 0, 0 + } + km := calculateDistance(lat, lng, b.Pickuplatitude, b.Pickuplongitude) + km = float64(int(km*10+0.5)) / 10 + + // 18 km/h is a two-wheeler in Indian city traffic, plus a two-minute floor + // for parking and finding the door. Deliberately a rough number: the app + // shows it as an approximation and a precise-looking ETA that slips reads + // worse than an honest one. + const avgSpeedKMH = 18.0 + eta := int(km/avgSpeedKMH*60) + 2 + return km, eta +} + +// ── Bundle loading ─────────────────────────────────────────────────────────── + +// loadCxBundle fetches everything a page of bookings needs, in a fixed number +// of queries regardless of how many bookings or destinations are involved. +func loadCxBundle(bookings []models.PickupBooking) *cxBookingBundle { + bundle := &cxBookingBundle{ + destinations: map[int][]models.BookingDestination{}, + events: map[int][]models.BookingStageEvent{}, + photos: map[int][]models.BookingParcelPhoto{}, + milers: map[int]cxAgent{}, + assignedTo: map[int]int{}, + deliveryAgent: map[int]int{}, + payments: map[int]float64{}, + districts: map[string]models.ServiceableDistrict{}, + hubNames: map[int]string{}, + } + bundle.single = len(bookings) == 1 + + if len(bookings) == 0 { + return bundle + } + + bookingIDs := make([]int, 0, len(bookings)) + for _, b := range bookings { + bookingIDs = append(bookingIDs, b.Bookingid) + } + + var destinations []models.BookingDestination + if err := db.DB.Where("bookingid IN ?", bookingIDs). + Order("bookingid ASC, seq ASC").Find(&destinations).Error; err != nil { + utils.Error("loadCxBundle: destinations query failed", "error", err) + } + destIDs := make([]int, 0, len(destinations)) + districtCodes := map[string]bool{} + for _, d := range destinations { + bundle.destinations[d.Bookingid] = append(bundle.destinations[d.Bookingid], d) + destIDs = append(destIDs, d.Bookingdestinationid) + if d.Districtcode != "" { + districtCodes[d.Districtcode] = true + } + } + + var events []models.BookingStageEvent + if err := db.DB.Where("bookingid IN ?", bookingIDs). + Order("occurredat ASC, stageeventid ASC").Find(&events).Error; err != nil { + utils.Error("loadCxBundle: stage events query failed", "error", err) + } + for _, e := range events { + bundle.events[e.Bookingid] = append(bundle.events[e.Bookingid], e) + } + + if len(destIDs) > 0 { + var photos []models.BookingParcelPhoto + if err := db.DB.Where("bookingdestinationid IN ?", destIDs). + Order("capturedat ASC").Find(&photos).Error; err != nil { + utils.Warn("loadCxBundle: parcel photos query failed", "error", err) + } + for _, p := range photos { + if p.Bookingdestinationid != nil { + bundle.photos[*p.Bookingdestinationid] = append(bundle.photos[*p.Bookingdestinationid], p) + } + } + } + + milerIDs := map[int]bool{} + + // The live assignment, if any. Rejected and cancelled assignments are not + // the current rider and must not be shown as one. + var assignments []models.BookingAssignment + if err := db.DB.Where("bookingid IN ? AND assignmentstatus IN ?", bookingIDs, + []string{constants.AssignmentAssigned, constants.AssignmentAccepted, constants.AssignmentCompleted}). + Order("assignedat ASC").Find(&assignments).Error; err != nil { + utils.Warn("loadCxBundle: assignments query failed", "error", err) + } + for _, a := range assignments { + bundle.assignedTo[a.Bookingid] = a.Mileruserid + milerIDs[a.Mileruserid] = true + } + + // Who is delivering each order — the rider who recorded the out-for-delivery + // event on that consignment. + consignmentToDest := map[int]int{} + consignmentIDs := make([]int, 0, len(destinations)) + for _, d := range destinations { + if d.Consignmentid != nil { + consignmentToDest[*d.Consignmentid] = d.Bookingdestinationid + consignmentIDs = append(consignmentIDs, *d.Consignmentid) + } + } + if len(consignmentIDs) > 0 { + var history []models.ConsignmentHistory + if err := db.DB.Where("consignmentid IN ? AND eventstatus = ?", + consignmentIDs, constants.ConsignmentOutForDelivery). + Order("createdat ASC").Find(&history).Error; err != nil { + utils.Warn("loadCxBundle: consignment history query failed", "error", err) + } + for _, h := range history { + if h.Userid == nil { + continue + } + if destID, ok := consignmentToDest[h.Consignmentid]; ok { + bundle.deliveryAgent[destID] = *h.Userid + milerIDs[*h.Userid] = true + } + } + } + + for _, d := range destinations { + if d.Verifiedbyuserid != nil { + milerIDs[*d.Verifiedbyuserid] = true + } + } + bundle.milers = loadCxAgents(milerIDs) + + // What the customer actually paid. Summed from the payment rows rather than + // stored on the booking, so money has one home and a second collection + // cannot silently disagree with a cached total. + type paidRow struct { + Bookingid int + Total float64 + } + var paid []paidRow + if err := db.DB.Model(&models.BookingPayment{}). + Select("bookingid, sum(amount) as total"). + Where("bookingid IN ? AND paymentstatus = ?", bookingIDs, constants.PaymentStatusPaid). + Group("bookingid").Scan(&paid).Error; err != nil { + utils.Warn("loadCxBundle: payment totals query failed", "error", err) + } + for _, p := range paid { + bundle.payments[p.Bookingid] = p.Total + } + + if len(districtCodes) > 0 { + codes := make([]string, 0, len(districtCodes)) + for code := range districtCodes { + codes = append(codes, code) + } + var districts []models.ServiceableDistrict + if err := db.DB.Where("districtcode IN ?", codes).Find(&districts).Error; err != nil { + utils.Warn("loadCxBundle: district query failed", "error", err) + } + for _, d := range districts { + bundle.districts[d.Districtcode] = d + } + bundle.hubNames = hubNamesFor(districts) + } + + return bundle +} + +// loadCxAgents resolves rider display data — and their live position, which +// comes from Redis because it changes every few seconds and has no business in +// Postgres. +func loadCxAgents(ids map[int]bool) map[int]cxAgent { + out := map[int]cxAgent{} + if len(ids) == 0 { + return out + } + list := make([]int, 0, len(ids)) + for id := range ids { + list = append(list, id) + } + + var profiles []models.MilerProfile + if err := db.DB.Where("userid IN ?", list).Find(&profiles).Error; err != nil { + utils.Warn("loadCxAgents: profile query failed", "error", err) + return out + } + + vehicleIDs := make([]int, 0, len(profiles)) + for _, p := range profiles { + if p.Vehicleid != nil { + vehicleIDs = append(vehicleIDs, *p.Vehicleid) + } + } + vehicles := map[int]models.Vehicle{} + if len(vehicleIDs) > 0 { + var rows []models.Vehicle + if err := db.DB.Where("vehicleid IN ?", vehicleIDs).Find(&rows).Error; err == nil { + for _, v := range rows { + vehicles[v.Vehicleid] = v + } + } + } + + for _, p := range profiles { + agent := cxAgent{ + Name: p.Displayname, + Phone: cxMilerContact(p.Phone), + Rating: p.Rating, + Trips: p.Totalcompletedpickups, + VehicleType: p.Defaultvehicletype, + Lat: p.Currentlatitude, + Lng: p.Currentlongitude, + } + if p.Vehicleid != nil { + if v, ok := vehicles[*p.Vehicleid]; ok { + agent.Vehicle = v.Vehicleno + if agent.VehicleType == "" { + agent.VehicleType = v.Vehicletype + } + } + } + if lat, lng, ok := cxLiveRiderPosition(p.Userid); ok { + agent.Lat, agent.Lng = lat, lng + } + out[p.Userid] = agent + } + return out +} + +// cxLiveRiderPosition reads the rider's current position from the same Redis +// GEO index the assignment engine searches, so the distance the customer sees +// and the distance the dispatcher used are the same number. Falls back to the +// profile's last-known coordinates when Redis has nothing. +func cxLiveRiderPosition(milerUserID int) (lat, lng float64, ok bool) { + if db.Rdb == nil { + return 0, 0, false + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + positions, err := db.Rdb.GeoPos(ctx, "milers:locations", cxRiderGeoMember(milerUserID)).Result() + if err != nil || len(positions) == 0 || positions[0] == nil { + return 0, 0, false + } + return positions[0].Latitude, positions[0].Longitude, true +} + +// cxRiderGeoMember is the member name riders are stored under in the GEO index +// (see UpdateMilerLocation, which GEOADDs under the rider's user id). +func cxRiderGeoMember(milerUserID int) string { + return strconv.Itoa(milerUserID) +} + +// cxMilerContact decides what phone number the customer is given for their +// rider. +// +// A masked-calling proxy is preferred, and MILER_CALL_PROXY configures one: +// handing a customer a rider's personal mobile makes that number permanently +// theirs, and riders on comparable platforms have been contacted long after the +// delivery on numbers given out this way. With no proxy configured the real +// number is returned, because a Call Miler button that dials nothing is worse +// than one that dials a rider — but this is a setting to close before launch, +// and §13 of the contract asks product to confirm which it is. +func cxMilerContact(phone string) string { + if proxy := strings.TrimSpace(os.Getenv("MILER_CALL_PROXY")); proxy != "" { + return proxy + } + return phone +} diff --git a/controllers/cxCatalogueController.go b/controllers/cxCatalogueController.go new file mode 100644 index 0000000..170d131 --- /dev/null +++ b/controllers/cxCatalogueController.go @@ -0,0 +1,563 @@ +package controllers + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strconv" + "strings" + "time" + + "doormile/constants" + "doormile/db" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" +) + +// Catalogue and configuration — §5 of the customer contract. +// +// These four reads drive the whole booking form; nothing else in the app works +// without them. Every one of them degrades to something the app can still +// render rather than to an error, because a customer staring at a retry button +// on the state picker cannot book at all. + +// ── Serviceability ─────────────────────────────────────────────────────────── + +// GetCxStates lists the states a destination may be sent to. +// +// districtCount counts AVAILABLE districts only, and the client hides any state +// showing 0 — so a state that is listed but has nothing open still returns, +// carrying the "Opening soon" transit tag. An empty list is a legitimate +// answer: the app has a designed no-service state for it. +func GetCxStates(c *fiber.Ctx) error { + var states []models.ServiceableState + if err := db.DB.Where("status = ?", "Active"). + Order("displayorder ASC, statename ASC").Find(&states).Error; err != nil { + utils.Error("GetCxStates: query failed", "error", err) + return utils.CxInternal(c) + } + + // One grouped count instead of a query per state. + type stateCount struct { + Statecode string + N int + } + var counts []stateCount + if err := db.DB.Model(&models.ServiceableDistrict{}). + Select("statecode, count(*) as n"). + Where("available = ?", true). + Group("statecode").Scan(&counts).Error; err != nil { + utils.Warn("GetCxStates: district count failed, reporting zero", "error", err) + } + byState := make(map[string]int, len(counts)) + for _, sc := range counts { + byState[sc.Statecode] = sc.N + } + + out := make([]fiber.Map, 0, len(states)) + for _, s := range states { + out = append(out, fiber.Map{ + "code": s.Statecode, + "name": s.Statename, + "districtCount": byState[s.Statecode], + "transitTag": s.Transittag, + }) + } + + if served := serveIfNotModified(c, out); served { + return nil + } + return utils.CxList(c, out, len(out), nil) +} + +// GetCxDistricts lists every district in a state, unavailable ones included. +// +// The picker filters unavailable districts out, but their names still appear in +// a quiet "coming soon" line — dropping them here would delete real copy from +// the screen. `note` says why one is closed, so the app never has to invent a +// reason. +func GetCxDistricts(c *fiber.Ctx) error { + stateCode := strings.ToUpper(strings.TrimSpace(c.Params("stateCode"))) + if stateCode == "" { + return utils.CxBadRequest(c, "Pick a state first") + } + + var state models.ServiceableState + if err := db.DB.Where("statecode = ? AND status = ?", stateCode, "Active"). + First(&state).Error; err != nil { + return utils.CxNotFound(c, "That state is no longer serviceable") + } + + var districts []models.ServiceableDistrict + if err := db.DB.Where("statecode = ?", stateCode). + Order("available DESC, displayorder ASC, districtname ASC"). + Find(&districts).Error; err != nil { + utils.Error("GetCxDistricts: query failed", "state", stateCode, "error", err) + return utils.CxInternal(c) + } + + hubNames := hubNamesFor(districts) + + out := make([]fiber.Map, 0, len(districts)) + for _, d := range districts { + row := fiber.Map{ + "code": d.Districtcode, + "name": d.Districtname, + "available": d.Available, + } + if d.Note != "" { + row["note"] = d.Note + } + if d.Hubid != nil { + if name, ok := hubNames[*d.Hubid]; ok { + row["hub"] = name + } + } + if d.Promise != "" { + row["promise"] = d.Promise + } + // The district's centre. Only state and district are required at booking + // time, so for most destinations this is the ONLY geography the parcel + // has until the miler corrects it at the door — it is what places the + // destination on a map and what the fare estimate is priced against. + // Omitted rather than sent as 0,0 when unknown: null island is a real + // coordinate and would render as a pin off the coast of Africa. + if d.Centrelatitude != 0 || d.Centrelongitude != 0 { + row["lat"] = d.Centrelatitude + row["lng"] = d.Centrelongitude + } + out = append(out, row) + } + + if served := serveIfNotModified(c, out); served { + return nil + } + return utils.CxList(c, out, len(out), nil) +} + +// hubNamesFor resolves the serving-hub names for a page of districts in one +// query rather than one per row. +func hubNamesFor(districts []models.ServiceableDistrict) map[int]string { + ids := make([]int, 0, len(districts)) + seen := map[int]bool{} + for _, d := range districts { + if d.Hubid != nil && !seen[*d.Hubid] { + seen[*d.Hubid] = true + ids = append(ids, *d.Hubid) + } + } + names := make(map[int]string, len(ids)) + if len(ids) == 0 { + return names + } + var hubs []models.Hub + if err := db.DB.Select("hubid, hubname").Where("hubid IN ?", ids).Find(&hubs).Error; err != nil { + utils.Warn("hubNamesFor: hub lookup failed, omitting hub names", "error", err) + return names + } + for _, h := range hubs { + names[h.Hubid] = h.Hubname + } + return names +} + +// serveIfNotModified implements ETag/If-None-Match for the serviceability +// reads. Both change perhaps weekly and are fetched on every cold start of the +// booking form, so a 304 is the difference between a full round trip and a +// header exchange. Returns true when it has already answered. +func serveIfNotModified(c *fiber.Ctx, payload interface{}) bool { + body, err := c.App().Config().JSONEncoder(payload) + if err != nil { + return false + } + sum := sha256.Sum256(body) + etag := `"` + hex.EncodeToString(sum[:16]) + `"` + c.Set("ETag", etag) + c.Set("Cache-Control", "max-age=300") + + // A client may legitimately send several etags, or the weak form. + for _, candidate := range strings.Split(c.Get("If-None-Match"), ",") { + candidate = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(candidate), "W/")) + if candidate == etag || candidate == "*" { + c.Status(fiber.StatusNotModified) + return true + } + } + return false +} + +// ── Pickup slots ───────────────────────────────────────────────────────────── + +const ( + // cxSlotLeadMinutes is how far ahead of a window's start the app may still + // offer it. A window that starts in four minutes cannot be staffed, and + // offering it produces a booking nobody can reach on time. + cxSlotLeadMinutes = 45 + // cxSlotDays is how far ahead slots are offered: today and tomorrow, which + // is what the design lays out. + cxSlotDays = 2 + // cxSlotZoneRadiusKM bounds "the customer's zone" when counting how full a + // window already is. Capacity is a property of an area's riders, not of the + // whole city. + cxSlotZoneRadiusKM = 12.0 + // cxMilersNearbyRadiusKM is the radius for the reassuring "4 milers nearby" + // line — deliberately tighter than the capacity radius, because it is a + // statement about who could arrive shortly. + cxMilersNearbyRadiusKM = 6.0 +) + +// GetCxPickupSlots returns the pickup windows offered at a location. +// +// Slots are capacity- and location-aware: the id resolves to a real +// preferredpickupfrom/to on the booking, which is what the assignment engine +// consumes, so a slot the customer can pick is a slot ops can staff. Windows +// already past, or too close to start, are not returned at all rather than +// returned as unavailable — a greyed-out 8am slot at 6pm is noise. +func GetCxPickupSlots(c *fiber.Ctx) error { + lat, _ := strconv.ParseFloat(c.Query("lat"), 64) + lng, _ := strconv.ParseFloat(c.Query("lng"), 64) + + appLocationID := appLocationForPoint(lat, lng) + + var templates []models.PickupSlotTemplate + q := db.DB.Where("status = ?", "Active") + if appLocationID != nil { + q = q.Where("applocationid IS NULL OR applocationid = ?", *appLocationID) + } else { + q = q.Where("applocationid IS NULL") + } + if err := q.Order("displayorder ASC, starthour ASC").Find(&templates).Error; err != nil { + utils.Error("GetCxPickupSlots: template query failed", "error", err) + return utils.CxInternal(c) + } + + now := utils.ISTNow() + cutoff := now.Add(cxSlotLeadMinutes * time.Minute) + + candidates := make([]cxSlotCandidate, 0, len(templates)*cxSlotDays) + for day := 0; day < cxSlotDays; day++ { + d := now.AddDate(0, 0, day) + for _, tpl := range templates { + from := time.Date(d.Year(), d.Month(), d.Day(), tpl.Starthour, tpl.Startminute, 0, 0, utils.ISTLocation()) + to := time.Date(d.Year(), d.Month(), d.Day(), tpl.Endhour, tpl.Endminute, 0, 0, utils.ISTLocation()) + if !from.After(cutoff) { + continue + } + candidates = append(candidates, cxSlotCandidate{ + id: cxSlotID(from, tpl.Code), + from: from, + to: to, + tpl: tpl, + }) + } + } + + booked := slotLoad(candidates, lat, lng) + milersNearby := milersWithin(lat, lng, cxMilersNearbyRadiusKM) + + out := make([]fiber.Map, 0, len(candidates)) + taggedOne := false + for _, cand := range candidates { + remaining := cand.tpl.Capacity - booked[cand.id] + available := remaining > 0 + + row := fiber.Map{ + "id": cand.id, + "day": utils.FormatISTDay(cand.from), + "window": utils.FormatISTWindow(cand.from, cand.to), + "available": available, + } + if !available { + row["note"] = "Fully booked" + } + // At most one slot carries the tag, and only if it can actually be + // booked — labelling a full window "Fastest pickup" is worse than + // labelling nothing. + if available && !taggedOne && cand.tpl.Tag != "" { + row["tag"] = cand.tpl.Tag + taggedOne = true + } + if milersNearby > 0 { + row["milersNearby"] = milersNearby + } + if available && cand.tpl.Caption != "" { + row["caption"] = cand.tpl.Caption + } + out = append(out, row) + } + + // Slots are volatile; a stale slot list is a booking that 409s on confirm. + c.Set("Cache-Control", "max-age=30") + return utils.CxList(c, out, len(out), nil) +} + +// cxSlotID mints the opaque slot id the client sends back. It encodes the date +// and the template code so the server can resolve it to a real window without +// keeping per-request state — and so a slot id from yesterday's cached list +// resolves to yesterday and is rejected, rather than silently booking today. +func cxSlotID(from time.Time, code string) string { + return fmt.Sprintf("slot_%s_%s", from.Format("20060102"), code) +} + +// ResolveCxSlot turns a slot id back into the window it names, checking the +// template still exists and is active. Returns ok=false for an unknown, +// malformed or retired slot. +func ResolveCxSlot(slotID string) (from, to time.Time, ok bool) { + parts := strings.SplitN(slotID, "_", 3) + if len(parts) != 3 || parts[0] != "slot" { + return time.Time{}, time.Time{}, false + } + day, err := time.ParseInLocation("20060102", parts[1], utils.ISTLocation()) + if err != nil { + return time.Time{}, time.Time{}, false + } + + var tpl models.PickupSlotTemplate + if err := db.DB.Where("code = ? AND status = ?", parts[2], "Active"). + First(&tpl).Error; err != nil { + return time.Time{}, time.Time{}, false + } + + from = time.Date(day.Year(), day.Month(), day.Day(), tpl.Starthour, tpl.Startminute, 0, 0, utils.ISTLocation()) + to = time.Date(day.Year(), day.Month(), day.Day(), tpl.Endhour, tpl.Endminute, 0, 0, utils.ISTLocation()) + return from, to, true +} + +// CxSlotDateIsPast reports whether a slot id names a day that is already over. +// +// Pure: it reads the date out of the id and compares it to today, with no +// template lookup and no database. That matters because it is the cheapest +// validation in the booking path and it catches the most likely stale-slot +// case — an app left open across midnight, or one that cached the slot list for +// a whole session, sending yesterday's window in good faith. +// +// Only a whole day in the past is decided here. Whether one of TODAY's windows +// has already started needs the template's hours, which ResolveCxSlot loads. +func CxSlotDateIsPast(slotID string) bool { + parts := strings.SplitN(slotID, "_", 3) + if len(parts) != 3 || parts[0] != "slot" { + return false + } + day, err := time.ParseInLocation("20060102", parts[1], utils.ISTLocation()) + if err != nil { + return false + } + now := utils.ISTNow() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, utils.ISTLocation()) + return day.Before(today) +} + +// CxSlotHasCapacity re-checks a window at confirm time. The list read is +// advisory and up to 30 seconds stale; this is the authority, and it is what +// turns a race into a clean 409 rather than an overbooked window. +func CxSlotHasCapacity(slotID string, lat, lng float64) bool { + from, to, ok := ResolveCxSlot(slotID) + if !ok { + return false + } + var tpl models.PickupSlotTemplate + parts := strings.SplitN(slotID, "_", 3) + if err := db.DB.Where("code = ?", parts[2]).First(&tpl).Error; err != nil { + return false + } + return countBookingsInWindow(from, to, lat, lng) < tpl.Capacity +} + +// cxSlotCandidate is one concrete window on one concrete day, before capacity +// is applied — a template plus the date it was expanded onto. +type cxSlotCandidate struct { + id string + from, to time.Time + tpl models.PickupSlotTemplate +} + +// slotLoad counts how many live bookings already sit in each candidate window +// near this point. Done per window rather than in one grouped query because the +// windows overlap across days and the zone filter is geometric, not indexable +// here; the list is at most a dozen rows. +func slotLoad(candidates []cxSlotCandidate, lat, lng float64) map[string]int { + load := make(map[string]int, len(candidates)) + for _, cand := range candidates { + load[cand.id] = countBookingsInWindow(cand.from, cand.to, lat, lng) + } + return load +} + +// countBookingsInWindow counts pickups already committed to a window inside the +// customer's zone. Cancelled and completed bookings do not consume capacity — +// only work still to be done does. +func countBookingsInWindow(from, to time.Time, lat, lng float64) int { + // Stored timestamps are IST wall clock (see utils.DBNow), so the bounds are + // sent as those digits rather than as a UTC instant. Comparing a UTC clock + // against IST-stamped rows is what made date-range reports undercount. + fromDB := time.Date(from.Year(), from.Month(), from.Day(), from.Hour(), from.Minute(), 0, 0, time.UTC) + toDB := time.Date(to.Year(), to.Month(), to.Day(), to.Hour(), to.Minute(), 0, 0, time.UTC) + + type row struct { + Pickuplatitude float64 + Pickuplongitude float64 + } + var rows []row + err := db.DB.Model(&models.PickupBooking{}). + Select("pickuplatitude, pickuplongitude"). + Where("preferredpickupfrom >= ? AND preferredpickupfrom < ?", fromDB, toDB). + Where("status NOT IN ?", []string{constants.BookingCancelled, constants.BookingConvertedConsignment}). + Find(&rows).Error + if err != nil { + // Failing open keeps the form usable. An over-filled window is an ops + // problem; a booking form that cannot offer any slot is a dead app. + utils.Warn("countBookingsInWindow: query failed, treating window as open", "error", err) + return 0 + } + + if lat == 0 && lng == 0 { + return len(rows) + } + n := 0 + for _, r := range rows { + if r.Pickuplatitude == 0 && r.Pickuplongitude == 0 { + continue + } + if calculateDistance(lat, lng, r.Pickuplatitude, r.Pickuplongitude) <= cxSlotZoneRadiusKM { + n++ + } + } + return n +} + +// milersWithin counts riders currently reporting a position inside a radius. +// Reads the same Redis GEO index the assignment engine searches, so the number +// the customer is shown is the pool the dispatcher would actually draw from. +// Returns 0 when Redis is unavailable, and the client hides the line on 0. +func milersWithin(lat, lng, radiusKM float64) int { + if db.Rdb == nil || (lat == 0 && lng == 0) { + return 0 + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + locs, err := db.Rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{ + GeoSearchQuery: redis.GeoSearchQuery{ + Longitude: lng, + Latitude: lat, + Radius: radiusKM, + RadiusUnit: "km", + Sort: "ASC", + Count: 50, + }, + }).Result() + if err != nil { + utils.Warn("milersWithin: geo search failed", "error", err) + return 0 + } + return len(locs) +} + +// appLocationForPoint resolves which city a coordinate belongs to, via the +// nearest active hub. Nil when nothing is close enough to claim it, in which +// case only city-agnostic configuration applies. +func appLocationForPoint(lat, lng float64) *int { + if lat == 0 && lng == 0 { + return nil + } + var hubs []models.Hub + if err := db.DB.Select("hubid, applocationid, latitude, longitude"). + Where("status = ? AND deletedat IS NULL", "Active").Find(&hubs).Error; err != nil { + utils.Warn("appLocationForPoint: hub query failed", "error", err) + return nil + } + best := -1.0 + var bestID *int + for i := range hubs { + h := hubs[i] + if h.Latitude == 0 && h.Longitude == 0 { + continue + } + d := calculateDistance(lat, lng, h.Latitude, h.Longitude) + if best < 0 || d < best { + best = d + id := h.Applocationid + bestID = &id + } + } + // A hub 200km away says nothing about which city this is. + if bestID == nil || best > 60 { + return nil + } + return bestID +} + +// ── Booking limits ─────────────────────────────────────────────────────────── + +// cxDefaultMaxPackages / cxDefaultMaxDestinations are the last-resort values, +// used only when no configuration row exists at all. They can never be 0 — +// a zero cap would reject every booking on the platform. +// +// cxDefaultMaxDestinations is deliberately **1**, not the 5 the design allows. +// +// This is the fail-safe half of the multi-destination gate. The gate itself is a +// database value (customerbookinglimits.maxdestinations), and a gate that opens +// when its configuration is missing is not a gate: a migration that ran without +// the seed, a wiped table, or a fresh environment would silently permit +// multi-destination bookings that the deployed rider app cannot complete, +// stranding parcels with no stop and no way to close them. +// +// So "no configuration" resolves to the safest behaviour, not the most +// permissive. Ops raises it to 5 by inserting the row — an explicit act — once +// a rider build keying on consignmentid is live. The client's own 20/5 fallback +// is UI guidance only; this is the authority. +const ( + cxDefaultMaxPackages = 20 + cxDefaultMaxDestinations = 1 +) + +// GetCxBookingLimits returns the caps on a single pickup. +// +// Nothing in the UI hardcodes these; they live here so ops can vary them by +// city without an app release. Keyed off the pickup location when one is +// supplied, so the client can re-fetch when the pickup point moves. +func GetCxBookingLimits(c *fiber.Ctx) error { + lat, _ := strconv.ParseFloat(c.Query("lat"), 64) + lng, _ := strconv.ParseFloat(c.Query("lng"), 64) + + maxPackages, maxDestinations := CxBookingLimits(appLocationForPoint(lat, lng)) + return utils.CxOK(c, fiber.Map{ + "maxPackages": maxPackages, + "maxDestinations": maxDestinations, + }) +} + +// CxBookingLimits resolves the caps for a city, falling back to the global row +// and then to the built-in defaults. Never returns 0 for either: a zero cap +// rejects every booking, and a configuration mistake must not be able to take +// the product offline. +func CxBookingLimits(appLocationID *int) (maxPackages, maxDestinations int) { + maxPackages, maxDestinations = cxDefaultMaxPackages, cxDefaultMaxDestinations + + var limit models.CustomerBookingLimit + found := false + if appLocationID != nil { + if err := db.DB.Where("applocationid = ?", *appLocationID).First(&limit).Error; err == nil { + found = true + } + } + if !found { + if err := db.DB.Where("applocationid IS NULL").First(&limit).Error; err == nil { + found = true + } + } + if !found { + return + } + if limit.Maxpackages > 0 { + maxPackages = limit.Maxpackages + } + if limit.Maxdestinations > 0 { + maxDestinations = limit.Maxdestinations + } + return +} diff --git a/controllers/cxConsignmentHooks.go b/controllers/cxConsignmentHooks.go new file mode 100644 index 0000000..98ac573 --- /dev/null +++ b/controllers/cxConsignmentHooks.go @@ -0,0 +1,100 @@ +package controllers + +import ( + "doormile/constants" + "doormile/internal/cxstage" + "doormile/utils" + + "gorm.io/gorm" +) + +// Per-order stages — the second half of §8.3. +// +// Stages 6-8 belong to each order rather than to the booking, and may differ +// between destinations of the same pickup: one parcel out for delivery in +// Chennai while another is still at a hub in Kerala. Each of the operational +// writes that moves a consignment records its customer stage against the +// destination that consignment belongs to, and the booking's own stage falls +// back to the least-advanced of them. + +// cxStageForConsignmentStatus maps an operational consignment status onto the +// customer stage it means. Not every status has one: a parcel sitting on a +// tripsheet, or one flagged missing, is still "in transit" as far as the +// customer's seven milestones go, and inventing a stage for it would put a key +// on the wire the client silently falls back to `booked` for. +func cxStageForConsignmentStatus(status string) (string, bool) { + switch status { + case constants.ConsignmentInwardedAtHub, + constants.ConsignmentTripsheetLoaded, + constants.ConsignmentInTransit: + return constants.CxStageInTransit, true + case constants.ConsignmentOutForDelivery: + return constants.CxStageOutForDelivery, true + case constants.ConsignmentDelivered: + return constants.CxStageDelivered, true + default: + // Created and Collected_By_Miler both mean "the order exists and is in + // the rider's hands", which is order_created — already recorded at + // pickup-complete, so there is nothing new to say. + return "", false + } +} + +// recordCxConsignmentStage records the customer stage for one consignment, +// inside the caller's transaction, and returns the notification to fire once +// that transaction commits. +// +// The consignment is resolved to its destination through bookingdestinations, +// not through pickupbookings.consignmentid. That column names only the FIRST +// order of a multi-destination pickup, so a lookup through it finds nothing for +// destinations 2..N — which would mean no stage advance and no notification on +// every order after the first. +// +// A consignment that belongs to no customer booking at all — a console-created +// express shipment — is a no-op, not an error. Those have no customer app +// watching them. +func recordCxConsignmentStage(tx *gorm.DB, consignmentID int, status string, actorType string, actorID *int, source string) (afterCommit func(), err error) { + noop := func() {} + + stage, ok := cxStageForConsignmentStatus(status) + if !ok { + return noop, nil + } + + dest, booking, found := cxDestinationForConsignment(consignmentID) + if !found || booking == nil { + return noop, nil + } + if booking.Bookingsource != constants.BookingSourceCustomerApp { + return noop, nil + } + + destinationID := cxDestinationIDFor(dest) + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: booking.Bookingid, + DestinationID: destinationID, + Stage: stage, + ActorType: actorType, + ActorID: actorID, + Source: source, + At: utils.DBNow(), + }); err != nil { + return noop, err + } + + bookingID := booking.Bookingid + return func() { go cxstage.Notify(bookingID, destinationID, stage) }, nil +} + +// cancelCxBookingFromOps stands a pickup down on behalf of a rider or an ops +// user, recording who did it and why. +// +// A customer whose pickup disappears with no explanation has no way to tell a +// cancellation from a bug, and support has no way to answer them — so the +// actor and the reason are part of the write, not an afterthought. +func cancelCxBookingFromOps(tx *gorm.DB, bookingID int, reason, actorType string, actorID *int, source string) error { + if reason == "" { + reason = "Cancelled by Doormile" + } + return cxstage.Cancel(tx, bookingID, reason, actorType, actorID, source) +} diff --git a/controllers/cxCustomerApp_test.go b/controllers/cxCustomerApp_test.go new file mode 100644 index 0000000..3193627 --- /dev/null +++ b/controllers/cxCustomerApp_test.go @@ -0,0 +1,856 @@ +package controllers + +import ( + "strings" + "testing" + "time" + + "doormile/constants" + "doormile/models" + "doormile/utils" +) + +// The app currently sends "+91 98765 43210" with spaces and is being tightened +// to send E.164. Both shapes must land on the SAME stored value: if they do +// not, a customer signing in from the newer build gets a second account and +// loses every booking they have made. +func TestNormalizePhoneCollapsesEveryShapeToOne(t *testing.T) { + want := "+919876543210" + + inputs := []string{ + "+91 98765 43210", // what the app sends today + "+919876543210", // what it is being tightened to send + "9876543210", // bare keypad entry + "09876543210", // with the national trunk prefix + "919876543210", // country code, no plus + "+91-98765-43210", // typed with dashes by support + " 9876543210 ", // pasted with whitespace + } + + for _, in := range inputs { + got, kind, ok := normalizePhone(in) + if !ok { + t.Errorf("normalizePhone(%q) rejected a valid number", in) + continue + } + if kind != "phone" { + t.Errorf("normalizePhone(%q) kind = %q, want \"phone\"", in, kind) + } + if got != want { + t.Errorf("normalizePhone(%q) = %q, want %q — two spellings of one number must not make two accounts", + in, got, want) + } + } +} + +func TestNormalizePhoneRejectsNonsense(t *testing.T) { + for _, in := range []string{"", " ", "12345", "abcdef", "+1"} { + if got, _, ok := normalizePhone(in); ok { + t.Errorf("normalizePhone(%q) = %q, ok — want rejected", in, got) + } + } +} + +func TestNormalizeIdentifierSplitsPhoneFromEmail(t *testing.T) { + cases := []struct { + in string + want string + wantKind string + wantOK bool + }{ + {"Joe@Example.COM", "joe@example.com", "email", true}, + {"+91 98765 43210", "+919876543210", "phone", true}, + {"joe@example", "", "", false}, // no dot in the domain + {"@example.com", "", "", false}, // no local part + {"joe@", "", "", false}, // no domain + {"", "", "", false}, + } + + for _, tc := range cases { + got, kind, ok := normalizeIdentifier(tc.in) + if ok != tc.wantOK || got != tc.want || kind != tc.wantKind { + t.Errorf("normalizeIdentifier(%q) = (%q, %q, %v), want (%q, %q, %v)", + tc.in, got, kind, ok, tc.want, tc.wantKind, tc.wantOK) + } + } +} + +func TestSplitNameKeepsOneWordNames(t *testing.T) { + cases := []struct { + in string + first, last string + }{ + {"Joe Oommen", "Joe", "Oommen"}, + {"Meera", "Meera", ""}, // plenty of people have one name + {" Arun Kumar ", "Arun", "Kumar"}, // collapses whitespace + {"Vijay Raghav Menon", "Vijay Raghav", "Menon"}, // last token is the surname + {"J", "", ""}, // under two characters is invalid_name + {"", "", ""}, + } + + for _, tc := range cases { + first, last := splitName(tc.in) + if first != tc.first || last != tc.last { + t.Errorf("splitName(%q) = (%q, %q), want (%q, %q)", tc.in, first, last, tc.first, tc.last) + } + } +} + +// The slot id is opaque to the client but has to round-trip: it encodes the +// date so a slot id from yesterday's cached list resolves to yesterday and gets +// rejected, rather than silently booking today's window. +func TestSlotIDCarriesItsDate(t *testing.T) { + from := time.Date(2026, 9, 5, 14, 0, 0, 0, time.UTC) + if got, want := cxSlotID(from, "t1"), "slot_20260905_t1"; got != want { + t.Errorf("cxSlotID = %q, want %q", got, want) + } + + // Two days must never produce the same id for the same template. + other := time.Date(2026, 9, 6, 14, 0, 0, 0, time.UTC) + if cxSlotID(from, "t1") == cxSlotID(other, "t1") { + t.Error("cxSlotID collides across days — a stale slot would book today's window") + } +} + +func TestDescribeParcelsMatchesTheReceiptCopy(t *testing.T) { + cases := []struct { + packages int + want string + }{ + {0, "Standard box (up to 3 kg)"}, + {1, "Standard box (up to 3 kg)"}, + {3, "3 boxes (up to 3 kg each)"}, + } + for _, tc := range cases { + if got := describeParcels(tc.packages); got != tc.want { + t.Errorf("describeParcels(%d) = %q, want %q", tc.packages, got, tc.want) + } + } +} + +// pickup.title and pickup.sub are typed non-nullable by the client and will +// throw in its parser on a null. A console-created booking has only one flat +// address string, so both have to be derivable from it. +func TestPickupTitleAndSubAreNeverEmpty(t *testing.T) { + cases := []struct { + name string + booking models.PickupBooking + }{ + {"customer-app booking", models.PickupBooking{ + Pickuptitle: "12 Nehru Street", Pickupsub: "Gandhipuram, Coimbatore 641012"}}, + {"console booking with a flat address", models.PickupBooking{ + Pickupaddress: "12 Nehru Street, Gandhipuram, Coimbatore", Pickuppincode: "641012"}}, + {"address with no comma", models.PickupBooking{ + Pickupaddress: "Brookefields", Pickuppincode: "641001"}}, + {"nothing recorded at all", models.PickupBooking{}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + b := tc.booking + if title := cxPickupTitle(&b); title == "" { + t.Error("cxPickupTitle returned empty — the client types it non-nullable") + } + if sub := cxPickupSub(&b); sub == "" { + t.Error("cxPickupSub returned empty — the client types it non-nullable") + } + }) + } +} + +func TestPickupTitleIsCappedForTheDesign(t *testing.T) { + b := models.PickupBooking{ + Pickupaddress: "A very long single-line address with no commas at all in it anywhere", + } + if got := cxPickupTitle(&b); len([]rune(got)) > 32 { + t.Errorf("cxPickupTitle = %q (%d runes), want at most 32", got, len([]rune(got))) + } +} + +// deliveredAt is the moment the LAST parcel landed. Reporting the first would +// tell a customer their whole pickup completed while parcels were still moving. +func TestAllDeliveredAtWaitsForTheLastParcel(t *testing.T) { + early := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC) + late := time.Date(2026, 9, 6, 16, 0, 0, 0, time.UTC) + + partly := []models.BookingDestination{ + {Deliveredat: &early}, + {Deliveredat: nil}, + } + if got := cxAllDeliveredAt(partly); got != nil { + t.Errorf("cxAllDeliveredAt with one parcel still moving = %v, want nil", got) + } + + all := []models.BookingDestination{ + {Deliveredat: &early}, + {Deliveredat: &late}, + } + got := cxAllDeliveredAt(all) + if got == nil || !got.Equal(late) { + t.Errorf("cxAllDeliveredAt = %v, want the last delivery %v", got, late) + } + + if got := cxAllDeliveredAt(nil); got != nil { + t.Errorf("cxAllDeliveredAt(no destinations) = %v, want nil", got) + } +} + +// A booking written before this surface existed carries no stored stage, and a +// console-created one never will. Both still have to render, so the stage is +// derived from the operational status rather than left blank. +func TestStageDerivationForBookingsWithNoStoredStage(t *testing.T) { + arrived := time.Date(2026, 9, 5, 14, 0, 0, 0, time.UTC) + + cases := []struct { + name string + booking models.PickupBooking + want string + }{ + {"pending pickup", models.PickupBooking{Status: constants.BookingPendingPickup}, constants.CxStageBooked}, + {"assigned, not yet arrived", models.PickupBooking{Status: constants.BookingMilerAssigned}, constants.CxStageAssigned}, + {"scheduled and arrived", models.PickupBooking{Status: constants.BookingPickupScheduled, Arrivedat: &arrived}, constants.CxStageArrived}, + {"picked up", models.PickupBooking{Status: constants.BookingPickedUp}, constants.CxStagePickedUp}, + {"converted", models.PickupBooking{Status: constants.BookingConvertedConsignment}, constants.CxStageOrderCreated}, + {"cancelled", models.PickupBooking{Status: constants.BookingCancelled}, constants.CxStageBooked}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + b := tc.booking + if got := deriveStageFromStatus(&b); got != tc.want { + t.Errorf("deriveStageFromStatus = %q, want %q", got, tc.want) + } + }) + } +} + +// The timeline is built from the event log. Cancellation and release rows carry +// a stage key only because the table needs one — putting them on the timeline +// would show the customer "Pickup booked" a second time when a rider handed +// their pickup back. +func TestHistoryExcludesAuditRowsAndDeduplicates(t *testing.T) { + at := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC) + + events := []models.BookingStageEvent{ + {Stage: constants.CxStageBooked, Occurredat: at}, + {Stage: constants.CxStageAssigned, Occurredat: at.Add(time.Minute)}, + {Stage: constants.CxStageBooked, Remarks: "released: rider unavailable", Occurredat: at.Add(2 * time.Minute)}, + {Stage: constants.CxStageAssigned, Occurredat: at.Add(3 * time.Minute)}, // second rider, same stage + {Stage: constants.CxStageBooked, Remarks: "cancelled: Package not ready", Occurredat: at.Add(4 * time.Minute)}, + } + + history := renderCxHistory(events) + if len(history) != 2 { + t.Fatalf("renderCxHistory returned %d entries, want 2 (booked, assigned)", len(history)) + } + if history[0]["stage"] != constants.CxStageBooked || history[1]["stage"] != constants.CxStageAssigned { + t.Errorf("renderCxHistory = %v, want booked then assigned in order", history) + } +} + +// A multi-destination pickup emits each per-order stage once per destination. +// The booking reaches that stage when its SLOWEST order does, so the timeline +// must carry the LAST of them — otherwise "Delivered" is timestamped at the +// moment the earliest parcel landed while deliveredAt reports the last one, and +// the same screen contradicts itself. +func TestHistoryTimestampsPerOrderStagesAtTheSlowestOrder(t *testing.T) { + base := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC) + firstParcel := base.Add(2 * time.Hour) + lastParcel := base.Add(30 * time.Hour) + + events := []models.BookingStageEvent{ + {Stage: constants.CxStageBooked, Occurredat: base}, + {Stage: constants.CxStageDelivered, Occurredat: firstParcel}, + {Stage: constants.CxStageDelivered, Occurredat: lastParcel}, + } + + history := renderCxHistory(events) + if len(history) != 2 { + t.Fatalf("renderCxHistory returned %d entries, want 2", len(history)) + } + if got, want := history[1]["at"], utils.EpochMillis(lastParcel); got != want { + t.Errorf("delivered timestamped at %v, want the last parcel %v", got, want) + } +} + +// Booking-level stages happen once for the whole pickup, so a duplicate is a +// re-assertion (Record dedupes, but a release can bring one back) and the +// original moment is the true one. +func TestHistoryKeepsTheFirstBookingLevelTimestamp(t *testing.T) { + first := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC) + later := first.Add(3 * time.Hour) + + events := []models.BookingStageEvent{ + {Stage: constants.CxStageAssigned, Occurredat: first}, + {Stage: constants.CxStageAssigned, Occurredat: later}, + } + + history := renderCxHistory(events) + if len(history) != 1 { + t.Fatalf("renderCxHistory returned %d entries, want 1", len(history)) + } + if got, want := history[0]["at"], utils.EpochMillis(first); got != want { + t.Errorf("assigned timestamped at %v, want the original %v", got, want) + } +} + +// Every stage the timeline can carry has to be one the client parses. An +// unknown key is silently rendered as `booked`, so a mapping that invents one +// makes a moving parcel look un-started. +func TestConsignmentStatusMapsOnlyToRealStages(t *testing.T) { + mapped := map[string]string{ + constants.ConsignmentInwardedAtHub: constants.CxStageInTransit, + constants.ConsignmentTripsheetLoaded: constants.CxStageInTransit, + constants.ConsignmentInTransit: constants.CxStageInTransit, + constants.ConsignmentOutForDelivery: constants.CxStageOutForDelivery, + constants.ConsignmentDelivered: constants.CxStageDelivered, + } + for status, want := range mapped { + got, ok := cxStageForConsignmentStatus(status) + if !ok || got != want { + t.Errorf("cxStageForConsignmentStatus(%q) = (%q, %v), want (%q, true)", status, got, ok, want) + } + if constants.CxStageRank(got) < 0 { + t.Errorf("cxStageForConsignmentStatus(%q) produced %q, which is not one of the nine stage keys", status, got) + } + } + + // Statuses that mean "the order exists and is in the rider's hands" are + // already covered by order_created and must not emit a second stage. + for _, status := range []string{ + constants.ConsignmentCreated, + constants.ConsignmentCollectedByMiler, + constants.ConsignmentMissing, + "Cancelled", + } { + if got, ok := cxStageForConsignmentStatus(status); ok { + t.Errorf("cxStageForConsignmentStatus(%q) = %q, want no stage", status, got) + } + } +} + +// cxLegWeights is what the price settles on. A leg whose packages were never +// weighed must still produce a chargeable consignment, or an unweighed pickup +// bills at zero. +func TestLegWeightsFallBackWhenNothingWasWeighed(t *testing.T) { + empty := cxPickupLeg{} + dead, chargeable, _, _, _ := cxLegWeights(empty) + if dead != 0.5 || chargeable != 0.5 { + t.Errorf("cxLegWeights(no parcels) = (%v, %v), want the 0.5 kg placeholder", dead, chargeable) + } + + // Volumetric weight wins when the box is bulky and light — that is the + // whole reason the column exists. + bulky := cxPickupLeg{Parcels: []models.BookingParcel{ + {Weight: 1.0, Length: 40, Width: 40, Height: 40}, // volumetric = 64000/5000 = 12.8 + }} + _, chargeable, maxL, _, _ := cxLegWeights(bulky) + if chargeable != 12.8 { + t.Errorf("cxLegWeights chargeable = %v, want the volumetric 12.8", chargeable) + } + if maxL != 40 { + t.Errorf("cxLegWeights maxL = %v, want 40", maxL) + } +} + +// How many stops a booking is worth to the rider decides whether a parcel is +// deliverable at all. The queue used to emit one row per booking keyed on +// pickupbookings.consignmentid — a column that names only the FIRST order — so +// on a three-destination pickup two parcels sat in the rider's bag with no +// stop, no deliver button and no way to close them. +func TestMilerStopsBeforeCollectionAreOneVisit(t *testing.T) { + booking := models.PickupBooking{ + Bookingid: 7, + Deliveryaddress: "12th Main, Chennai", + Deliverylatitude: 13.08, + Deliverylongitude: 80.27, + } + // Booked, not yet collected: no destination carries a consignment. + destinations := []models.BookingDestination{ + {Bookingdestinationid: 1, Bookingid: 7, Seq: 0, Districtname: "Chennai"}, + {Bookingdestinationid: 2, Bookingid: 7, Seq: 1, Districtname: "Ernakulam"}, + {Bookingdestinationid: 3, Bookingid: 7, Seq: 2, Districtname: "Bengaluru Urban"}, + } + + stops := milerStopsForBooking(&booking, destinations) + if len(stops) != 1 { + t.Fatalf("got %d stops before collection, want 1 — the rider makes ONE visit to the door", len(stops)) + } + if stops[0].deliveryLat != 13.08 { + t.Errorf("pre-pickup stop lost the booking's mirrored coordinates: %v", stops[0].deliveryLat) + } +} + +func TestMilerStopsAfterCollectionAreOnePerOrder(t *testing.T) { + c1, c2, c3 := 101, 102, 103 + pinLat, pinLng := 9.98, 76.29 + + booking := models.PickupBooking{ + Bookingid: 7, + Consignmentid: &c1, + Deliverylatitude: 13.08, + Deliverylongitude: 80.27, + Parcels: []models.BookingParcel{ + {Bookingparcelid: 1, Bookingdestinationid: intPtr(1)}, + {Bookingparcelid: 2, Bookingdestinationid: intPtr(1)}, + {Bookingparcelid: 3, Bookingdestinationid: intPtr(2)}, + {Bookingparcelid: 4, Bookingdestinationid: intPtr(3)}, + }, + } + destinations := []models.BookingDestination{ + {Bookingdestinationid: 1, Bookingid: 7, Seq: 0, Districtname: "Chennai", + Statename: "Tamil Nadu", Consignmentid: &c1, Trackingno: "DMX10000001"}, + {Bookingdestinationid: 2, Bookingid: 7, Seq: 1, Districtname: "Ernakulam", + Statename: "Kerala", Consignmentid: &c2, Trackingno: "DMX10000002", + Pinlatitude: &pinLat, Pinlongitude: &pinLng}, + {Bookingdestinationid: 3, Bookingid: 7, Seq: 2, Districtname: "Bengaluru Urban", + Statename: "Karnataka", Consignmentid: &c3, Trackingno: "DMX10000003"}, + } + + stops := milerStopsForBooking(&booking, destinations) + if len(stops) != 3 { + t.Fatalf("got %d stops after collection, want 3 — one per order, or the other parcels are undeliverable", len(stops)) + } + + // Each stop names its own order. Sharing one consignment id would have the + // rider close the same parcel three times. + seen := map[int]bool{} + for i, s := range stops { + if s.consignmentID == nil { + t.Fatalf("stop %d has no consignment id", i) + } + if seen[*s.consignmentID] { + t.Errorf("stop %d repeats consignment %d", i, *s.consignmentID) + } + seen[*s.consignmentID] = true + if s.trackingNo == "" { + t.Errorf("stop %d has no tracking number", i) + } + if s.deliveryAddress == "" { + t.Errorf("stop %d has no delivery address — the rider has nowhere to go", i) + } + } + + // Parcels follow their own destination, so each order settles and is handed + // over with the packages that actually belong to it. + if len(stops[0].parcels) != 2 || len(stops[1].parcels) != 1 || len(stops[2].parcels) != 1 { + t.Errorf("parcels split as %d/%d/%d, want 2/1/1", + len(stops[0].parcels), len(stops[1].parcels), len(stops[2].parcels)) + } + + // A destination with its own pin uses it; destination 0 falls back to the + // coordinates mirrored onto the booking, which the rider may have corrected. + if stops[1].deliveryLat != pinLat { + t.Errorf("stop 1 lat = %v, want the customer's pin %v", stops[1].deliveryLat, pinLat) + } + if stops[0].deliveryLat != 13.08 { + t.Errorf("stop 0 lat = %v, want the booking's mirrored 13.08", stops[0].deliveryLat) + } +} + +// A console-created express booking has no destination rows at all and must +// keep producing exactly the one stop it always did. +func TestMilerStopsForConsoleBookingAreUnchanged(t *testing.T) { + cid := 55 + booking := models.PickupBooking{ + Bookingid: 9, + Consignmentid: &cid, + Deliveryaddress: "Kitchen 4, Nagercoil", + Deliverylatitude: 8.17, + Deliverylongitude: 77.43, + Parcels: []models.BookingParcel{{Bookingparcelid: 1}}, + } + + stops := milerStopsForBooking(&booking, nil) + if len(stops) != 1 { + t.Fatalf("got %d stops, want 1", len(stops)) + } + if stops[0].consignmentID == nil || *stops[0].consignmentID != cid { + t.Errorf("console stop lost its consignment id: %v", stops[0].consignmentID) + } + if stops[0].deliveryAddress != "Kitchen 4, Nagercoil" || len(stops[0].parcels) != 1 { + t.Errorf("console stop changed shape: %+v", stops[0]) + } +} + +// Three console paths cancel a booking by writing pickupbookings.status +// directly and none of them knows the customer projection exists. The customer +// must never keep seeing a cancelled pickup as active and cancellable, so the +// operational status is the authority here regardless of what customerstatus +// holds. +func TestOpsCancellationAlwaysReachesTheCustomer(t *testing.T) { + bundle := loadCxBundleForTest() + + booking := models.PickupBooking{ + Bookingid: 1, + Bookingno: "DM-482913", + // Written as active at booking time — this is the value that used to + // win and keep a cancelled pickup looking live. + Customerstatus: constants.CxStatusActive, + Customerstage: constants.CxStageAssigned, + Status: constants.BookingCancelled, + Createdat: time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC), + } + + out := renderCxBooking(&booking, bundle) + + if out["status"] != constants.CxStatusCancelled { + t.Errorf("status = %v, want %q — an ops cancel must reach the customer", + out["status"], constants.CxStatusCancelled) + } + if out["cancellable"] != false { + t.Errorf("cancellable = %v on a cancelled pickup, want false", out["cancellable"]) + } +} + +// A booking that is genuinely still live must not be swept up by that rule. +func TestActiveBookingStaysActiveAndCancellable(t *testing.T) { + bundle := loadCxBundleForTest() + + booking := models.PickupBooking{ + Bookingid: 2, + Bookingno: "DM-482914", + Customerstatus: constants.CxStatusActive, + Customerstage: constants.CxStageArrived, + Status: constants.BookingPickupScheduled, + Createdat: time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC), + } + + out := renderCxBooking(&booking, bundle) + + if out["status"] != constants.CxStatusActive { + t.Errorf("status = %v, want %q", out["status"], constants.CxStatusActive) + } + // arrived is the last cancellable stage. + if out["cancellable"] != true { + t.Errorf("cancellable = %v at arrived, want true", out["cancellable"]) + } +} + +// loadCxBundleForTest builds an empty bundle, so the projection can be +// exercised without a database. +func loadCxBundleForTest() *cxBookingBundle { + return &cxBookingBundle{ + destinations: map[int][]models.BookingDestination{}, + events: map[int][]models.BookingStageEvent{}, + photos: map[int][]models.BookingParcelPhoto{}, + milers: map[int]cxAgent{}, + assignedTo: map[int]int{}, + deliveryAgent: map[int]int{}, + payments: map[int]float64{}, + districts: map[string]models.ServiceableDistrict{}, + hubNames: map[int]string{}, + } +} + +// ─── Fan-out row-level proof ───────────────────────────────────────────────── +// +// One pickup, three genuinely different destinations. Every assertion below +// exists because the failure it guards against is silent: the rows would still +// render, the rider would still see stops, and the parcels would go to the +// wrong doors. Booking-level destination-0 data leaking into rows 2 and 3 is +// the specific defect this locks down. + +// cxThreeDestinationFixture builds a collected pickup bound for Chennai, +// Ernakulam and Bengaluru — different addresses, different coordinates, +// different recipients, different tracking numbers, different COD. +func cxThreeDestinationFixture() (models.PickupBooking, []models.BookingDestination) { + c1, c2, c3 := 901, 902, 903 + chennaiLat, chennaiLng := 13.082680, 80.270718 + kochiLat, kochiLng := 9.981636, 76.299881 + blrLat, blrLng := 12.971599, 77.594566 + + booking := models.PickupBooking{ + Bookingid: 70, + Bookingno: "DM-482913", + Consignmentid: &c1, + // Booking-level delivery data mirrors destination 0 ONLY. If any of it + // leaks onto stops 1 or 2, the assertions below catch it. + Deliveryaddress: "3B, 12th Main, Chennai, Tamil Nadu", + Deliverylatitude: chennaiLat, + Deliverylongitude: chennaiLng, + Parcels: []models.BookingParcel{ + {Bookingparcelid: 1, Bookingdestinationid: intPtr(11)}, + {Bookingparcelid: 2, Bookingdestinationid: intPtr(11)}, + {Bookingparcelid: 3, Bookingdestinationid: intPtr(12)}, + {Bookingparcelid: 4, Bookingdestinationid: intPtr(13)}, + }, + } + + destinations := []models.BookingDestination{ + { + Bookingdestinationid: 11, Bookingid: 70, Seq: 0, + Statecode: "TN", Statename: "Tamil Nadu", + Districtcode: "TN-MAA", Districtname: "Chennai", + Building: "3B", Street: "12th Main", + Recipientname: "Meera S", Recipientphone: "+919884412210", + Packagecount: 2, Codamount: 1200, + Consignmentid: &c1, Trackingno: "DMX10482913", + Pinlatitude: &chennaiLat, Pinlongitude: &chennaiLng, + }, + { + Bookingdestinationid: 12, Bookingid: 70, Seq: 1, + Statecode: "KL", Statename: "Kerala", + Districtcode: "KL-EKM", Districtname: "Ernakulam", + Street: "Marine Drive", Landmark: "Near the ferry", + Recipientname: "Joe Oommen", Recipientphone: "+919847011223", + Packagecount: 1, Codamount: 0, + Consignmentid: &c2, Trackingno: "DMX10559120", + Pinlatitude: &kochiLat, Pinlongitude: &kochiLng, + }, + { + Bookingdestinationid: 13, Bookingid: 70, Seq: 2, + Statecode: "KA", Statename: "Karnataka", + Districtcode: "KA-BLR", Districtname: "Bengaluru Urban", + Street: "Brigade Road", + Recipientname: "Arun Kumar", Recipientphone: "+919000011223", + Packagecount: 1, Codamount: 450, + Consignmentid: &c3, Trackingno: "DMX10662004", + Pinlatitude: &blrLat, Pinlongitude: &blrLng, + }, + } + return booking, destinations +} + +// Each generated stop carries a DIFFERENT, correct address — none of them +// inherits the booking-level destination-0 string. +func TestFanoutStopsHaveDistinctAddresses(t *testing.T) { + booking, destinations := cxThreeDestinationFixture() + stops := milerStopsForBooking(&booking, destinations) + + if len(stops) != 3 { + t.Fatalf("got %d stops, want 3", len(stops)) + } + + seen := map[string]bool{} + for i, s := range stops { + if s.deliveryAddress == "" { + t.Fatalf("stop %d has no delivery address — the rider has nowhere to go", i) + } + if seen[s.deliveryAddress] { + t.Errorf("stop %d repeats an address already used: %q", i, s.deliveryAddress) + } + seen[s.deliveryAddress] = true + } + + wantDistrict := []string{"Chennai", "Ernakulam", "Bengaluru Urban"} + for i, want := range wantDistrict { + if !strings.Contains(stops[i].deliveryAddress, want) { + t.Errorf("stop %d address %q does not name %q", i, stops[i].deliveryAddress, want) + } + } + // The specific leak this guards: destination 0's address on a later stop. + for i := 1; i < 3; i++ { + if strings.Contains(stops[i].deliveryAddress, "Chennai") { + t.Errorf("stop %d inherited destination 0's address: %q", i, stops[i].deliveryAddress) + } + } +} + +// Coordinates are distinct and correct per stop. A shared coordinate sends +// every parcel to one map pin, which is how a rider drives to the wrong city. +func TestFanoutStopsHaveDistinctCoordinates(t *testing.T) { + booking, destinations := cxThreeDestinationFixture() + stops := milerStopsForBooking(&booking, destinations) + + type point struct{ lat, lng float64 } + want := []point{{13.082680, 80.270718}, {9.981636, 76.299881}, {12.971599, 77.594566}} + + seen := map[point]bool{} + for i, s := range stops { + got := point{s.deliveryLat, s.deliveryLng} + if got.lat == 0 && got.lng == 0 { + t.Errorf("stop %d sits at 0,0 — route sequencing skips those", i) + } + if seen[got] { + t.Errorf("stop %d repeats coordinates %v", i, got) + } + seen[got] = true + if got != want[i] { + t.Errorf("stop %d coordinates = %v, want %v", i, got, want[i]) + } + } +} + +// Recipient details stay attached to their own stop. Delivering Meera's parcel +// while showing Joe's phone number is a handover to the wrong person. +func TestFanoutRecipientsStayWithTheirStop(t *testing.T) { + booking, destinations := cxThreeDestinationFixture() + stops := milerStopsForBooking(&booking, destinations) + + want := []struct{ name, phone string }{ + {"Meera S", "+919884412210"}, + {"Joe Oommen", "+919847011223"}, + {"Arun Kumar", "+919000011223"}, + } + for i, w := range want { + if stops[i].recipientName != w.name { + t.Errorf("stop %d recipientName = %q, want %q", i, stops[i].recipientName, w.name) + } + if stops[i].recipientPhone != w.phone { + t.Errorf("stop %d recipientPhone = %q, want %q", i, stops[i].recipientPhone, w.phone) + } + } +} + +// Tracking and consignment ids stay attached to their own stop. A crossed id +// means the rider closes the wrong order and the customer is told the wrong +// parcel arrived. +func TestFanoutTrackingAndConsignmentIdsStayWithTheirStop(t *testing.T) { + booking, destinations := cxThreeDestinationFixture() + stops := milerStopsForBooking(&booking, destinations) + + wantTracking := []string{"DMX10482913", "DMX10559120", "DMX10662004"} + wantConsignment := []int{901, 902, 903} + + seenTracking := map[string]bool{} + seenConsignment := map[int]bool{} + + for i := range stops { + if stops[i].trackingNo != wantTracking[i] { + t.Errorf("stop %d trackingNo = %q, want %q", i, stops[i].trackingNo, wantTracking[i]) + } + if stops[i].consignmentID == nil { + t.Fatalf("stop %d has no consignment id", i) + } + if *stops[i].consignmentID != wantConsignment[i] { + t.Errorf("stop %d consignmentID = %d, want %d", i, *stops[i].consignmentID, wantConsignment[i]) + } + if seenTracking[stops[i].trackingNo] || seenConsignment[*stops[i].consignmentID] { + t.Errorf("stop %d repeats an identifier already used by another stop", i) + } + seenTracking[stops[i].trackingNo] = true + seenConsignment[*stops[i].consignmentID] = true + } +} + +// COD is never copied across destinations. Money is the one field where a leak +// is not a display bug: a rider shown 1200 at a door that owes nothing collects +// it, and a door owing 450 shown 0 goes uncollected. +func TestFanoutCodIsNotCopiedAcrossDestinations(t *testing.T) { + booking, destinations := cxThreeDestinationFixture() + stops := milerStopsForBooking(&booking, destinations) + + want := []float64{1200, 0, 450} + for i, w := range want { + if stops[i].codAmount != w { + t.Errorf("stop %d codAmount = %v, want %v", i, stops[i].codAmount, w) + } + } + + for i := 1; i < len(stops); i++ { + if stops[i].codAmount == 1200 { + t.Errorf("stop %d carries destination 0's COD of 1200", i) + } + } +} + +// Parcels follow their own destination, so each order settles and is handed +// over with the packages that actually belong to it. +func TestFanoutParcelsFollowTheirDestination(t *testing.T) { + booking, destinations := cxThreeDestinationFixture() + stops := milerStopsForBooking(&booking, destinations) + + want := []int{2, 1, 1} + for i, w := range want { + if len(stops[i].parcels) != w { + t.Errorf("stop %d has %d parcels, want %d", i, len(stops[i].parcels), w) + } + } + seen := map[int]int{} + for i, s := range stops { + for _, p := range s.parcels { + if prev, dup := seen[p.Bookingparcelid]; dup { + t.Errorf("parcel %d appears on stops %d and %d", p.Bookingparcelid, prev, i) + } + seen[p.Bookingparcelid] = i + } + } +} + +// Route order follows destinationseq. The stops are emitted in seq order so an +// app that renders them as received shows "Stop 1, 2, 3" without sorting. +func TestFanoutRouteOrderFollowsDestinationSeq(t *testing.T) { + booking, destinations := cxThreeDestinationFixture() + + // Deliberately handed over out of order — the ordering must come from seq, + // not from however the rows happened to arrive. + shuffled := []models.BookingDestination{destinations[2], destinations[0], destinations[1]} + stops := milerStopsForBooking(&booking, shuffled) + + if len(stops) != 3 { + t.Fatalf("got %d stops, want 3", len(stops)) + } + for i := 1; i < len(stops); i++ { + if stops[i].seq <= stops[i-1].seq { + t.Errorf("stop %d has seq %d, which does not follow stop %d's seq %d", + i, stops[i].seq, i-1, stops[i-1].seq) + } + } + // And seq must still name the right destination after ordering. + if stops[0].trackingNo != "DMX10482913" || stops[2].trackingNo != "DMX10662004" { + t.Errorf("ordering by seq detached a stop from its order: %q ... %q", + stops[0].trackingNo, stops[2].trackingNo) + } +} + +// Every one of the ten row-level fields the rider app reads is present and +// destination-specific. This is the field-by-field audit, asserted rather than +// described: consignmentid, trackingno, deliveryaddress, deliverylatitude, +// deliverylongitude, recipientname, recipientphone, collectionamt (codAmount), +// destinationseq (seq) and destinationcount (len). +func TestFanoutEveryRowLevelFieldIsDestinationSpecific(t *testing.T) { + booking, destinations := cxThreeDestinationFixture() + stops := milerStopsForBooking(&booking, destinations) + + if len(stops) != 3 { + t.Fatalf("got %d stops, want 3", len(stops)) + } + + for i, s := range stops { + if s.consignmentID == nil { + t.Errorf("stop %d: consignmentid missing", i) + } + if s.trackingNo == "" { + t.Errorf("stop %d: trackingno missing", i) + } + if s.deliveryAddress == "" { + t.Errorf("stop %d: deliveryaddress missing", i) + } + if s.deliveryLat == 0 && s.deliveryLng == 0 { + t.Errorf("stop %d: delivery coordinates missing", i) + } + if s.recipientName == "" { + t.Errorf("stop %d: recipientname missing", i) + } + if s.recipientPhone == "" { + t.Errorf("stop %d: recipientphone missing", i) + } + if s.seq != i { + t.Errorf("stop %d: destinationseq = %d, want %d", i, s.seq, i) + } + } + + // codAmount is exempt from the non-empty check — 0 is a legitimate value + // (destination 1 owes nothing) and is asserted exactly in the COD test. +} + +// maxDestinations is enforced HERE, not in the app. The Flutter limit is UI +// guidance that a modified client, a stale build, or a failed +// /config/booking-limits fetch can all bypass; this is the authority. +// +// The default matters as much as the check: with no configuration row at all +// the cap must resolve to the SAFEST value, not the most permissive. A gate +// that opens when its config is missing is not a gate — a migration that ran +// without the seed would silently admit multi-destination bookings the rider +// app cannot complete. +func TestMaxDestinationsDefaultsToTheSafestValue(t *testing.T) { + if cxDefaultMaxDestinations != 1 { + t.Errorf("cxDefaultMaxDestinations = %d, want 1 — a missing config row must not open the gate", + cxDefaultMaxDestinations) + } + if cxDefaultMaxPackages <= 0 { + t.Errorf("cxDefaultMaxPackages = %d — a zero cap rejects every booking on the platform", + cxDefaultMaxPackages) + } +} diff --git a/controllers/cxDeviceController.go b/controllers/cxDeviceController.go new file mode 100644 index 0000000..d0bec08 --- /dev/null +++ b/controllers/cxDeviceController.go @@ -0,0 +1,85 @@ +package controllers + +import ( + "strings" + + "doormile/db" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Device registration — §10 of the contract. +// +// One row per device token rather than one column on the customer. A customer +// with a phone and a tablet has to get the delivery notification on both, and +// appcustomers.device_token could only ever hold whichever registered last — +// so the older device silently stopped receiving anything. + +// RegisterCxDevice records a push token for the signed-in customer. +func RegisterCxDevice(c *fiber.Ctx) error { + customerID := c.Locals("userid").(int) + + var req struct { + Token string `json:"token"` + Platform string `json:"platform"` + AppVersion string `json:"appVersion"` + } + if err := c.BodyParser(&req); err != nil { + return utils.CxBadRequest(c, "We could not read that request") + } + token := strings.TrimSpace(req.Token) + if token == "" { + return utils.CxBadRequest(c, "A device token is required") + } + + platform := strings.ToLower(strings.TrimSpace(req.Platform)) + if platform != "android" && platform != "ios" { + platform = "" + } + + device := models.CustomerDevice{ + Appcustomerid: customerID, + Token: token, + Platform: platform, + Appversion: strings.TrimSpace(req.AppVersion), + Lastseenat: utils.DBNow(), + } + + // A token can migrate between accounts — a shared handset, or a customer + // signing out and a second one signing in. Upserting on the token (rather + // than inserting) reassigns it, which is the only outcome that does not + // send one person's parcel updates to another person's phone. + if err := db.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "token"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "appcustomerid", "platform", "appversion", "lastseenat", + }), + }).Create(&device).Error; err != nil { + utils.Error("RegisterCxDevice: upsert failed", "customer_id", customerID, "error", err) + return utils.CxInternal(c) + } + + return utils.CxOK(c, fiber.Map{"registered": true}) +} + +// UnregisterCxDevice drops a push token. Called on sign-out, so a signed-out +// phone stops receiving updates about a pickup it can no longer open. +func UnregisterCxDevice(c *fiber.Ctx) error { + customerID := c.Locals("userid").(int) + token := strings.TrimSpace(c.Params("token")) + if token == "" { + return utils.CxBadRequest(c, "A device token is required") + } + + if err := db.DB.Where("appcustomerid = ? AND token = ?", customerID, token). + Delete(&models.CustomerDevice{}).Error; err != nil && err != gorm.ErrRecordNotFound { + utils.Error("UnregisterCxDevice: delete failed", "customer_id", customerID, "error", err) + return utils.CxInternal(c) + } + + return utils.CxOK(c, fiber.Map{"registered": false}) +} diff --git a/controllers/cxFareController.go b/controllers/cxFareController.go new file mode 100644 index 0000000..035ffde --- /dev/null +++ b/controllers/cxFareController.go @@ -0,0 +1,266 @@ +package controllers + +import ( + "fmt" + "math" + "strings" + + "doormile/db" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" +) + +// Fare estimate — §7 of the contract. +// +// The number here is an ESTIMATE RANGE and never a final price. Weight is never +// collected from the customer: the miler weighs and photographs each package at +// the door, and that is when the price settles. Everything shown before that is +// a promise about a band, which is why this returns min/max rather than a +// figure. +// +// Called on every route and package-count change, so it stays cheap: the +// pricing slab comes out of the same Redis-warmed cache the public price check +// uses, and the distance is straight-line rather than a routing call. + +// cxAssumedKgPerPackage is the weight band the estimate is priced against +// before anything has been weighed. It is the ceiling of the standard box the +// copy promises ("Standard box (up to 3 kg)"), so the customer is quoted the +// top of the band they were shown rather than an optimistic guess that the +// settled price then exceeds. +const cxAssumedKgPerPackage = 3.0 + +// cxMultiStopUpliftPct is charged per additional destination on one pickup. +// One visit collecting for three places is one visit, so the uplift is well +// under three times the price — but the parcels still travel three separate +// journeys after the hub, and pricing them as one would undercharge the part +// that actually costs money. +const cxMultiStopUpliftPct = 0.35 + +// cxFallbackBase / cxFallbackPerKm / cxFallbackPerKg reproduce the estimate the +// booking path already falls back to when no pricing rule matches, so an +// unpriced lane quotes the same number it charges. +const ( + cxFallbackBase = 50.0 + cxFallbackPerKm = 5.0 + cxFallbackPerKg = 10.0 +) + +type cxEstimateDestination struct { + StateCode string `json:"stateCode"` + DistrictCode string `json:"districtCode"` + PackageCount int `json:"packageCount"` +} + +type cxEstimateRequest struct { + Pickup struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + } `json:"pickup"` + Destinations []cxEstimateDestination `json:"destinations"` +} + +// cxQuote is what both the estimate endpoint and the booking create path work +// from, so the number quoted on Review is the number stored on the booking. +type cxQuote struct { + Min int + Max int + PaymentMethod string + Parcel string + RouteKM float64 +} + +// EstimateCxFare prices a whole pickup — one visit, every destination. +func EstimateCxFare(c *fiber.Ctx) error { + var req cxEstimateRequest + if err := c.BodyParser(&req); err != nil { + return utils.CxBadRequest(c, "We could not read that request") + } + if len(req.Destinations) == 0 { + return utils.CxBadRequest(c, "Add at least one destination") + } + + quote := quoteCxPickup(req.Pickup.Lat, req.Pickup.Lng, req.Destinations) + + // Volatile enough to be worth a short cache, stable enough that the same + // route typed twice should not re-price. + c.Set("Cache-Control", "max-age=60") + + return utils.CxOK(c, fiber.Map{ + "min": quote.Min, + "max": quote.Max, + "paymentMethod": quote.PaymentMethod, + "parcel": quote.Parcel, + "routeKm": quote.RouteKM, + }) +} + +// quoteCxPickup prices one pickup visit across all of its destinations. +// +// The farthest destination sets the route distance shown on the outline and the +// receipt; every destination contributes its own leg price, and the additional +// stops carry an uplift rather than a full second visit. +func quoteCxPickup(pickupLat, pickupLng float64, destinations []cxEstimateDestination) cxQuote { + districts := loadDistricts(destinations) + + var totalMin, totalMax, maxRouteKM float64 + totalPackages := 0 + + for i, d := range destinations { + packages := d.PackageCount + if packages < 1 { + packages = 1 + } + totalPackages += packages + + district, known := districts[strings.ToUpper(strings.TrimSpace(d.DistrictCode))] + + routeKM := 0.0 + if known && (district.Centrelatitude != 0 || district.Centrelongitude != 0) && + (pickupLat != 0 || pickupLng != 0) { + routeKM = calculateDistance(pickupLat, pickupLng, district.Centrelatitude, district.Centrelongitude) + } + if routeKM > maxRouteKM { + maxRouteKM = routeKM + } + + legMin, legMax := priceLeg(pickupLat, pickupLng, district, known, packages, routeKM) + + // The first destination is the visit; every one after it is an extra + // leg on the same visit. + if i > 0 { + legMin *= cxMultiStopUpliftPct + 1 + legMax *= cxMultiStopUpliftPct + 1 + } + totalMin += legMin + totalMax += legMax + } + + // Whole rupees, not paise — "min: 49" renders as ₹49. Rounded outward so + // the band the customer is shown always contains the price that settles + // inside it. + minR := int(math.Floor(totalMin)) + maxR := int(math.Ceil(totalMax)) + if maxR < minR { + maxR = minR + } + + return cxQuote{ + Min: minR, + Max: maxR, + PaymentMethod: "UPI · Cash at doorstep", + Parcel: describeParcels(totalPackages), + RouteKM: math.Round(maxRouteKM*10) / 10, + } +} + +// priceLeg prices one destination's journey. Falls back to the same +// distance-and-weight formula the booking path uses when no pricing rule +// covers the lane, so an unpriced route still quotes rather than failing — +// a failed estimate must never block a booking. +func priceLeg(pickupLat, pickupLng float64, district models.ServiceableDistrict, known bool, packages int, routeKM float64) (min, max float64) { + weight := float64(packages) * cxAssumedKgPerPackage + + zone := "National" + if known && district.Pincodeprefix != "" { + // Zone is resolved from postal prefixes, the same rule the rest of the + // pricing engine uses, so an estimate and a settlement agree on which + // slab applies. + zone = resolveZone(pincodeForPoint(pickupLat, pickupLng), district.Pincodeprefix) + } + + if rules := matchedPricingRules(zone, "Normal", weight); len(rules) > 0 { + lo, hi := rules[0].Minprice, rules[0].Maxprice + for _, r := range rules[1:] { + if r.Minprice < lo { + lo = r.Minprice + } + if r.Maxprice > hi { + hi = r.Maxprice + } + } + return lo, hi + } + + base := cxFallbackBase + routeKM*cxFallbackPerKm + weight*cxFallbackPerKg + // A ±20% band around the fallback, so the customer still sees a range and + // not a false precision the miler's scale is about to contradict. + return base * 0.8, base * 1.2 +} + +// matchedPricingRules returns the active rules covering a weight in a zone, +// reusing the Redis-warmed slab the public price check reads. +func matchedPricingRules(zone, serviceType string, weight float64) []models.DoormilePricing { + rules, err := loadFromPostgres(zone, serviceType) + if err != nil || len(rules) == 0 { + return nil + } + matched := applyFilters(rules, weight, "General") + if len(matched) == 0 { + matched = applyFilters(rules, weight, "") + } + return matched +} + +// loadDistricts fetches every district named in one estimate in a single query. +func loadDistricts(destinations []cxEstimateDestination) map[string]models.ServiceableDistrict { + codes := make([]string, 0, len(destinations)) + seen := map[string]bool{} + for _, d := range destinations { + code := strings.ToUpper(strings.TrimSpace(d.DistrictCode)) + if code != "" && !seen[code] { + seen[code] = true + codes = append(codes, code) + } + } + out := make(map[string]models.ServiceableDistrict, len(codes)) + if len(codes) == 0 { + return out + } + var districts []models.ServiceableDistrict + if err := db.DB.Where("districtcode IN ?", codes).Find(&districts).Error; err != nil { + utils.Warn("loadDistricts: query failed, pricing on the fallback formula", "error", err) + return out + } + for _, d := range districts { + out[d.Districtcode] = d + } + return out +} + +// pincodeForPoint gives the zone resolver something to work with when the +// pickup is a map pin rather than a typed address. Empty is a valid answer — +// resolveZone treats an unknown prefix as a different state, which prices the +// long way round rather than under-quoting. +func pincodeForPoint(lat, lng float64) string { + if lat == 0 && lng == 0 { + return "" + } + var hubs []models.Hub + if err := db.DB.Select("hubid, pincode, latitude, longitude"). + Where("status = ? AND deletedat IS NULL", "Active").Find(&hubs).Error; err != nil { + return "" + } + best := -1.0 + pincode := "" + for i := range hubs { + h := hubs[i] + if h.Pincode == "" || (h.Latitude == 0 && h.Longitude == 0) { + continue + } + d := calculateDistance(lat, lng, h.Latitude, h.Longitude) + if best < 0 || d < best { + best, pincode = d, h.Pincode + } + } + return pincode +} + +// describeParcels is the "what is being priced" line on Review and the receipt. +func describeParcels(packages int) string { + if packages <= 1 { + return fmt.Sprintf("Standard box (up to %.0f kg)", cxAssumedKgPerPackage) + } + return fmt.Sprintf("%d boxes (up to %.0f kg each)", packages, cxAssumedKgPerPackage) +} diff --git a/controllers/cxHttp_test.go b/controllers/cxHttp_test.go new file mode 100644 index 0000000..075ab43 --- /dev/null +++ b/controllers/cxHttp_test.go @@ -0,0 +1,713 @@ +package controllers + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "doormile/config" + "doormile/utils" + + "github.com/gofiber/fiber/v2" +) + +// HTTP-level contract tests for the customer surface. +// +// These exercise the real handlers through a real Fiber router and assert the +// STATUS CODE and the ENVELOPE the client will actually receive. They cover +// every path that can be reached without a database — which is every validation +// and gate in the surface, and is precisely where a wrong status code would +// reach production unnoticed. +// +// What they deliberately do NOT cover: the happy paths, which need Postgres, +// Redis and NATS. A 200 from CreateCxBooking cannot be asserted here, and +// pretending otherwise with a mock would test the mock. Those need the +// integration pass against staging (see docs/customer-app-api.md §7). +// +// The rule every test below enforces: a validation failure must be a 4xx with a +// machine-readable error.code and customer-safe English. It must NEVER be a 500 +// ("Something went wrong" on a request the server understood perfectly well) +// and never a bare 404 from the router (which would mean the route is missing). + +// cxFutureSlotID is a slot id whose date cannot go stale. Used by the cases +// where the SLOT is not what is under test — a dated id like slot_20260905_t1 +// silently becomes an expired-slot test the day after it was written, and +// would then assert the wrong failure. +const cxFutureSlotID = "slot_20991231_t1" + +// cxTestApp builds a router with the customer routes mounted and a stub auth +// middleware, so handler behaviour is tested rather than JWT parsing. +func cxTestApp(t *testing.T, authenticated bool) *fiber.App { + t.Helper() + + app := fiber.New(fiber.Config{ + // Without this a panic becomes a dropped connection instead of a 500, + // and a test would report a confusing transport error rather than the + // real fault. + DisableStartupMessage: true, + }) + app.Use(func(c *fiber.Ctx) error { + if authenticated { + c.Locals("userid", 4242) + c.Locals("roleid", 9) + c.Locals("tenantid", 0) + } + return c.Next() + }) + + cfg := &config.Config{JWTSecret: "test-secret"} + + customer := app.Group("/customer") + customer.Post("/auth/otp/request", CxRequestOtp(cfg)) + customer.Post("/auth/signup", CxSignup(cfg)) + customer.Post("/auth/otp/verify", CxVerifyOtp(cfg)) + customer.Post("/auth/refresh", CxRefresh(cfg)) + customer.Post("/fare/estimate", EstimateCxFare) + customer.Post("/bookings", CreateCxBooking) + customer.Patch("/bookings/:reference/destinations/:index", PatchCxDestination) + customer.Get("/orders/:trackingId", GetCxOrder) + customer.Post("/devices", RegisterCxDevice) + customer.Get("/places/reverse-geocode", ReverseGeocodeCx(cfg)) + customer.Post("/ops/bookings/:reference/stage", ForceCxStage) + + return app +} + +type cxResponse struct { + status int + body map[string]interface{} + raw string +} + +func cxDo(t *testing.T, app *fiber.App, method, path string, body interface{}) cxResponse { + t.Helper() + + var reader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + t.Fatalf("could not encode request body: %v", err) + } + reader = bytes.NewReader(encoded) + } + + req := httptest.NewRequest(method, path, reader) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := app.Test(req, 5000) + if err != nil { + t.Fatalf("%s %s: transport error: %v", method, path, err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + out := cxResponse{status: resp.StatusCode, raw: string(raw)} + _ = json.Unmarshal(raw, &out.body) + return out +} + +// assertCxError checks the full error contract in one place: the status code, +// the envelope shape, the machine code, and that the message is fit to show a +// customer. +func assertCxError(t *testing.T, got cxResponse, wantStatus int, wantCode string) { + t.Helper() + + if got.status != wantStatus { + t.Fatalf("status = %d, want %d (body: %s)", got.status, wantStatus, got.raw) + } + if success, _ := got.body["success"].(bool); success { + t.Errorf("success = true on an error response (body: %s)", got.raw) + } + + errObj, ok := got.body["error"].(map[string]interface{}) + if !ok { + t.Fatalf("no error object in the envelope — the client reads error.code (body: %s)", got.raw) + } + if code, _ := errObj["code"].(string); code != wantCode { + t.Errorf("error.code = %q, want %q", errObj["code"], wantCode) + } + + message, _ := got.body["message"].(string) + if message == "" { + t.Error("message is empty — the app renders it verbatim in its one error state") + } + assertCustomerSafe(t, message) +} + +// assertCustomerSafe rejects anything that reads like an internal artefact +// rather than something a customer should be shown. The contract is explicit +// that `message` is displayed verbatim and must never be an enum key, a stack +// trace or a driver error. +func assertCustomerSafe(t *testing.T, message string) { + t.Helper() + + leaks := []string{ + "gorm", "sql:", "pq:", "panic", "nil pointer", "goroutine", + "doormile/", ".go:", "SELECT ", "INSERT ", "record not found", + } + for _, leak := range leaks { + if bytes.Contains([]byte(message), []byte(leak)) { + t.Errorf("message %q leaks an internal detail (%q) to the customer", message, leak) + } + } + // An enum key rather than a sentence — "INVALID_INPUT", "not_found". + if message == "" { + return + } + upperOnly := true + for _, r := range message { + if r >= 'a' && r <= 'z' { + upperOnly = false + break + } + } + if upperOnly { + t.Errorf("message %q looks like an enum key, not customer-safe English", message) + } +} + +// ── Auth (§4) ──────────────────────────────────────────────────────────────── + +func TestCxAuthValidationStatusCodes(t *testing.T) { + app := cxTestApp(t, false) + + cases := []struct { + name string + method string + path string + body interface{} + wantStatus int + wantCode string + }{ + { + name: "otp request with no identifier", + method: http.MethodPost, path: "/customer/auth/otp/request", + body: map[string]interface{}{"identifier": ""}, + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + { + name: "otp request with a malformed phone", + method: http.MethodPost, path: "/customer/auth/otp/request", + body: map[string]interface{}{"identifier": "12345"}, + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + { + name: "otp request with a malformed email", + method: http.MethodPost, path: "/customer/auth/otp/request", + body: map[string]interface{}{"identifier": "joe@example"}, + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + { + // The contract pins this to its own code so the app can highlight + // the name field rather than showing a generic error. + name: "signup with a one-character name", + method: http.MethodPost, path: "/customer/auth/signup", + body: map[string]interface{}{"name": "J", "phone": "+919876543210"}, + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalidName, + }, + { + name: "signup with no name at all", + method: http.MethodPost, path: "/customer/auth/signup", + body: map[string]interface{}{"phone": "+919876543210"}, + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalidName, + }, + { + name: "signup with a valid name but an unusable phone", + method: http.MethodPost, path: "/customer/auth/signup", + body: map[string]interface{}{"name": "Joe Oommen", "phone": "123"}, + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + { + name: "verify with no code", + method: http.MethodPost, path: "/customer/auth/otp/verify", + body: map[string]interface{}{"identifier": "+919876543210", "code": ""}, + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + { + name: "verify with an unusable identifier", + method: http.MethodPost, path: "/customer/auth/otp/verify", + body: map[string]interface{}{"identifier": "nope", "code": "4821"}, + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + { + // 401 rather than 400: the client's recovery is "sign in again", + // which it branches on the status for. + name: "refresh with no token", + method: http.MethodPost, path: "/customer/auth/refresh", + body: map[string]interface{}{"refreshToken": ""}, + wantStatus: fiber.StatusUnauthorized, wantCode: utils.CxErrUnauthorized, + }, + { + name: "refresh with a whitespace token", + method: http.MethodPost, path: "/customer/auth/refresh", + body: map[string]interface{}{"refreshToken": " "}, + wantStatus: fiber.StatusUnauthorized, wantCode: utils.CxErrUnauthorized, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := cxDo(t, app, tc.method, tc.path, tc.body) + // Logged so `go test -v` shows the exact bytes the app receives, + // not just a pass mark. These tests are about the wire contract, so + // the wire response is the evidence. + t.Logf("%s %s -> %d %s", tc.method, tc.path, got.status, got.raw) + assertCxError(t, got, tc.wantStatus, tc.wantCode) + }) + } +} + +// ── Bookings, estimate, devices, places ────────────────────────────────────── + +func TestCxRequestValidationStatusCodes(t *testing.T) { + app := cxTestApp(t, true) + + cases := []struct { + name string + method string + path string + body interface{} + wantStatus int + wantCode string + }{ + { + name: "booking with no destinations", + method: http.MethodPost, path: "/customer/bookings", + body: map[string]interface{}{"slotId": cxFutureSlotID}, + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + { + name: "booking with an empty destinations array", + method: http.MethodPost, path: "/customer/bookings", + body: map[string]interface{}{ + "slotId": cxFutureSlotID, + "destinations": []interface{}{}, + }, + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + { + name: "booking with destinations but no slot", + method: http.MethodPost, path: "/customer/bookings", + body: map[string]interface{}{ + "destinations": []map[string]interface{}{ + {"stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1}, + }, + }, + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + { + name: "estimate with no destinations", + method: http.MethodPost, path: "/customer/fare/estimate", + body: map[string]interface{}{"pickup": map[string]float64{"lat": 11.0, "lng": 76.9}}, + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + { + name: "device registration with no token", + method: http.MethodPost, path: "/customer/devices", + body: map[string]interface{}{"platform": "android"}, + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + { + name: "device registration with a whitespace token", + method: http.MethodPost, path: "/customer/devices", + body: map[string]interface{}{"token": " ", "platform": "ios"}, + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + { + name: "destination patch with a non-numeric index", + method: http.MethodPatch, path: "/customer/bookings/DM-482913/destinations/abc", + body: map[string]interface{}{"street": "12th Main"}, + wantStatus: fiber.StatusNotFound, wantCode: utils.CxErrNotFound, + }, + { + name: "destination patch with a negative index", + method: http.MethodPatch, path: "/customer/bookings/DM-482913/destinations/-1", + body: map[string]interface{}{"street": "12th Main"}, + wantStatus: fiber.StatusNotFound, wantCode: utils.CxErrNotFound, + }, + { + name: "reverse geocode with no coordinates", + method: http.MethodGet, path: "/customer/places/reverse-geocode", + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + { + name: "reverse geocode with unparseable coordinates", + method: http.MethodGet, path: "/customer/places/reverse-geocode?lat=abc&lng=def", + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + { + name: "reverse geocode at the null island", + method: http.MethodGet, path: "/customer/places/reverse-geocode?lat=0&lng=0", + wantStatus: fiber.StatusBadRequest, wantCode: utils.CxErrInvalid, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := cxDo(t, app, tc.method, tc.path, tc.body) + t.Logf("%s %s -> %d %s", tc.method, tc.path, got.status, got.raw) + assertCxError(t, got, tc.wantStatus, tc.wantCode) + }) + } +} + +// A body the parser cannot read is the customer's problem to fix, not a server +// fault. Answering 500 here would put a "Something went wrong" retry loop in +// front of a request that will never succeed. +func TestCxMalformedJsonIsFourHundredNotFiveHundred(t *testing.T) { + app := cxTestApp(t, true) + + for _, path := range []string{ + "/customer/bookings", + "/customer/fare/estimate", + "/customer/devices", + } { + t.Run(path, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte("{not json"))) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, 5000) + if err != nil { + t.Fatalf("transport error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 500 { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d on malformed JSON, want 4xx (body: %s)", resp.StatusCode, raw) + } + if resp.StatusCode != fiber.StatusBadRequest { + t.Errorf("status = %d, want 400", resp.StatusCode) + } + }) + } +} + +// ── The QA stage override (§11) ────────────────────────────────────────────── + +// The override is double-gated. Both switches off must be indistinguishable +// from the route not existing — advertising a disabled admin capability tells +// an attacker exactly what to go looking for. +func TestForceStageIsInvisibleUnlessBothGatesAreOpen(t *testing.T) { + app := cxTestApp(t, true) + + restore := func(key, value string) func() { + previous, had := os.LookupEnv(key) + _ = os.Setenv(key, value) + return func() { + if had { + _ = os.Setenv(key, previous) + } else { + _ = os.Unsetenv(key) + } + } + } + + cases := []struct { + name string + env string + override string + }{ + {"both gates closed", "development", ""}, + {"override off in development", "development", "false"}, + {"override on but production", "production", "true"}, + {"override on but PRODUCTION in caps", "PRODUCTION", "true"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + defer restore("ENV", tc.env)() + defer restore("CX_ALLOW_STAGE_OVERRIDE", tc.override)() + + got := cxDo(t, app, http.MethodPost, + "/customer/ops/bookings/DM-482913/stage", + map[string]interface{}{"stage": "delivered"}) + + if got.status != fiber.StatusNotFound { + t.Fatalf("status = %d, want 404 — a disabled override must not announce itself (body: %s)", + got.status, got.raw) + } + }) + } +} + +// With both gates open the route is reachable, and an unknown stage is a +// validation failure rather than a server fault. +func TestForceStageRejectsAnUnknownStage(t *testing.T) { + app := cxTestApp(t, true) + + previousEnv, hadEnv := os.LookupEnv("ENV") + previousOverride, hadOverride := os.LookupEnv("CX_ALLOW_STAGE_OVERRIDE") + _ = os.Setenv("ENV", "development") + _ = os.Setenv("CX_ALLOW_STAGE_OVERRIDE", "true") + defer func() { + if hadEnv { + _ = os.Setenv("ENV", previousEnv) + } else { + _ = os.Unsetenv("ENV") + } + if hadOverride { + _ = os.Setenv("CX_ALLOW_STAGE_OVERRIDE", previousOverride) + } else { + _ = os.Unsetenv("CX_ALLOW_STAGE_OVERRIDE") + } + }() + + got := cxDo(t, app, http.MethodPost, + "/customer/ops/bookings/DM-482913/stage", + map[string]interface{}{"stage": "teleported"}) + + assertCxError(t, got, fiber.StatusBadRequest, utils.CxErrInvalid) +} + +// ── Envelope shape (§3.1, §3.4) ────────────────────────────────────────────── + +// Every success response carries `message` as a present-but-empty string. The +// contract states it explicitly, and a client that reads message.length on a +// missing key throws. +func TestSuccessEnvelopeAlwaysCarriesAnEmptyMessage(t *testing.T) { + app := fiber.New(fiber.Config{DisableStartupMessage: true}) + app.Get("/ok", func(c *fiber.Ctx) error { + return utils.CxOK(c, fiber.Map{"value": 1}) + }) + app.Get("/created", func(c *fiber.Ctx) error { + return utils.CxCreated(c, fiber.Map{"value": 1}) + }) + app.Get("/list", func(c *fiber.Ctx) error { + return utils.CxList(c, []int{1, 2}, 2, nil) + }) + + cases := []struct { + path string + wantStatus int + }{ + {"/ok", fiber.StatusOK}, + {"/created", fiber.StatusCreated}, + {"/list", fiber.StatusOK}, + } + + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + got := cxDo(t, app, http.MethodGet, tc.path, nil) + if got.status != tc.wantStatus { + t.Fatalf("status = %d, want %d", got.status, tc.wantStatus) + } + if success, _ := got.body["success"].(bool); !success { + t.Error("success != true on a success response") + } + if _, present := got.body["message"]; !present { + t.Error("message key missing — the contract says always present, empty on success") + } + if message, _ := got.body["message"].(string); message != "" { + t.Errorf("message = %q on a success response, want empty", message) + } + if _, present := got.body["data"]; !present { + t.Error("data key missing — every payload lives in data, auth included") + } + }) + } +} + +// A list envelope always carries an ARRAY and an explicit nextCursor, even when +// empty. The client types data as a list and nextCursor as nullable; a missing +// key or a null data throws in its parser. +func TestListEnvelopeIsAlwaysAnArrayWithACursorKey(t *testing.T) { + app := fiber.New(fiber.Config{DisableStartupMessage: true}) + app.Get("/empty", func(c *fiber.Ctx) error { + return utils.CxList(c, []string{}, 0, nil) + }) + cursor := "1042" + app.Get("/paged", func(c *fiber.Ctx) error { + return utils.CxList(c, []string{"a"}, 9, &cursor) + }) + + empty := cxDo(t, app, http.MethodGet, "/empty", nil) + if empty.status != fiber.StatusOK { + t.Fatalf("status = %d, want 200", empty.status) + } + if _, ok := empty.body["data"].([]interface{}); !ok { + t.Errorf("data is not an array on an empty list (body: %s)", empty.raw) + } + if _, present := empty.body["nextCursor"]; !present { + t.Error("nextCursor key missing — it must be present and null on the last page") + } + if empty.body["nextCursor"] != nil { + t.Errorf("nextCursor = %v on the last page, want null", empty.body["nextCursor"]) + } + if total, _ := empty.body["total"].(float64); total != 0 { + t.Errorf("total = %v, want 0", empty.body["total"]) + } + + paged := cxDo(t, app, http.MethodGet, "/paged", nil) + if got, _ := paged.body["nextCursor"].(string); got != cursor { + t.Errorf("nextCursor = %v, want %q", paged.body["nextCursor"], cursor) + } + if total, _ := paged.body["total"].(float64); total != 9 { + t.Errorf("total = %v, want 9 (the size of the filtered set, not the page)", paged.body["total"]) + } +} + +// Every code in the contract maps to the status the client branches on, and +// none of them produce a 5xx. +func TestErrorEnvelopeStatusCodeMapping(t *testing.T) { + app := fiber.New(fiber.Config{DisableStartupMessage: true}) + + cases := []struct { + name string + status int + code string + message string + }{ + {"invalid", fiber.StatusBadRequest, utils.CxErrInvalid, "Every destination needs a serviceable state and district"}, + {"invalid_name", fiber.StatusBadRequest, utils.CxErrInvalidName, "Enter your full name"}, + {"invalid_otp", fiber.StatusUnauthorized, utils.CxErrInvalidOtp, "That code did not match"}, + {"unauthorized", fiber.StatusUnauthorized, utils.CxErrUnauthorized, "Please sign in again"}, + {"forbidden", fiber.StatusForbidden, utils.CxErrForbidden, "You do not have access to this"}, + {"not_found", fiber.StatusNotFound, utils.CxErrNotFound, "We could not find that pickup"}, + {"conflict", fiber.StatusConflict, utils.CxErrConflict, "This pickup can no longer be cancelled"}, + {"unserviceable", fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable, "That district is no longer available"}, + {"rate_limited", fiber.StatusTooManyRequests, utils.CxErrRateLimited, "Too many attempts. Try again in a minute"}, + } + + for _, tc := range cases { + tc := tc + app.Get("/"+tc.name, func(c *fiber.Ctx) error { + return utils.CxFail(c, tc.status, tc.code, tc.message) + }) + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := cxDo(t, app, http.MethodGet, "/"+tc.name, nil) + assertCxError(t, got, tc.status, tc.code) + if got.status >= 500 { + t.Errorf("a documented client error answered %d", got.status) + } + }) + } +} + +// CxInternal is the only 5xx the surface produces, and it must never carry the +// underlying error outward. +func TestInternalErrorNeverLeaksTheCause(t *testing.T) { + app := fiber.New(fiber.Config{DisableStartupMessage: true}) + app.Get("/boom", func(c *fiber.Ctx) error { + return utils.CxInternal(c) + }) + + got := cxDo(t, app, http.MethodGet, "/boom", nil) + if got.status != fiber.StatusInternalServerError { + t.Fatalf("status = %d, want 500", got.status) + } + if message, _ := got.body["message"].(string); message != "Something went wrong" { + t.Errorf("message = %q, want the fixed customer-safe string", message) + } + assertCustomerSafe(t, got.body["message"].(string)) + + errObj, ok := got.body["error"].(map[string]interface{}) + if !ok || errObj["code"] != utils.CxErrServer { + t.Errorf("error.code = %v, want %q", got.body["error"], utils.CxErrServer) + } +} + +// An expired slot and a full slot are different failures. A client that cached +// the slot list and was left open across midnight sends yesterday's window in +// good faith; telling that customer the window "just filled up" is untrue and +// points them at the wrong recovery. +func TestExpiredSlotIsNotReportedAsFull(t *testing.T) { + app := cxTestApp(t, true) + + // A slot id from a date that has certainly passed. It is rejected before + // any database access, because the id carries its own date. + got := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{ + "pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558}, + "slotId": "slot_20200101_t1", + "destinations": []map[string]interface{}{ + {"stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1}, + }, + }) + + if got.status == fiber.StatusConflict { + t.Fatalf("an expired slot answered 409 — that is the capacity race, not a stale id (body: %s)", got.raw) + } + if got.status >= 500 { + t.Fatalf("status = %d, want 4xx (body: %s)", got.status, got.raw) + } + if message, _ := got.body["message"].(string); strings.Contains(strings.ToLower(message), "filled up") { + t.Errorf("message = %q — an expired slot is not a full one", message) + } +} + +// The server refuses an over-cap booking itself. The client's own limit is UI +// guidance — a modified build, or one whose /config/booking-limits fetch +// failed, still cannot create a booking the fleet cannot service. +func TestMaxDestinationsIsEnforcedServerSide(t *testing.T) { + app := cxTestApp(t, true) + + // Above the absolute ceiling, so the refusal lands before any database + // work and is assertable here. The configured per-city cap is the real + // policy and is exercised separately in cxCustomerApp_test.go. + destinations := make([]map[string]interface{}, 0, cxAbsoluteMaxDestinations+1) + for i := 0; i <= cxAbsoluteMaxDestinations; i++ { + destinations = append(destinations, map[string]interface{}{ + "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 1, + }) + } + + got := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{ + "pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558}, + "slotId": "slot_20991231_t1", + "destinations": destinations, + }) + + if got.status >= 500 { + t.Fatalf("status = %d, want a 4xx refusal (body: %s)", got.status, got.raw) + } + if got.status < 400 { + t.Fatalf("status = %d — an over-cap booking was accepted (body: %s)", got.status, got.raw) + } +} + +// "No destinations at all" and "a destination is missing its state or district" +// are different problems with different fixes. They shared one message, which +// told a customer who had added nothing to go and correct the state on +// destinations they did not have. The messages must stay distinct AND the empty +// case must match what the estimate endpoint says for the same mistake. +func TestEmptyDestinationsSaysAddOneNotFixTheirDetails(t *testing.T) { + app := cxTestApp(t, true) + + booking := cxDo(t, app, http.MethodPost, "/customer/bookings", map[string]interface{}{ + "pickup": map[string]interface{}{"title": "a", "sub": "b", "lat": 11.0168, "lng": 76.9558}, + "slotId": cxFutureSlotID, + "destinations": []interface{}{}, + }) + estimate := cxDo(t, app, http.MethodPost, "/customer/fare/estimate", map[string]interface{}{ + "pickup": map[string]interface{}{"lat": 11.0168, "lng": 76.9558}, + "destinations": []interface{}{}, + }) + + t.Logf("booking -> %d %s", booking.status, booking.raw) + t.Logf("estimate -> %d %s", estimate.status, estimate.raw) + + bookingMsg, _ := booking.body["message"].(string) + estimateMsg, _ := estimate.body["message"].(string) + + if strings.Contains(bookingMsg, "serviceable state and district") { + t.Errorf("empty destinations answered %q — that describes a problem the customer does not have; "+ + "they added nothing, so they need to add one", bookingMsg) + } + if bookingMsg != estimateMsg { + t.Errorf("the same mistake is described two ways: booking says %q, estimate says %q", + bookingMsg, estimateMsg) + } + if !strings.Contains(strings.ToLower(bookingMsg), "at least one destination") { + t.Errorf("message = %q, want it to name the actual fix (add a destination)", bookingMsg) + } +} diff --git a/controllers/cxIdentifierScramble.go b/controllers/cxIdentifierScramble.go new file mode 100644 index 0000000..13bb6ad --- /dev/null +++ b/controllers/cxIdentifierScramble.go @@ -0,0 +1,181 @@ +package controllers + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "os" + "strings" + + "doormile/utils" +) + +// Format-preserving scrambling for the customer-facing identifiers. +// +// THE PROBLEM. Both identifiers come off a Postgres sequence, because a +// sequence is the only generator here that can promise uniqueness — the columns +// are UNIQUE, and a random 8-digit id collides with ~43% probability by the +// ten-thousandth parcel, which would be a rider unable to complete a pickup. +// But a sequence is also readable: DMX10000042 and DMX10000043 are visibly +// adjacent, so anyone holding two numbers learns the throughput between them, +// and anyone holding one can guess its neighbours. +// +// THE FIX. Keep the sequence — keep its uniqueness guarantee — and pass the +// index through a bijection before formatting it. Same one-to-one property, so +// no two parcels can ever collide; unrelated outputs, so nothing is guessable +// from a neighbour. +// +// WHY NOT THE OBVIOUS TRICK. Multiplying by a number coprime with the domain is +// also a bijection and is one line. It is wrong here: a multi-destination +// pickup hands ONE customer three consecutive sequence values, so the +// differences between their three tracking numbers are all exactly the +// multiplier. One booking leaks the key, and the whole range becomes walkable. +// The mapping has to be non-linear. +// +// WHAT THIS IS. A 4-round balanced Feistel network keyed with HMAC-SHA256, plus +// cycle-walking to keep the result inside the digit range. A Feistel is a +// bijection for ANY round function — that is its defining property — so +// uniqueness survives regardless of the key. Cycle-walking (re-encrypt until +// the output lands in range) preserves bijectivity on the subset. +// +// WHAT THIS IS NOT. Not a security boundary. Every route that resolves a +// tracking number is already authenticated and owner-scoped, and that is what +// actually stops a stranger reading someone's parcel. This removes the +// information leak in the identifier itself, so the authorisation check is not +// the only thing standing between an outsider and your volume figures. + +const ( + // Tracking numbers occupy DMX10000000..DMX99999999 — 90,000,000 values, + // always eight digits so the format never changes width. + cxTrackingBase = 10_000_000 + cxTrackingDomain = 90_000_000 + // 28 bits (two 14-bit halves) is the smallest even split covering the + // domain. Cycle-walking averages ~3 encryptions per id; each is four + // HMACs, so this is microseconds. + cxTrackingHalfBits = 14 + + // Booking references occupy DM-100000..DM-999999 — 900,000 values, always + // six digits. + cxBookingBase = 100_000 + cxBookingDomain = 900_000 + // 20 bits (two 10-bit halves). Cycle-walking averages ~1.2 encryptions. + cxBookingHalfBits = 10 + + // cxFeistelRounds. Four is the standard minimum for a Feistel to be a + // strong pseudorandom permutation (Luby–Rackoff). More rounds cost HMACs + // for no property this needs. + cxFeistelRounds = 4 + + // cxCycleWalkLimit bounds the walk so a pathological key can never hang a + // request. Reaching it is astronomically unlikely — each step has a ~2/3 + // chance of landing in range for tracking numbers — and the caller falls + // back to the plain sequence rather than failing a booking. + cxCycleWalkLimit = 64 +) + +// cxScrambleKey keys the round function. +// +// Overridable via CX_ID_SCRAMBLE_KEY. Changing it changes every identifier +// minted AFTERWARDS and none already stored, so rotation is safe but leaves a +// visible discontinuity — there is no reason to rotate it, and a good reason +// not to. +// +// The built-in default is not a secret and is not pretending to be one. It +// exists so the scrambling works out of the box rather than being silently off +// on any deployment that forgot to set an env var — an identifier scheme that +// depends on configuration to be safe is one that will be unsafe somewhere. +var cxScrambleKey = func() []byte { + if k := strings.TrimSpace(os.Getenv("CX_ID_SCRAMBLE_KEY")); k != "" { + return []byte(k) + } + return []byte("doormile-cx-identifier-permutation-v1") +}() + +// cxFeistelRound is the round function. It need not be invertible — a Feistel +// is a bijection whatever this returns — so any keyed mixing works, and HMAC +// gives good diffusion for four bytes of output. +func cxFeistelRound(half uint64, round int, key []byte) uint64 { + var buf [9]byte + binary.BigEndian.PutUint64(buf[:8], half) + buf[8] = byte(round) + + mac := hmac.New(sha256.New, key) + mac.Write(buf[:]) + sum := mac.Sum(nil) + + return uint64(binary.BigEndian.Uint32(sum[:4])) +} + +// cxFeistelEncrypt permutes a value within 2^(2*halfBits). +// +// Bijective by construction: every round is invertible because the half that is +// mixed is carried forward untouched, so the whole network can be run +// backwards. That is the property the UNIQUE constraint depends on. +func cxFeistelEncrypt(x uint64, halfBits uint, key []byte) uint64 { + mask := uint64(1)<> halfBits) & mask + right := x & mask + + for round := 0; round < cxFeistelRounds; round++ { + left, right = right, left^(cxFeistelRound(right, round, key)&mask) + } + return (left << halfBits) | right +} + +// cxPermuteIndex maps a sequence index onto a scattered index in the same +// domain, one-to-one. +// +// Cycle-walking: the Feistel operates on the whole power-of-two space, which is +// larger than the digit range, so an output that overshoots is re-encrypted +// until it lands inside. Re-encrypting a bijection is still a bijection on the +// subset, so no two indices can ever converge. +func cxPermuteIndex(index, domain uint64, halfBits uint) uint64 { + if index >= domain { + // Past the end of the fixed-width range. The caller handles this; + // returning the index unchanged keeps the function total. + return index + } + + x := index + for i := 0; i < cxCycleWalkLimit; i++ { + x = cxFeistelEncrypt(x, halfBits, cxScrambleKey) + if x < domain { + return x + } + } + + // Unreachable in practice. Falling back to the sequential index keeps the + // identifier unique — which is the property that must never break — and + // loses only the scattering. + utils.Warn("cxPermuteIndex: cycle walk did not converge, using the sequential index", + "index", index, "domain", domain) + return index +} + +// cxScrambledTracking turns a sequence value into the eight digits after DMX. +// +// Returns ok=false once the sequence runs past the fixed-width range, so the +// caller can fall back to plain sequential formatting and let the identifier +// grow a digit rather than wrapping onto one already issued. +func cxScrambledTracking(seq int64) (uint64, bool) { + if seq < cxTrackingBase { + return 0, false + } + index := uint64(seq - cxTrackingBase) + if index >= cxTrackingDomain { + return 0, false + } + return cxTrackingBase + cxPermuteIndex(index, cxTrackingDomain, cxTrackingHalfBits), true +} + +// cxScrambledBooking is the same for the six digits after DM-. +func cxScrambledBooking(seq int64) (uint64, bool) { + if seq < cxBookingBase { + return 0, false + } + index := uint64(seq - cxBookingBase) + if index >= cxBookingDomain { + return 0, false + } + return cxBookingBase + cxPermuteIndex(index, cxBookingDomain, cxBookingHalfBits), true +} diff --git a/controllers/cxIdentifierScramble_test.go b/controllers/cxIdentifierScramble_test.go new file mode 100644 index 0000000..9adc375 --- /dev/null +++ b/controllers/cxIdentifierScramble_test.go @@ -0,0 +1,281 @@ +package controllers + +import ( + "fmt" + "testing" +) + +// The scrambling exists to remove an information leak, but the property that +// MUST survive it is uniqueness: trackingno and bookingno are UNIQUE columns, +// and a collision is a rider standing at a door unable to complete a pickup. +// Every test here is ultimately about that. + +// No two sequence values may ever produce the same tracking number. Checked +// over a large contiguous run, which is exactly the shape real traffic takes. +func TestTrackingScrambleIsCollisionFree(t *testing.T) { + const sample = 200_000 + + seen := make(map[uint64]int64, sample) + for seq := int64(cxTrackingBase); seq < cxTrackingBase+sample; seq++ { + got, ok := cxScrambledTracking(seq) + if !ok { + t.Fatalf("seq %d reported out of range inside the domain", seq) + } + if prev, dup := seen[got]; dup { + t.Fatalf("COLLISION: seq %d and seq %d both produced %d — "+ + "the UNIQUE constraint would reject the second booking", prev, seq, got) + } + seen[got] = seq + } + if len(seen) != sample { + t.Errorf("produced %d distinct numbers from %d inputs", len(seen), sample) + } +} + +func TestBookingScrambleIsCollisionFree(t *testing.T) { + // The booking domain is only 900,000, so the whole thing is checkable — + // this is an exhaustive proof of bijectivity, not a sample. + seen := make(map[uint64]int64, cxBookingDomain) + for seq := int64(cxBookingBase); seq < cxBookingBase+cxBookingDomain; seq++ { + got, ok := cxScrambledBooking(seq) + if !ok { + t.Fatalf("seq %d reported out of range inside the domain", seq) + } + if prev, dup := seen[got]; dup { + t.Fatalf("COLLISION: seq %d and seq %d both produced %d", prev, seq, got) + } + seen[got] = seq + } + if len(seen) != cxBookingDomain { + t.Fatalf("the permutation is not a bijection: %d distinct outputs from %d inputs", + len(seen), cxBookingDomain) + } +} + +// Output must stay inside the digit range, or the format silently changes +// width and every label, column and deep link that assumed it breaks. +func TestScrambledIdentifiersKeepTheirWidth(t *testing.T) { + for _, seq := range []int64{ + cxTrackingBase, + cxTrackingBase + 1, + cxTrackingBase + 12_345, + cxTrackingBase + cxTrackingDomain - 1, + } { + got, ok := cxScrambledTracking(seq) + if !ok { + t.Fatalf("seq %d out of range", seq) + } + if got < cxTrackingBase || got > 99_999_999 { + t.Errorf("seq %d produced %d, outside the eight-digit range", seq, got) + } + if formatted := fmt.Sprintf("DMX%08d", got); len(formatted) != 11 { + t.Errorf("formatted as %q (%d chars), want 11", formatted, len(formatted)) + } + } + + for _, seq := range []int64{ + cxBookingBase, + cxBookingBase + 1, + cxBookingBase + cxBookingDomain - 1, + } { + got, ok := cxScrambledBooking(seq) + if !ok { + t.Fatalf("seq %d out of range", seq) + } + if got < cxBookingBase || got > 999_999 { + t.Errorf("seq %d produced %d, outside the six-digit range", seq, got) + } + if formatted := fmt.Sprintf("DM-%06d", got); len(formatted) != 9 { + t.Errorf("formatted as %q (%d chars), want 9", formatted, len(formatted)) + } + } +} + +// The point of the whole exercise: consecutive sequence values must NOT produce +// adjacent identifiers. This is the leak being closed. +func TestConsecutiveSequenceValuesAreNotAdjacent(t *testing.T) { + const run = 500 + + var previous uint64 + adjacent := 0 + for i := 0; i < run; i++ { + got, _ := cxScrambledTracking(int64(cxTrackingBase + i)) + if i > 0 { + diff := int64(got) - int64(previous) + if diff < 0 { + diff = -diff + } + if diff < 100 { + adjacent++ + } + } + previous = got + } + + // In a well-scattered 90,000,000-wide range, landing within 100 of the + // previous value should essentially never happen. + if adjacent > 2 { + t.Errorf("%d of %d consecutive pairs landed within 100 of each other — "+ + "the identifiers are still walkable", adjacent, run-1) + } +} + +// A multi-destination pickup hands ONE customer several consecutive sequence +// values at once. If the mapping were linear — multiply by a coprime, the +// obvious one-line trick — the differences between those tracking numbers would +// all equal the multiplier, and that single booking would hand over the key to +// the whole range. This is the test that rejects that design. +func TestOneBookingDoesNotLeakTheMapping(t *testing.T) { + // Three orders minted back to back, as a three-destination pickup would. + a, _ := cxScrambledTracking(cxTrackingBase + 5000) + b, _ := cxScrambledTracking(cxTrackingBase + 5001) + c, _ := cxScrambledTracking(cxTrackingBase + 5002) + + d1 := int64(b) - int64(a) + d2 := int64(c) - int64(b) + + if d1 == d2 { + t.Fatalf("consecutive differences are identical (%d) — the mapping is "+ + "linear, so one multi-destination booking reveals it and the whole "+ + "range becomes enumerable", d1) + } + + // And knowing two neighbours must not predict the third. + if int64(c) == int64(b)+d1 { + t.Error("the third identifier is predictable from the first two") + } +} + +// The mapping is deterministic — the same sequence value always yields the same +// identifier. It is computed at insert time and stored, so this matters only +// for reasoning and tests, but a non-deterministic mapping would mean the +// scrambling depended on something it should not. +func TestScramblingIsDeterministic(t *testing.T) { + for _, seq := range []int64{cxTrackingBase, cxTrackingBase + 99, cxTrackingBase + 123_456} { + first, _ := cxScrambledTracking(seq) + for i := 0; i < 5; i++ { + again, _ := cxScrambledTracking(seq) + if again != first { + t.Fatalf("seq %d produced %d then %d", seq, first, again) + } + } + } +} + +// Past the fixed-width range the caller must be told, so it can let the +// identifier grow a digit rather than wrap onto one already issued. Wrapping +// would be a duplicate, and a duplicate is a failed booking. +func TestExhaustedDomainIsReportedNotWrapped(t *testing.T) { + if _, ok := cxScrambledTracking(cxTrackingBase + cxTrackingDomain); ok { + t.Error("the first sequence value past the tracking domain was accepted — " + + "it would wrap onto an identifier already issued") + } + if _, ok := cxScrambledBooking(cxBookingBase + cxBookingDomain); ok { + t.Error("the first sequence value past the booking domain was accepted") + } + + // And the generator falls back to plain sequential formatting there, which + // grows a digit rather than colliding. + if got := fmt.Sprintf("DM-%06d", cxBookingBase+cxBookingDomain); len(got) != 10 { + t.Errorf("the overflow reference formats as %q; it should simply grow a digit", got) + } +} + +// A value below the sequence start is not a valid index and must be refused +// rather than producing a negative or wrapped result. +func TestBelowBaseIsRefused(t *testing.T) { + if _, ok := cxScrambledTracking(0); ok { + t.Error("seq 0 accepted for tracking") + } + if _, ok := cxScrambledBooking(cxBookingBase - 1); ok { + t.Error("a sequence value below the booking base was accepted") + } +} + +// The Feistel itself is a permutation over the full power-of-two space. This is +// the property everything else rests on, so it is checked directly rather than +// only through its callers. +func TestFeistelIsAPermutation(t *testing.T) { + const halfBits = 8 // a 16-bit space, small enough to check exhaustively + full := uint64(1) << (2 * halfBits) + + seen := make(map[uint64]uint64, full) + for x := uint64(0); x < full; x++ { + y := cxFeistelEncrypt(x, halfBits, cxScrambleKey) + if y >= full { + t.Fatalf("encrypt(%d) = %d, outside the %d-wide space", x, y, full) + } + if prev, dup := seen[y]; dup { + t.Fatalf("not a permutation: %d and %d both map to %d", prev, x, y) + } + seen[y] = x + } + if uint64(len(seen)) != full { + t.Fatalf("covered %d of %d values", len(seen), full) + } +} + +// A different key must produce a different permutation — otherwise the key is +// not actually keying anything. +func TestKeyChangesThePermutation(t *testing.T) { + const halfBits = 8 + differences := 0 + for x := uint64(0); x < 256; x++ { + if cxFeistelEncrypt(x, halfBits, []byte("key-one")) != + cxFeistelEncrypt(x, halfBits, []byte("key-two")) { + differences++ + } + } + if differences < 250 { + t.Errorf("only %d of 256 values differed between keys — the key has "+ + "little effect on the mapping", differences) + } +} + +// Every surface — customer app, miler app, admin console, hub console — reads +// the SAME column, so format consistency is structural: one generator, one +// stored value. These assert the generators themselves produce the documented +// shape, including on the fallback path that runs when the sequence cannot be +// read (no database in a test, which is exactly what exercises it here). +func TestGeneratorsProduceTheDocumentedFormat(t *testing.T) { + for i := 0; i < 50; i++ { + booking := generateBookingNo() + if len(booking) < 9 || booking[:3] != "DM-" { + t.Fatalf("generateBookingNo() = %q, want DM- followed by at least six digits", booking) + } + for _, r := range booking[3:] { + if r < '0' || r > '9' { + t.Fatalf("generateBookingNo() = %q — the part after DM- must be digits only", booking) + } + } + + tracking := generateTrackingNo() + if len(tracking) < 11 || tracking[:3] != "DMX" { + t.Fatalf("generateTrackingNo() = %q, want DMX followed by at least eight digits", tracking) + } + for _, r := range tracking[3:] { + if r < '0' || r > '9' { + t.Fatalf("generateTrackingNo() = %q — the part after DMX must be digits only", tracking) + } + } + // A tracking number must never be mistakeable for a booking reference: + // the assistant and the consoles tell them apart by prefix alone. + if tracking[:3] == "DM-" { + t.Fatalf("tracking number %q collides with the booking reference prefix", tracking) + } + } +} + +// The fallback path must never emit a short number that formats with leading +// zeros — DM-000042 reads as a broken reference, and a padded id is a support +// call. +func TestFallbackNumberNeverGoesShort(t *testing.T) { + for i := 0; i < 200; i++ { + if n := fallbackNumber(6); n < 100_000 || n > 999_999 { + t.Fatalf("fallbackNumber(6) = %d, outside the six-digit range", n) + } + if n := fallbackNumber(8); n < 10_000_000 || n > 99_999_999 { + t.Fatalf("fallbackNumber(8) = %d, outside the eight-digit range", n) + } + } +} diff --git a/controllers/cxIdentifiers.go b/controllers/cxIdentifiers.go new file mode 100644 index 0000000..0507311 --- /dev/null +++ b/controllers/cxIdentifiers.go @@ -0,0 +1,95 @@ +package controllers + +import ( + "fmt" + "math/rand" + "time" + + "doormile/db" + "doormile/utils" +) + +// Human-facing identifiers. +// +// A pickup booking is DM-######; an order is DMX########. Both columns are +// UNIQUE, and both used to be minted from four random bytes plus a truncated +// unix second. Random short ids collide long before the id space runs out, and +// a collision here is not a retry — it is a failed booking at the moment the +// customer taps Confirm. So both come off a Postgres sequence, which is the +// only generator in this system that can promise uniqueness. +// +// The sequences have no MAXVALUE and no CYCLE (migrations/migrate.go): past +// 999999 the reference simply grows a digit rather than wrapping onto an id +// that already exists. Rows written before this change keep their old +// DM-BK-/DM-TRK- strings; nothing anywhere parses either format, so the two +// coexist and no backfill is needed. + +// nextSequenceValue draws the next value from a Postgres sequence. +func nextSequenceValue(sequence string) (int64, bool) { + if db.DB == nil { + return 0, false + } + var n int64 + if err := db.DB.Raw(fmt.Sprintf("SELECT nextval('%s')", sequence)).Scan(&n).Error; err != nil { + utils.Warn("identifier sequence unavailable, falling back", "sequence", sequence, "error", err) + return 0, false + } + return n, true +} + +// fallbackNumber is used only when the sequence cannot be read — a database +// that is unreachable, or a deployment where the migration has not run yet. It +// keeps the shape of the identifier (so the client and the console never see a +// second format) and takes its entropy from the clock plus a random tail, +// which makes a collision vanishingly unlikely for the short window this path +// is ever live. It is a degradation, not a design: the sequence is the +// guarantee. +func fallbackNumber(digits int) int64 { + span := int64(1) + for i := 0; i < digits; i++ { + span *= 10 + } + base := time.Now().UnixNano() % span + jitter := rand.Int63n(1000) + n := (base + jitter) % span + // Never return a value that would render with fewer digits than the format + // promises — DM-000042 reads as a broken reference, not a short one. + if n < span/10 { + n += span / 10 + } + return n +} + +// generateBookingNo mints a pickup booking reference: DM-482913. +// +// The sequence guarantees uniqueness; cxScrambledBooking scatters it so +// consecutive bookings do not get adjacent references. See +// cxIdentifierScramble.go for why the scattering is a keyed permutation rather +// than arithmetic. +// +// Past 900,000 bookings the fixed-width range is exhausted and the reference +// grows a digit instead of wrapping onto one already issued. Uniqueness is +// never traded for appearance. +func generateBookingNo() string { + if n, ok := nextSequenceValue("cx_booking_reference_seq"); ok { + if scrambled, inRange := cxScrambledBooking(n); inRange { + return fmt.Sprintf("DM-%06d", scrambled) + } + return fmt.Sprintf("DM-%06d", n) + } + return fmt.Sprintf("DM-%06d", fallbackNumber(6)) +} + +// generateTrackingNo mints an order's tracking number: DMX10482913. One per +// destination, minted when the miler completes the pickup — never at booking +// time, because until the parcels are actually collected there is no order to +// track. +func generateTrackingNo() string { + if n, ok := nextSequenceValue("cx_tracking_seq"); ok { + if scrambled, inRange := cxScrambledTracking(n); inRange { + return fmt.Sprintf("DMX%08d", scrambled) + } + return fmt.Sprintf("DMX%08d", n) + } + return fmt.Sprintf("DMX%08d", fallbackNumber(8)) +} diff --git a/controllers/cxOpsController.go b/controllers/cxOpsController.go new file mode 100644 index 0000000..5ce04d0 --- /dev/null +++ b/controllers/cxOpsController.go @@ -0,0 +1,160 @@ +package controllers + +import ( + "os" + "strings" + + "doormile/constants" + "doormile/db" + "doormile/internal/cxstage" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" +) + +// QA support — §11 of the deliverables. +// +// "A way to force a booking to any stage on staging. Every tracking state must +// be reachable for QA — this is what lets us delete the debug stepper." +// +// The customer app currently walks its tracking screen through a hardcoded +// stepper because no real backend could produce those states on demand. +// Reaching out_for_delivery honestly needs a rider to accept, drive, weigh a +// parcel, hand it to a hub and start a delivery run — which is not something +// design QA can do before every screenshot. +// +// This endpoint is hard-gated. It refuses outright when ENV is production, and +// it additionally requires CX_ALLOW_STAGE_OVERRIDE to be set: two independent +// switches, because one of them being wrong on a production deploy would hand +// anyone with a customer token the ability to mark their own parcel delivered. + +// cxStageOverrideEnabled reports whether the QA stage override may run at all. +func cxStageOverrideEnabled() bool { + if strings.EqualFold(strings.TrimSpace(os.Getenv("ENV")), "production") { + return false + } + return strings.EqualFold(strings.TrimSpace(os.Getenv("CX_ALLOW_STAGE_OVERRIDE")), "true") +} + +// ForceCxStage drives a booking to an arbitrary stage on staging. +// +// It writes through the same cxstage recorder every real transition uses, so +// what QA sees is the real projection over real event rows — not a special +// rendering path that could pass while the production one is broken. +func ForceCxStage(c *fiber.Ctx) error { + if !cxStageOverrideEnabled() { + return utils.CxNotFound(c, "Not available") + } + + customerID := c.Locals("userid").(int) + reference := strings.TrimSpace(c.Params("reference")) + + var req struct { + Stage string `json:"stage"` + // Reason is recorded on the audit row so a forced transition is + // distinguishable from a real one forever after. A staging database + // that gets promoted, or an export read months later, must not present + // invented history as observed history. + Reason string `json:"reason"` + } + if err := c.BodyParser(&req); err != nil { + return utils.CxBadRequest(c, "We could not read that request") + } + + stage := strings.TrimSpace(req.Stage) + if constants.CxStageRank(stage) < 0 { + return utils.CxBadRequest(c, "Unknown stage") + } + + booking, err := cxLoadBooking(customerID, reference) + if err != nil { + return utils.CxNotFound(c, "We could not find that pickup") + } + + var destinations []models.BookingDestination + if err := db.DB.Where("bookingid = ?", booking.Bookingid). + Order("seq ASC").Find(&destinations).Error; err != nil { + utils.Error("ForceCxStage: destination query failed", "booking_id", booking.Bookingid, "error", err) + return utils.CxInternal(c) + } + + reason := strings.TrimSpace(req.Reason) + if reason == "" { + reason = "forced on staging for QA" + } + + tx := db.DB.Begin() + + // Walk every stage up to the target rather than jumping. A timeline with a + // hole in it is not a state the production flow can produce, so testing + // against one proves nothing about the screen that renders it. + for _, s := range []string{ + constants.CxStageBooked, constants.CxStageAssigned, constants.CxStageOnTheWay, + constants.CxStageArrived, constants.CxStagePickedUp, constants.CxStageOrderCreated, + constants.CxStageInTransit, constants.CxStageOutForDelivery, constants.CxStageDelivered, + } { + if constants.CxStageRank(s) > constants.CxStageRank(stage) { + break + } + + perOrder := constants.CxStageRank(s) >= constants.CxStageOrder[constants.CxStageInTransit] + if perOrder && len(destinations) > 0 { + for i := range destinations { + id := destinations[i].Bookingdestinationid + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: booking.Bookingid, + DestinationID: &id, + Stage: s, + ActorType: constants.CxActorOps, + ActorID: &customerID, + Source: "POST /customer/ops/bookings/{reference}/stage", + Remarks: reason, + }); err != nil { + tx.Rollback() + utils.Error("ForceCxStage: record failed", "booking_id", booking.Bookingid, "stage", s, "error", err) + return utils.CxInternal(c) + } + } + continue + } + + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: booking.Bookingid, + Stage: s, + ActorType: constants.CxActorOps, + ActorID: &customerID, + Source: "POST /customer/ops/bookings/{reference}/stage", + Remarks: reason, + }); err != nil { + tx.Rollback() + utils.Error("ForceCxStage: record failed", "booking_id", booking.Bookingid, "stage", s, "error", err) + return utils.CxInternal(c) + } + } + + // Tracking numbers exist from order_created onward, so a forced booking + // past that point needs them or the orders render with a null trackingId + // and the deep-link path cannot be tested at all. + if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStageOrderCreated] { + for i := range destinations { + if destinations[i].Trackingno != "" { + continue + } + if err := tx.Model(&models.BookingDestination{}). + Where("bookingdestinationid = ?", destinations[i].Bookingdestinationid). + Update("trackingno", generateTrackingNo()).Error; err != nil { + tx.Rollback() + utils.Error("ForceCxStage: could not mint tracking number", "booking_id", booking.Bookingid, "error", err) + return utils.CxInternal(c) + } + } + } + + if err := tx.Commit().Error; err != nil { + utils.Error("ForceCxStage: commit failed", "booking_id", booking.Bookingid, "error", err) + return utils.CxInternal(c) + } + + return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusOK) +} diff --git a/controllers/cxPickupFanout.go b/controllers/cxPickupFanout.go new file mode 100644 index 0000000..465e2a4 --- /dev/null +++ b/controllers/cxPickupFanout.go @@ -0,0 +1,251 @@ +package controllers + +import ( + "math" + "time" + + "doormile/constants" + "doormile/db" + "doormile/models" + "doormile/utils" + + "gorm.io/gorm" +) + +// Pickup fan-out: one visit becomes N orders. +// +// This is the change §1 of the customer contract turns on. A pickup booking +// used to convert into exactly one consignment, because a booking carried +// exactly one delivery address in its own columns. A customer-app booking +// carries 1..N destinations, and each of those has to become its own +// consignment with its own tracking number and its own journey — three parcels +// collected in one visit for Chennai, Kochi and Bengaluru travel three separate +// routes the moment they leave the door. +// +// A booking with no destination rows — every console-created express booking, +// and every row written before this table existed — produces exactly one leg +// built from the flat delivery columns, which is byte-for-byte the behaviour +// that was there before. Single-destination is not a special case in either +// direction: it is one leg, through the same code. + +// cxPickupLeg is one journey to create at pickup completion. +type cxPickupLeg struct { + // Destination is nil for a booking that has no destination rows. + Destination *models.BookingDestination + + DeliveryLatitude float64 + DeliveryLongitude float64 + DeliveryPincode string + CodAmount float64 + + // Parcels are the packages going to this destination, already weighed by + // the miler. + Parcels []models.BookingParcel +} + +// cxPickupLegs splits a booking into the journeys its parcels are about to +// take. +func cxPickupLegs(tx *gorm.DB, booking *models.PickupBooking) ([]cxPickupLeg, error) { + var destinations []models.BookingDestination + if err := tx.Where("bookingid = ?", booking.Bookingid). + Order("seq ASC").Find(&destinations).Error; err != nil { + return nil, err + } + + var parcels []models.BookingParcel + if err := tx.Where("bookingid = ?", booking.Bookingid).Find(&parcels).Error; err != nil { + return nil, err + } + + if len(destinations) == 0 { + // The pre-existing shape: one consignment from the booking's own + // delivery columns, carrying every parcel on the booking. + return []cxPickupLeg{{ + DeliveryLatitude: booking.Deliverylatitude, + DeliveryLongitude: booking.Deliverylongitude, + DeliveryPincode: booking.Deliverypincode, + Parcels: parcels, + }}, nil + } + + byDestination := map[int][]models.BookingParcel{} + unassigned := []models.BookingParcel{} + for _, p := range parcels { + if p.Bookingdestinationid == nil { + unassigned = append(unassigned, p) + continue + } + byDestination[*p.Bookingdestinationid] = append(byDestination[*p.Bookingdestinationid], p) + } + + legs := make([]cxPickupLeg, 0, len(destinations)) + for i := range destinations { + d := destinations[i] + + lat, lng := cxDestinationPoint(&d) + leg := cxPickupLeg{ + Destination: &destinations[i], + DeliveryLatitude: lat, + DeliveryLongitude: lng, + DeliveryPincode: d.Pincode, + CodAmount: d.Codamount, + Parcels: byDestination[d.Bookingdestinationid], + } + // Parcels the miler added at the door without naming a destination go + // with the first one. Attributing them to nothing would drop them out + // of every weight total, and the first destination is the one the rider + // was shown. + if i == 0 { + leg.Parcels = append(leg.Parcels, unassigned...) + } + legs = append(legs, leg) + } + return legs, nil +} + +// cxDestinationPoint gives a destination the best coordinates it has: the +// customer's own map pin if they dropped one, otherwise the district centre. +// Only state and district are required at booking time, so the centre is +// frequently all there is — and routing skips stops sitting at 0,0. +func cxDestinationPoint(d *models.BookingDestination) (lat, lng float64) { + if d.Pinlatitude != nil && d.Pinlongitude != nil && + (*d.Pinlatitude != 0 || *d.Pinlongitude != 0) { + return *d.Pinlatitude, *d.Pinlongitude + } + var district models.ServiceableDistrict + if err := db.DB.Select("centrelatitude, centrelongitude"). + Where("districtcode = ?", d.Districtcode).First(&district).Error; err == nil { + return district.Centrelatitude, district.Centrelongitude + } + return 0, 0 +} + +// cxLegWeights totals a leg's packages the way the consignment records them. +// A leg whose packages were never weighed falls back to the same 0.5 kg +// placeholder the single-consignment path already used, so an unweighed pickup +// still produces a chargeable consignment rather than a zero-weight one. +func cxLegWeights(leg cxPickupLeg) (deadWeight, chargeable, maxL, maxW, maxH float64) { + for _, p := range leg.Parcels { + vol := calculateVolumetricWeight(p.Length, p.Width, p.Height) + deadWeight += p.Weight + chargeable += math.Max(p.Weight, vol) + if p.Length > maxL { + maxL = p.Length + } + if p.Width > maxW { + maxW = p.Width + } + if p.Height > maxH { + maxH = p.Height + } + } + if len(leg.Parcels) == 0 || chargeable == 0 { + deadWeight, chargeable = 0.5, 0.5 + } + return +} + +// cxLinkLegToOrder records that a destination has become an order: its tracking +// number, its consignment, and the delivery date the customer is promised. +func cxLinkLegToOrder(tx *gorm.DB, leg cxPickupLeg, consignmentID int, trackingNo string, expected *time.Time, at time.Time) error { + if leg.Destination == nil { + return nil + } + updates := map[string]interface{}{ + "consignmentid": consignmentID, + "trackingno": trackingNo, + "stage": constants.CxStageOrderCreated, + "updatedat": at, + } + if expected != nil { + updates["expecteddeliveryat"] = *expected + } + return tx.Model(&models.BookingDestination{}). + Where("bookingdestinationid = ?", leg.Destination.Bookingdestinationid). + Updates(updates).Error +} + +// cxRecordVerification stores what the miler measured at the door: the weight +// the price settles on, when, and who took the reading. It is the evidence half +// of the receipt, and it is per destination because each order is weighed +// separately. +func cxRecordVerification(tx *gorm.DB, destinationID int, weightKg float64, byUserID int, at time.Time) error { + return tx.Model(&models.BookingDestination{}). + Where("bookingdestinationid = ?", destinationID). + Updates(map[string]interface{}{ + "verifiedweightkg": weightKg, + "verifiedat": at, + "verifiedbyuserid": byUserID, + "updatedat": at, + }).Error +} + +// cxDestinationForConsignment finds which order a consignment belongs to. +// +// The delivery handlers used to reach the booking with +// `WHERE consignmentid = ?` on pickupbookings, which held exactly one +// consignment id. With a fan-out that column only names the FIRST order, so +// that lookup silently found nothing for destinations 2..N — no customer +// notification, no stage advance, on every multi-destination booking. The join +// goes through bookingdestinations now, which is the table that actually knows. +func cxDestinationForConsignment(consignmentID int) (*models.BookingDestination, *models.PickupBooking, bool) { + var dest models.BookingDestination + if err := db.DB.Where("consignmentid = ?", consignmentID).First(&dest).Error; err == nil { + var booking models.PickupBooking + if err := db.DB.First(&booking, dest.Bookingid).Error; err == nil { + return &dest, &booking, true + } + return &dest, nil, false + } + + // No destination row: a console-created booking, or one written before the + // fan-out existed. The legacy link still answers for those. + var booking models.PickupBooking + if err := db.DB.Where("consignmentid = ?", consignmentID).First(&booking).Error; err != nil { + return nil, nil, false + } + return nil, &booking, true +} + +// cxDestinationIDFor returns the destination id to attribute a per-order stage +// to, or nil when the booking has no destination rows. +func cxDestinationIDFor(dest *models.BookingDestination) *int { + if dest == nil { + return nil + } + id := dest.Bookingdestinationid + return &id +} + +// cxEstimatedDelivery is the promise shown to the customer, derived from the +// serving district's own promise text rather than a single platform-wide SLA — +// "Next-day delivery" into Chennai and "2-day delivery" into a district two +// states away are different promises and must not resolve to the same date. +func cxEstimatedDelivery(leg cxPickupLeg, from time.Time) *time.Time { + days := 2 + if leg.Destination != nil { + var district models.ServiceableDistrict + if err := db.DB.Select("promise"). + Where("districtcode = ?", leg.Destination.Districtcode). + First(&district).Error; err == nil { + switch district.Promise { + case "Same-day delivery": + days = 0 + case "Next-day delivery": + days = 1 + case "2-day delivery": + days = 2 + case "3-day delivery": + days = 3 + } + } + } + // Delivered by end of the promised day, expressed in the wall clock this + // database stores. + target := from.AddDate(0, 0, days) + expected := time.Date(target.Year(), target.Month(), target.Day(), 20, 0, 0, 0, time.UTC) + if utils.EpochMillis(expected) < utils.EpochMillis(from) { + expected = from + } + return &expected +} diff --git a/controllers/cxPlacesController.go b/controllers/cxPlacesController.go new file mode 100644 index 0000000..7ff9599 --- /dev/null +++ b/controllers/cxPlacesController.go @@ -0,0 +1,350 @@ +package controllers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "doormile/config" + "doormile/db" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" +) + +// Places — §6 of the contract. +// +// Both endpoints proxy a geocoder rather than handing the app a key. The legacy +// rider app shipped a Google Maps key inside the binary; it was extracted and +// had to be revoked, and that side now runs on OSM/OSRM with no key at all. The +// customer app is never given one: it asks Doormile, Doormile asks the +// geocoder, and the answer is cached so the same street typed a hundred times +// costs one upstream call. + +const ( + // cxGeocodeTimeout is short on purpose. The pickup point already has a + // device-supplied coordinate; a slow geocoder should degrade the label, not + // stall the booking form. + cxGeocodeTimeout = 4 * time.Second + // cxGeocodeCacheTTL — addresses do not move. A week is conservative. + cxGeocodeCacheTTL = 7 * 24 * time.Hour + cxSearchCacheTTL = 24 * time.Hour + cxMaxSearchResults = 6 + // cxRecentPlacesShown is what the search sheet opens on: the customer's own + // recent and saved places, capped by the design. + cxRecentPlacesShown = 4 +) + +// cxPlace is the one shape a place is returned in, from search, from reverse +// geocode and from the saved-address list. title is a short label the UI puts +// on one line; sub is the full address under it. Neither is ever null — the +// client types both as non-nullable strings. +type cxPlace struct { + Title string `json:"title"` + Sub string `json:"sub"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` +} + +// ReverseGeocodeCx turns the device's coordinates into the pickup label. +func ReverseGeocodeCx(cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + lat, latErr := strconv.ParseFloat(c.Query("lat"), 64) + lng, lngErr := strconv.ParseFloat(c.Query("lng"), 64) + if latErr != nil || lngErr != nil || (lat == 0 && lng == 0) { + return utils.CxBadRequest(c, "We need a location to look up") + } + + cacheKey := fmt.Sprintf("cx:geo:rev:%.5f:%.5f", lat, lng) + if cached, ok := cxCacheGet(cacheKey); ok { + var place cxPlace + if json.Unmarshal([]byte(cached), &place) == nil { + return utils.CxOK(c, place) + } + } + + place, err := cxReverseGeocode(cfg, lat, lng) + if err != nil { + utils.Warn("ReverseGeocodeCx: upstream failed", "error", err) + // A label the customer can correct beats a blocked booking form. + // The coordinates are what the rider actually navigates to; the + // text is what the customer reads, and they can edit it. + return utils.CxOK(c, cxPlace{ + Title: "Selected location", + Sub: fmt.Sprintf("%.5f, %.5f", lat, lng), + Lat: lat, + Lng: lng, + }) + } + + if data, merr := json.Marshal(place); merr == nil { + cxCacheSet(cacheKey, string(data), cxGeocodeCacheTTL) + } + return utils.CxOK(c, place) + } +} + +// SearchCxPlaces backs the pickup-point search sheet. +// +// An empty query is not an error: the sheet opens on it, and answers with the +// customer's own saved and recently used places. +func SearchCxPlaces(cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + q := strings.TrimSpace(c.Query("q")) + lat, _ := strconv.ParseFloat(c.Query("lat"), 64) + lng, _ := strconv.ParseFloat(c.Query("lng"), 64) + + if q == "" { + customerID, _ := c.Locals("userid").(int) + places := cxRecentPlaces(customerID) + return utils.CxList(c, places, len(places), nil) + } + + cacheKey := fmt.Sprintf("cx:geo:q:%s:%.2f:%.2f", strings.ToLower(q), lat, lng) + if cached, ok := cxCacheGet(cacheKey); ok { + var places []cxPlace + if json.Unmarshal([]byte(cached), &places) == nil { + return utils.CxList(c, places, len(places), nil) + } + } + + places, err := cxSearchPlaces(cfg, q, lat, lng) + if err != nil { + utils.Warn("SearchCxPlaces: upstream failed", "error", err) + // An empty list is a designed state in the sheet ("no matches"); + // an error is a retry button in the middle of a booking. + return utils.CxList(c, []cxPlace{}, 0, nil) + } + + if data, merr := json.Marshal(places); merr == nil { + cxCacheSet(cacheKey, string(data), cxSearchCacheTTL) + } + return utils.CxList(c, places, len(places), nil) + } +} + +// ── Upstream ───────────────────────────────────────────────────────────────── + +type nominatimPlace struct { + DisplayName string `json:"display_name"` + Lat string `json:"lat"` + Lon string `json:"lon"` + Name string `json:"name"` + Address struct { + Road string `json:"road"` + HouseNumber string `json:"house_number"` + Neighbourhood string `json:"neighbourhood"` + Suburb string `json:"suburb"` + City string `json:"city"` + Town string `json:"town"` + Village string `json:"village"` + StateDistrict string `json:"state_district"` + State string `json:"state"` + Postcode string `json:"postcode"` + } `json:"address"` +} + +func cxReverseGeocode(cfg *config.Config, lat, lng float64) (cxPlace, error) { + endpoint := fmt.Sprintf("%s/reverse?format=jsonv2&lat=%f&lon=%f&addressdetails=1&zoom=18", + strings.TrimRight(cfg.GeocoderURL, "/"), lat, lng) + + var place nominatimPlace + if err := cxGeocoderGet(cfg, endpoint, &place); err != nil { + return cxPlace{}, err + } + return cxPlaceFrom(place, lat, lng), nil +} + +func cxSearchPlaces(cfg *config.Config, q string, lat, lng float64) ([]cxPlace, error) { + endpoint := fmt.Sprintf("%s/search?format=jsonv2&addressdetails=1&limit=%d&countrycodes=in&q=%s", + strings.TrimRight(cfg.GeocoderURL, "/"), cxMaxSearchResults, url.QueryEscape(q)) + + // Bias to where the customer is. A "Brookefields" typed in Coimbatore must + // not answer with one in another state first. + if lat != 0 || lng != 0 { + const box = 0.75 // degrees, roughly 80km + endpoint += fmt.Sprintf("&viewbox=%f,%f,%f,%f&bounded=0", + lng-box, lat+box, lng+box, lat-box) + } + + var raw []nominatimPlace + if err := cxGeocoderGet(cfg, endpoint, &raw); err != nil { + return nil, err + } + + places := make([]cxPlace, 0, len(raw)) + for _, r := range raw { + plat, _ := strconv.ParseFloat(r.Lat, 64) + plng, _ := strconv.ParseFloat(r.Lon, 64) + places = append(places, cxPlaceFrom(r, plat, plng)) + } + return places, nil +} + +func cxGeocoderGet(cfg *config.Config, endpoint string, out interface{}) error { + ctx, cancel := context.WithTimeout(context.Background(), cxGeocodeTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + // Nominatim's usage policy requires an identifiable caller; anonymous + // traffic gets throttled or blocked outright. + agent := "doormile-backend/1.0" + if cfg.GeocoderEmail != "" { + agent += " (" + cfg.GeocoderEmail + ")" + } + req.Header.Set("User-Agent", agent) + req.Header.Set("Accept-Language", "en") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("geocoder returned %d", resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// cxPlaceFrom builds the two-line label. title is capped at the 32 characters +// the design allots it, and falls back through the parts of the address most +// likely to be recognisable at a glance. +func cxPlaceFrom(p nominatimPlace, lat, lng float64) cxPlace { + title := strings.TrimSpace(p.Name) + if title == "" { + title = joinNonEmpty(" ", p.Address.HouseNumber, p.Address.Road) + } + if title == "" { + title = firstNonEmpty(p.Address.Neighbourhood, p.Address.Suburb, + p.Address.City, p.Address.Town, p.Address.Village) + } + if title == "" { + // Last resort: the leading segment of the display name. + if i := strings.Index(p.DisplayName, ","); i > 0 { + title = strings.TrimSpace(p.DisplayName[:i]) + } else { + title = "Selected location" + } + } + if r := []rune(title); len(r) > 32 { + title = strings.TrimSpace(string(r[:32])) + } + + sub := joinNonEmpty(", ", + firstNonEmpty(p.Address.Neighbourhood, p.Address.Suburb), + firstNonEmpty(p.Address.City, p.Address.Town, p.Address.Village, p.Address.StateDistrict), + p.Address.Postcode) + if sub == "" { + sub = strings.TrimSpace(p.DisplayName) + } + if sub == "" { + sub = fmt.Sprintf("%.5f, %.5f", lat, lng) + } + + return cxPlace{Title: title, Sub: sub, Lat: lat, Lng: lng} +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if s := strings.TrimSpace(v); s != "" { + return s + } + } + return "" +} + +// cxRecentPlaces answers an empty search with the places this customer already +// uses — their saved addresses first, then the pickup points of their recent +// bookings. Booking again from the same doorstep is the common case, and making +// them re-type it is the friction this removes. +func cxRecentPlaces(customerID int) []cxPlace { + places := make([]cxPlace, 0, cxRecentPlacesShown) + seen := map[string]bool{} + + add := func(p cxPlace) { + if len(places) >= cxRecentPlacesShown { + return + } + key := fmt.Sprintf("%.4f:%.4f", p.Lat, p.Lng) + if seen[key] || (p.Lat == 0 && p.Lng == 0) { + return + } + seen[key] = true + places = append(places, p) + } + + if customerID != 0 { + var saved []models.AppCustomerLocation + if err := db.DB.Where("appcustomerid = ? AND status = ?", customerID, "Active"). + Order("isdefault DESC, appcustomerlocationid DESC"). + Limit(cxRecentPlacesShown).Find(&saved).Error; err == nil { + for _, l := range saved { + title := l.Label + if title == "" { + title = l.Address + } + add(cxPlace{ + Title: title, + Sub: joinNonEmpty(", ", l.Address, l.City, l.Pincode), + Lat: l.Latitude, + Lng: l.Longitude, + }) + } + } + + var recent []models.PickupBooking + if err := db.DB.Select("pickuptitle, pickupsub, pickupaddress, pickuppincode, pickuplatitude, pickuplongitude"). + Where("appcustomerid = ?", customerID). + Order("bookingid DESC").Limit(10).Find(&recent).Error; err == nil { + for i := range recent { + b := recent[i] + add(cxPlace{ + Title: cxPickupTitle(&b), + Sub: cxPickupSub(&b), + Lat: b.Pickuplatitude, + Lng: b.Pickuplongitude, + }) + } + } + } + + return places +} + +// ── Cache ──────────────────────────────────────────────────────────────────── + +// cxCacheGet / cxCacheSet are best-effort. A geocoder answer is a convenience, +// never a correctness requirement, so a Redis outage costs latency and upstream +// quota rather than the feature. +func cxCacheGet(key string) (string, bool) { + if db.Rdb == nil { + return "", false + } + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + value, err := db.Rdb.Get(ctx, key).Result() + if err != nil { + return "", false + } + return value, true +} + +func cxCacheSet(key, value string, ttl time.Duration) { + if db.Rdb == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + if err := db.Rdb.Set(ctx, key, value, ttl).Err(); err != nil { + utils.Warn("cxCacheSet: failed", "key", key, "error", err) + } +} diff --git a/controllers/logisticsHandoverController.go b/controllers/logisticsHandoverController.go index d16bf31..3344749 100644 --- a/controllers/logisticsHandoverController.go +++ b/controllers/logisticsHandoverController.go @@ -443,8 +443,13 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error { // the base gate — and ridercharges the order amount, both written the same way // MilerDeliverConsignment writes them for a final-mile leg. Without this an // intercity rider's every job reported zero distance and zero value. - var booking models.PickupBooking - if tx.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil { + // Resolved through bookingdestinations, not through + // pickupbookings.consignmentid. That column names only the FIRST order of a + // multi-destination pickup, so joining on it found nothing for orders 2..N + // — and an intercity rider handing in the second parcel of a three-stop + // pickup had their assignment left open and their distance recorded as zero. + if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil { + booking := *bookingPtr dropLat, dropLon := lat, lon if dropLat == 0 && dropLon == 0 { dropLat, dropLon = hub.Latitude, hub.Longitude @@ -479,10 +484,25 @@ func MilerInwardConsignmentAtHub(c *fiber.Ctx) error { return utils.Internal(c, "failed to update miler availability") } + // The customer's "In transit" milestone. Recorded against THIS order, not + // the booking, because the other parcels from the same visit may still be + // in the rider's hands. + notifyInTransit, err := recordCxConsignmentStage(tx, consignment.Consignmentid, + constants.ConsignmentInwardedAtHub, constants.CxActorMiler, &milerUserID, + "POST /miler/consignments/{id}/inward-at-hub") + if err != nil { + tx.Rollback() + utils.Error("MilerInwardConsignmentAtHub: could not record in_transit", + "consignment_id", consignment.Consignmentid, "error", err) + return utils.Internal(c, "failed to record the handover") + } + if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to record the handover") } + notifyInTransit() + // Best-effort, on an already-bound subject — a dropped event must never fail // a handover the rider has physically completed. if db.Js != nil { diff --git a/controllers/milerAppController.go b/controllers/milerAppController.go index d3670a8..94a4cb0 100644 --- a/controllers/milerAppController.go +++ b/controllers/milerAppController.go @@ -254,6 +254,28 @@ func MilerGetMyBookings(c *fiber.Ctx) error { } } + // A customer-app pickup fans out into one consignment per destination, and + // each of those is a SEPARATE delivery the rider has to make. Without this + // the queue showed one row per booking keyed on pickupbookings.consignmentid + // — which names only the first order — so on a three-destination pickup two + // parcels would exist in the rider's bag with no stop, no deliver button and + // no way to close them. + // + // Bookings with no destination rows (every console/express booking, and + // everything written before the fan-out) are untouched: they still produce + // exactly one row, built from the booking's own columns. + destinationsByBooking := map[int][]models.BookingDestination{} + if len(bookingIDs) > 0 { + var destinations []models.BookingDestination + db.DB.Where("bookingid IN ?", bookingIDs).Order("seq ASC").Find(&destinations) + for _, d := range destinations { + destinationsByBooking[d.Bookingid] = append(destinationsByBooking[d.Bookingid], d) + if d.Consignmentid != nil { + consignmentIDs = append(consignmentIDs, *d.Consignmentid) + } + } + } + // step / road-optimized sequence lives on the active assignment row, written // by the express route optimizer. Step 0 = not sequenced (single-stop or // optimizer down), never a position — passed through verbatim. @@ -287,41 +309,6 @@ func MilerGetMyBookings(c *fiber.Ctx) error { var customer models.AppCustomer db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer) - // Cash-to-collect: prefer the consignment's COD once it exists, otherwise - // fall back to a pending Cash payment on the booking. Prepaid/UPI stays 0. - codAmount := 0.0 - paymentMode := "" - if b.Consignmentid != nil { - if cn, ok := codByConsignment[*b.Consignmentid]; ok { - paymentMode = cn.Paymentmode - codAmount = cn.Codamount - cn.Codcollected - if codAmount < 0 { - codAmount = 0 - } - } - } - if codAmount == 0 { - for _, p := range b.Payments { - if p.Paymentmode == constants.PaymentModeCash && p.Paymentstatus == constants.PaymentStatusPending { - codAmount = p.Amount - paymentMode = p.Paymentmode - break - } - if paymentMode == "" { - paymentMode = p.Paymentmode - } - } - } - - // consignmentid/consignmentstatus so the app can call deliver, skip and - // start-delivery straight from the list without a per-order lookup. - var consignmentStatus string - if b.Consignmentid != nil { - if cn, ok := codByConsignment[*b.Consignmentid]; ok { - consignmentStatus = cn.Status - } - } - // Where this parcel is collected FROM, and what kind of place that is, so // Home can title the stop correctly. Without pickup_source_type every // logistics pickup was grouped under the rider's own base name and a @@ -334,79 +321,145 @@ func MilerGetMyBookings(c *fiber.Ctx) error { sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customer.Firstname+" "+customer.Lastname) - // next_action / next_hub: the leg this parcel is on, rebuilt from server - // state on every poll. pickup-complete used to be the only place that ever - // said it, so a restart mid-leg left the app with nothing authoritative to - // read — consignment status alone cannot separate a hub-routed parcel from - // a freshly-collected hyperlocal one, since both can sit on Created. - nextAction := constants.NextActionPickup - var nextHub fiber.Map - if b.Status == constants.BookingCancelled { - nextAction = constants.NextActionNone - } else if b.Consignmentid != nil { - if cn, ok := codByConsignment[*b.Consignmentid]; ok { - nextAction = nextActionForConsignment(cn.Status) - nextHub = nextHubForConsignment(&cn) - } - } + stops := milerStopsForBooking(&b, destinationsByBooking[b.Bookingid]) - row := fiber.Map{ - "bookingid": b.Bookingid, - "bookingreference": b.Bookingno, - "status": b.Status, - "stoptype": milerStopType(b.Status), - "consignmentid": b.Consignmentid, - "consignmentstatus": consignmentStatus, - "next_action": nextAction, - "next_hub": nextHub, - "pickup_source_type": sourceType, - "sourceid": sourceID, - "pickuplocationid": sourceID, - "pickup_source_name": sourceName, - "pickupaddress": sourceAddress, - "pickuplatitude": b.Pickuplatitude, - "pickuplongitude": b.Pickuplongitude, - "deliveryaddress": b.Deliveryaddress, - "deliverylatitude": b.Deliverylatitude, - "deliverylongitude": b.Deliverylongitude, - "customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname), - "customerphone": customer.Phone, - // Arrival fact: the rider app derives its "Arrived" rung from - // pickup-scheduled + a non-null reachedat, so this survives an app - // restart without a separate booking status. Null until the rider hits - // the reached endpoint. arrivallatitude/longitude are the GPS captured - // at that moment (null if the app sent none). - "reachedat": b.Arrivedat, - "arrivallatitude": b.Arrivallatitude, - "arrivallongitude": b.Arrivallongitude, - "parcels": b.Parcels, - "serviceoptions": b.ServiceOptions, - "codamount": codAmount, - "paymentmode": paymentMode, - "createdat": b.Createdat, - // Route sequencing — 0/empty when the stop was never sequenced. - // sequencedat is the authoritative-order signal: non-null means the - // console/optimizer fixed this stop's position and the app must follow - // step exactly; null means no route was assigned and the app is free to - // fall back to its own nearest-first ordering. - "step": 0, - "cumulativekms": 0.0, - "etaminutes": 0, - "cumulativeeta": 0, - "sequencedat": nil, + for _, stop := range stops { + // Cash-to-collect: prefer the consignment's COD once it exists, otherwise + // fall back to a pending Cash payment on the booking. Prepaid/UPI stays 0. + codAmount := 0.0 + paymentMode := "" + var consignmentStatus string + + // next_action / next_hub: the leg this parcel is on, rebuilt from server + // state on every poll. pickup-complete used to be the only place that ever + // said it, so a restart mid-leg left the app with nothing authoritative to + // read — consignment status alone cannot separate a hub-routed parcel from + // a freshly-collected hyperlocal one, since both can sit on Created. + nextAction := constants.NextActionPickup + var nextHub fiber.Map + + if stop.consignmentID != nil { + if cn, ok := codByConsignment[*stop.consignmentID]; ok { + consignmentStatus = cn.Status + paymentMode = cn.Paymentmode + codAmount = cn.Codamount - cn.Codcollected + if codAmount < 0 { + codAmount = 0 + } + nextAction = nextActionForConsignment(cn.Status) + nextHub = nextHubForConsignment(&cn) + } else { + // The consignment row did not load. Fall back to what this + // destination asked for rather than to zero — a rider shown + // ₹0 collects nothing, and the customer's money is the one + // thing that must not silently vanish from a stop. + codAmount = stop.codAmount + } + } else if stop.codAmount > 0 { + // Not yet collected: the collection is what the customer + // requested for this door. + codAmount = stop.codAmount + } + if b.Status == constants.BookingCancelled { + nextAction = constants.NextActionNone + } + + // The pickup fee is charged once for the visit, so it is only offered + // against the first stop — asking a rider to collect it again at the + // second parcel would double-charge the customer. + if codAmount == 0 && stop.seq == 0 { + for _, p := range b.Payments { + if p.Paymentmode == constants.PaymentModeCash && p.Paymentstatus == constants.PaymentStatusPending { + codAmount = p.Amount + paymentMode = p.Paymentmode + break + } + if paymentMode == "" { + paymentMode = p.Paymentmode + } + } + } + + row := fiber.Map{ + "bookingid": b.Bookingid, + "bookingreference": b.Bookingno, + "status": b.Status, + "stoptype": milerStopType(b.Status), + // consignmentid/consignmentstatus so the app can call deliver, skip and + // start-delivery straight from the list without a per-order lookup. + "consignmentid": stop.consignmentID, + "consignmentstatus": consignmentStatus, + "trackingno": stop.trackingNo, + // Which of the booking's destinations this stop is, and how many + // there are in total, so the app can say "Stop 2 of 3" instead of + // showing three identical-looking rows. Always 0 and 1 for a + // single-destination or console booking. + "destinationseq": stop.seq, + "destinationcount": len(stops), + "next_action": nextAction, + "next_hub": nextHub, + + "pickup_source_type": sourceType, + "sourceid": sourceID, + "pickuplocationid": sourceID, + "pickup_source_name": sourceName, + "pickupaddress": sourceAddress, + "pickuplatitude": b.Pickuplatitude, + "pickuplongitude": b.Pickuplongitude, + + "deliveryaddress": stop.deliveryAddress, + "deliverylatitude": stop.deliveryLat, + "deliverylongitude": stop.deliveryLng, + "recipientname": stop.recipientName, + "recipientphone": stop.recipientPhone, + + "customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname), + "customerphone": customer.Phone, + // Arrival fact: the rider app derives its "Arrived" rung from + // pickup-scheduled + a non-null reachedat, so this survives an app + // restart without a separate booking status. Null until the rider hits + // the reached endpoint. arrivallatitude/longitude are the GPS captured + // at that moment (null if the app sent none). + "reachedat": b.Arrivedat, + "arrivallatitude": b.Arrivallatitude, + "arrivallongitude": b.Arrivallongitude, + "parcels": stop.parcels, + "serviceoptions": b.ServiceOptions, + "codamount": codAmount, + // collectionamt is the same number under the name the rider app + // reads. Added rather than renamed: `codamount` is what this + // endpoint has always emitted and the deployed build parses it, + // so removing it would break every stop on every existing + // device. Both are written from one variable, so they cannot + // drift apart. + "collectionamt": codAmount, + "paymentmode": paymentMode, + "createdat": b.Createdat, + // Route sequencing — 0/empty when the stop was never sequenced. + // sequencedat is the authoritative-order signal: non-null means the + // console/optimizer fixed this stop's position and the app must follow + // step exactly; null means no route was assigned and the app is free to + // fall back to its own nearest-first ordering. + "step": 0, + "cumulativekms": 0.0, + "etaminutes": 0, + "cumulativeeta": 0, + "sequencedat": nil, + } + if a, ok := seqByBooking[b.Bookingid]; ok { + row["step"] = a.Step + row["cumulativekms"] = a.Cumulativekms + row["etaminutes"] = a.Etaminutes + row["cumulativeeta"] = a.Cumulativeeta + row["sequencedat"] = a.Sequencedat + } + response = append(response, row) } - if a, ok := seqByBooking[b.Bookingid]; ok { - row["step"] = a.Step - row["cumulativekms"] = a.Cumulativekms - row["etaminutes"] = a.Etaminutes - row["cumulativeeta"] = a.Cumulativeeta - row["sequencedat"] = a.Sequencedat - } - response = append(response, row) } // Sequenced stops ascend by step; unsequenced (step 0) fall to the end while - // keeping the newest-first order the app already relied on. + // keeping the newest-first order the app already relied on. Stops from one + // booking share its step, so they stay adjacent and in destination order. sort.SliceStable(response, func(i, j int) bool { si, sj := response[i]["step"].(int), response[j]["step"].(int) switch { @@ -424,6 +477,121 @@ func MilerGetMyBookings(c *fiber.Ctx) error { return utils.List(c, response, int64(len(response))) } +// milerStop is one thing the rider actually has to do with one parcel — a +// pickup before collection, a delivery leg after it. +type milerStop struct { + seq int + consignmentID *int + trackingNo string + deliveryAddress string + deliveryLat float64 + deliveryLng float64 + recipientName string + recipientPhone string + // codAmount is the collection the CUSTOMER asked for at THIS door, read off + // the destination row. The consignment's own figure wins at delivery time + // because it also knows what has already been collected — but this is what + // makes the per-stop split provable without a database, and it is the value + // used if the consignment row could not be loaded. Money must never fall + // back to another destination's number. + codAmount float64 + parcels []models.BookingParcel +} + +// milerStopsForBooking decides how many stops a booking is worth to the rider. +// +// Before the parcels are collected it is always ONE stop: the rider makes a +// single visit to the customer's door, whatever it is carrying away. After +// collection a customer-app pickup becomes one stop per destination, because +// each parcel now has its own journey, its own tracking number and its own +// deliver/skip action. +// +// A booking with no destination rows — every console/express booking, and +// everything written before the fan-out — is always one stop built from the +// booking's own columns, exactly as before. +func milerStopsForBooking(b *models.PickupBooking, destinations []models.BookingDestination) []milerStop { + single := []milerStop{{ + seq: 0, + consignmentID: b.Consignmentid, + deliveryAddress: b.Deliveryaddress, + deliveryLat: b.Deliverylatitude, + deliveryLng: b.Deliverylongitude, + parcels: b.Parcels, + }} + // A booking with exactly one destination row still carries its COD there. + if len(destinations) == 1 { + single[0].codAmount = destinations[0].Codamount + } + + if len(destinations) == 0 { + return single + } + + // Not collected yet: one visit, one stop. The destinations are still only an + // intention, and showing three rows for a pickup that has not happened would + // have the rider drive to the same door three times. + collected := false + for _, d := range destinations { + if d.Consignmentid != nil { + collected = true + break + } + } + if !collected { + // Destination 0's address is already mirrored onto the booking, so the + // single pre-pickup stop is correct as built above. + return single + } + + parcelsByDestination := map[int][]models.BookingParcel{} + for _, p := range b.Parcels { + if p.Bookingdestinationid != nil { + parcelsByDestination[*p.Bookingdestinationid] = append(parcelsByDestination[*p.Bookingdestinationid], p) + } + } + + // Stop order is `seq`, guaranteed here rather than inherited from whatever + // ORDER BY the caller happened to use. The queue query does sort by seq + // today, so this changes nothing — but the ordering IS the route the rider + // drives, and leaving it as an unstated precondition means the next caller, + // or an edited query, silently reorders someone's afternoon. Sorted on a + // copy so the caller's slice is never mutated underneath it. + ordered := make([]models.BookingDestination, len(destinations)) + copy(ordered, destinations) + sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Seq < ordered[j].Seq }) + destinations = ordered + + stops := make([]milerStop, 0, len(destinations)) + for _, d := range destinations { + lat, lng := 0.0, 0.0 + if d.Pinlatitude != nil && d.Pinlongitude != nil { + lat, lng = *d.Pinlatitude, *d.Pinlongitude + } + stops = append(stops, milerStop{ + seq: d.Seq, + consignmentID: d.Consignmentid, + trackingNo: d.Trackingno, + deliveryAddress: joinNonEmpty(", ", d.Building, d.Street, d.Landmark, d.Districtname, d.Statename), + deliveryLat: lat, + deliveryLng: lng, + recipientName: d.Recipientname, + recipientPhone: d.Recipientphone, + codAmount: d.Codamount, + parcels: parcelsByDestination[d.Bookingdestinationid], + }) + } + + // Destination 0's coordinates are mirrored onto the booking and may have + // been corrected there by the rider at the door, so prefer those when the + // destination itself never got a pin. + if stops[0].deliveryLat == 0 && stops[0].deliveryLng == 0 { + stops[0].deliveryLat = b.Deliverylatitude + stops[0].deliveryLng = b.Deliverylongitude + } + + return stops +} + // milerConsignmentForRider loads a consignment and confirms it belongs to this // rider — either it is linked to a booking currently assigned to them, or they // are the one who collected it (Createdby). Returns a stable error code on @@ -437,6 +605,19 @@ func milerConsignmentForRider(milerUserID, consignmentID int) (*models.Consignme db.DB.Model(&models.PickupBooking{}). Where("consignmentid = ? AND assignedmileruserid = ?", consignmentID, milerUserID). Count(&count) + + // pickupbookings.consignmentid names only the FIRST order of a + // multi-destination pickup, so the count above misses orders 2..N entirely. + // The destination table is what actually knows which booking a consignment + // belongs to. + if count == 0 { + db.DB.Model(&models.BookingDestination{}). + Joins("JOIN pickupbookings ON pickupbookings.bookingid = bookingdestinations.bookingid"). + Where("bookingdestinations.consignmentid = ? AND pickupbookings.assignedmileruserid = ?", + consignmentID, milerUserID). + Count(&count) + } + if count == 0 && consignment.Createdby != milerUserID { return nil, constants.ErrConsignmentNotAssigned, fmt.Errorf("not this rider's consignment") } @@ -544,14 +725,30 @@ func MilerStartDelivery(c *fiber.Ctx) error { return utils.Internal(c, "failed to update miler availability") } + notifyOutForDelivery, err := recordCxConsignmentStage(tx, consignment.Consignmentid, + constants.ConsignmentOutForDelivery, constants.CxActorMiler, &milerUserID, + "POST /miler/consignments/{id}/start-delivery") + if err != nil { + tx.Rollback() + utils.Error("MilerStartDelivery: could not record out_for_delivery", + "consignment_id", consignment.Consignmentid, "error", err) + return utils.Internal(c, "failed to start delivery") + } + if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to start delivery") } + notifyOutForDelivery() + // Tell the customer it's on the way, and hand the receiver their OTP (only the // receiver — the rider is told it at the door). - var booking models.PickupBooking - if db.DB.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil { + // + // Resolved through bookingdestinations: pickupbookings.consignmentid names + // only the first order of a multi-destination pickup, so joining on it meant + // no customer was ever told about orders 2..N going out for delivery. + if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil { + booking := *bookingPtr var customer models.AppCustomer if db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error == nil && customer.Devicetoken != "" { body := "Your parcel is out for delivery." @@ -598,16 +795,32 @@ func MilerDeliverConsignment(c *fiber.Ctx) error { return utils.BadRequest(c, "deliveredtoname is required") } - var consignment models.Consignment - if err := db.DB.First(&consignment, consignmentID).Error; err != nil { - return utils.NotFound(c, "consignment not found") - } - - var booking models.PickupBooking - if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID). - First(&booking).Error; err != nil { + // Ownership and the booking behind the parcel, via the one helper that + // understands a multi-destination pickup. This used to be a direct + // `WHERE consignmentid = ? AND assignedmileruserid = ?` on pickupbookings — + // which names only the FIRST order of a pickup, so a rider delivering the + // second parcel of a three-destination visit was told "assigned consignment + // not found" and could not close the delivery at all. + // Both failures answer 404 with the exact messages this endpoint has always + // returned. milerConsignmentForRider distinguishes "no such consignment" + // from "not yours" and start-delivery reports that as a 403, but the + // deployed rider app was built against a 404 here and changing a live + // endpoint's status code is not this work's business. Only the LOOKUP is + // fixed; the contract is byte-identical. + consignmentPtr, code, err := milerConsignmentForRider(milerUserID, consignmentID) + if err != nil { + if code == constants.ErrConsignmentNotFound { + return utils.NotFound(c, "consignment not found") + } return utils.NotFound(c, "assigned consignment not found") } + consignment := *consignmentPtr + + _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid) + if !ok || bookingPtr == nil { + return utils.NotFound(c, "assigned consignment not found") + } + booking := *bookingPtr if consignment.Status != constants.ConsignmentOutForDelivery { return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState, @@ -702,10 +915,27 @@ func MilerDeliverConsignment(c *fiber.Ctx) error { return utils.Internal(c, "failed to close assignment") } + // The customer's Delivered milestone, against THIS order. The booking only + // reads as completed once every destination has landed — cxstage rolls the + // booking up from the least-advanced order, so a two-parcel pickup with one + // still in transit stays "In transit" rather than telling the customer + // everything arrived. + notifyDelivered, err := recordCxConsignmentStage(tx, consignment.Consignmentid, + constants.ConsignmentDelivered, constants.CxActorMiler, &milerUserID, + "POST /miler/consignments/{id}/deliver") + if err != nil { + tx.Rollback() + utils.Error("MilerDeliverConsignment: could not record delivered", + "consignment_id", consignment.Consignmentid, "error", err) + return utils.Internal(c, "failed to confirm delivery") + } + if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to confirm delivery") } + notifyDelivered() + if db.Js != nil { payload := map[string]interface{}{ "bookingid": booking.Bookingid, diff --git a/controllers/milerController.go b/controllers/milerController.go index c4a2c2c..476d004 100644 --- a/controllers/milerController.go +++ b/controllers/milerController.go @@ -2,7 +2,6 @@ package controllers import ( "context" - "crypto/rand" "encoding/json" "fmt" "math" @@ -16,6 +15,7 @@ import ( "doormile/db" "doormile/dto" "doormile/internal/assignment" + "doormile/internal/cxstage" "doormile/internal/legs" "doormile/internal/notify" "doormile/internal/routing" @@ -26,12 +26,6 @@ import ( "github.com/redis/go-redis/v9" ) -func generateTrackingNo() string { - b := make([]byte, 4) - rand.Read(b) - return fmt.Sprintf("DM-TRK-%X-%d", b, time.Now().Unix()%100000) -} - func LoginMiler(cfg *config.Config) fiber.Handler { return func(c *fiber.Ctx) error { req := new(dto.MilerLoginRequest) @@ -529,10 +523,40 @@ func AcceptMilerAssignment(c *fiber.Ctx) error { return utils.Internal(c, "failed to update miler availability") } + // Accepting is the customer's "on the way": the rider has seen the job and + // is heading over. `assigned` was already recorded when the assignment was + // created (assignMilerTx / commitAssignment) and is re-asserted here only + // as a safety net for a booking assigned before this surface existed — + // Record dedupes, so it appends nothing when it is already on the timeline. + // + // Deriving on_the_way from the GPS stream instead would mean re-deriving it + // on every ping: thousands of writes to learn something the accept already + // said. + stageAt := utils.DBNow() + for _, stage := range []string{constants.CxStageAssigned, constants.CxStageOnTheWay} { + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: assignment.Bookingid, + Stage: stage, + ActorType: constants.CxActorMiler, + ActorID: &milerUserID, + Source: "POST /miler/assignments/{id}/accept", + At: stageAt, + }); err != nil { + tx.Rollback() + utils.Error("AcceptMilerAssignment: could not record customer stage", + "booking_id", assignment.Bookingid, "stage", stage, "error", err) + return utils.Internal(c, "failed to accept assignment") + } + } + if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to commit assignment acceptance") } + // No push here: `assigned` was already announced when the assignment was + // created, and on_the_way deliberately rolls up on the timeline. The + // existing "Miler Accepted" notification below is the one the customer gets. + // Accepting a stop moves it into the active set the optimizer orders over, so // re-sequence the rider off the request path. No-op below two active stops. routing.SequenceMilerStopsAsync(milerUserID) @@ -687,6 +711,17 @@ func MilerCancelAssignment(c *fiber.Ctx) error { return utils.Internal(c, "failed to update miler availability") } + // The pickup is NOT cancelled — it goes back into the pool. The customer's + // stage has to walk back with it, or they keep seeing "Miler assigned" and + // a rider card for someone who is no longer coming. + if err := cxstage.Release(tx, booking.Bookingid, req.Reason, + constants.CxActorMiler, &milerUserID, + "POST /miler/bookings/{id}/cancel"); err != nil { + tx.Rollback() + utils.Error("MilerCancelAssignment: could not release the customer stage", "booking_id", booking.Bookingid, "error", err) + return utils.Internal(c, "failed to commit cancellation") + } + if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to commit cancellation") } @@ -751,9 +786,29 @@ func BookingReachedCustomer(c *fiber.Ctx) error { return utils.Internal(c, "failed to update miler availability") } + // Arrival is the LAST cancellable stage on the customer side, so it has to + // be recorded transactionally with the arrival fact itself. A gap between + // the two is a window in which the customer can still cancel a pickup the + // rider is already standing at. + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: booking.Bookingid, + Stage: constants.CxStageArrived, + ActorType: constants.CxActorMiler, + ActorID: &milerUserID, + Source: "POST /miler/bookings/{id}/reached", + At: utils.DBNow(), + }); err != nil { + tx.Rollback() + utils.Error("BookingReachedCustomer: could not record arrived stage", "booking_id", booking.Bookingid, "error", err) + return utils.Internal(c, "failed to record arrival") + } + if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to confirm arrival") } + + go cxstage.Notify(booking.Bookingid, nil, constants.CxStageArrived) + return utils.OK(c, fiber.Map{ "bookingid": booking.Bookingid, "status": booking.Status, @@ -859,12 +914,19 @@ func BookingParcelConfirm(c *fiber.Ctx) error { return utils.NotFound(c, "assigned booking not found") } + // Photos are the evidence half of the receipt. Weight without a photograph + // is a number the customer has no way to check, and this is the only point + // in the flow where anyone is standing next to the parcel. Sent as storage + // keys from the presigned upload (POST /miler/uploads/sign), not as raw + // URLs: the customer is served a short-lived signed link derived from the + // key, never a permanent one. type ParcelUpdate struct { - ParcelID int `json:"parcel_id"` - Weight float64 `json:"weight"` - Length float64 `json:"length"` - Width float64 `json:"width"` - Height float64 `json:"height"` + ParcelID int `json:"parcel_id"` + Weight float64 `json:"weight"` + Length float64 `json:"length"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Photos []string `json:"photos"` } var req struct { Parcels []ParcelUpdate `json:"parcels"` @@ -887,8 +949,13 @@ func BookingParcelConfirm(c *fiber.Ctx) error { parcelMap[parcels[i].Bookingparcelid] = &parcels[i] } - now := time.Now() + now := utils.DBNow() var totalChargeable float64 + // Chargeable weight per destination, so each order settles on the weight of + // its own parcels rather than on the whole visit's total. + perDestination := map[int]float64{} + + tx := db.DB.Begin() for _, upd := range req.Parcels { p, ok := parcelMap[upd.ParcelID] @@ -900,10 +967,51 @@ func BookingParcelConfirm(c *fiber.Ctx) error { p.Width = upd.Width p.Height = upd.Height p.Updatedat = now - db.DB.Save(p) + if err := tx.Save(p).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to save parcel measurements") + } volumetric := calculateVolumetricWeight(upd.Length, upd.Width, upd.Height) - totalChargeable += math.Max(upd.Weight, volumetric) + chargeable := math.Max(upd.Weight, volumetric) + totalChargeable += chargeable + if p.Bookingdestinationid != nil { + perDestination[*p.Bookingdestinationid] += chargeable + } + + for _, key := range upd.Photos { + key = strings.TrimSpace(key) + if key == "" { + continue + } + photo := models.BookingParcelPhoto{ + Bookingid: bookingID, + Bookingdestinationid: p.Bookingdestinationid, + Objectkey: key, + Capturedbyuserid: &milerUserID, + Capturedat: now, + } + if err := tx.Create(&photo).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to save parcel photo") + } + } + } + + // The verification block the customer's receipt reads. Written here, at the + // door, where the measurement was actually taken — pickup-complete restates + // it from the same parcel rows when the price settles, so the two cannot + // disagree. + for destinationID, weight := range perDestination { + if err := cxRecordVerification(tx, destinationID, weight, milerUserID, now); err != nil { + tx.Rollback() + utils.Error("BookingParcelConfirm: could not record verification", "booking_id", bookingID, "error", err) + return utils.Internal(c, "failed to record the parcel weight") + } + } + + if err := tx.Commit().Error; err != nil { + return utils.Internal(c, "failed to confirm parcels") } return utils.OK(c, fiber.Map{ @@ -1030,30 +1138,17 @@ func BookingPickupComplete(c *fiber.Ctx) error { } } - var parcels []models.BookingParcel - tx.Where("bookingid = ?", bookingID).Find(&parcels) - - var totalDead, totalChargeable, maxL, maxW, maxH float64 - for _, p := range parcels { - vol := calculateVolumetricWeight(p.Length, p.Width, p.Height) - totalDead += p.Weight - totalChargeable += math.Max(p.Weight, vol) - if p.Length > maxL { - maxL = p.Length - } - if p.Width > maxW { - maxW = p.Width - } - if p.Height > maxH { - maxH = p.Height - } + // One visit, N orders. A customer-app booking fans out into one consignment + // per destination — each with its own tracking number and its own journey — + // while a console-created booking, which has no destination rows, produces + // the single consignment it always did. cxPickupLegs is what decides which + // of those this is; nothing below needs to know. + legs, err := cxPickupLegs(tx, &booking) + if err != nil { + tx.Rollback() + utils.Error("BookingPickupComplete: could not resolve pickup legs", "booking_id", bookingID, "error", err) + return utils.Internal(c, "failed to read the parcels on this booking") } - if len(parcels) == 0 { - totalDead = 0.5 - totalChargeable = 0.5 - } - - trackingNo := generateTrackingNo() // The base this parcel belongs to. Backend decides — the app is told where to // go and never picks a base itself. resolveHandoverHub prefers the base the @@ -1069,36 +1164,6 @@ func BookingPickupComplete(c *fiber.Ctx) error { utils.Warn("BookingPickupComplete: no base could be resolved for this pickup", "miler_user_id", milerUserID, "booking_id", bookingID) } - // A hub-routed parcel: with the hub-handover flow ON it stops at Created — - // collected, in the rider's hands, on its way to a base — and only reaches - // Inwarded_at_Hub when the handover is actually recorded. With it OFF - // (default, and what the current app expects) it is marked Inwarded_at_Hub - // here, which is not where the parcel physically is but is what the current - // app and the console's inbound views read. - consignmentStatus := constants.ConsignmentInwardedAtHub - if hubHandoverEnabled() { - consignmentStatus = constants.ConsignmentCreated - } - - // Hyperlocal shortcut: pickup and delivery in the same postal area mean no - // hub-to-hub tripsheet leg is needed, so the same miler carries it to the - // final mile instead of parking it at the hub. - // - // With the collected-state flow ON it lands in Collected_By_Miler — collected - // but not yet out for delivery — and the rider taps start-delivery to move it - // to Out_for_Delivery, which lets the console tell "collected" from "actively - // delivering". With it OFF (default, and what the current app expects) it goes - // straight to Out_for_Delivery exactly as before. - if isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode, - booking.Pickuplatitude, booking.Pickuplongitude, - booking.Deliverylatitude, booking.Deliverylongitude) { - if collectedStateEnabled() { - consignmentStatus = constants.ConsignmentCollectedByMiler - } else { - consignmentStatus = constants.ConsignmentOutForDelivery - } - } - // The consignment's tenant is the booking's own tenant (set explicitly at // CreateExpressBooking time), not the completing miler's tenantid claim — a // miler can carry parcels for tenants other than their own, and using @@ -1110,96 +1175,257 @@ func BookingPickupComplete(c *fiber.Ctx) error { consignmentTenantID = *booking.Tenantid } - // Carried over so the consignment stays traceable to the client site it was - // collected from — for a food client that's the kitchen, and "how many - // parcels went out of which kitchen" is unanswerable without it. - consignment := models.Consignment{ - Trackingno: trackingNo, - Tenantid: consignmentTenantID, - Pickuplocationid: booking.Pickuplocationid, - Tenantlocationid: booking.Tenantlocationid, - Pickuplatitude: booking.Pickuplatitude, - Pickuplongitude: booking.Pickuplongitude, - Deliverylatitude: booking.Deliverylatitude, - Deliverylongitude: booking.Deliverylongitude, - Pickuppincode: booking.Pickuppincode, - Deliverypincode: booking.Deliverypincode, - Length: maxL, - Width: maxW, - Height: maxH, - Deadweight: totalDead, - Volumetricweight: totalChargeable - totalDead, - Chargeableweight: totalChargeable, - Paymentmode: "Prepaid", - Status: consignmentStatus, - Estimateddeliveryat: nil, - Createdby: milerUserID, - Originhubid: defaultHubID, - Currenthubid: defaultHubID, - } - - // Under the compatibility flow the parcel is treated as received at the base - // the moment it is collected, so the received-at fact is stamped here too — - // otherwise every parcel inwarded this way would have a null handover time - // and base reconciliation would have nothing to compare against. - if consignmentStatus == constants.ConsignmentInwardedAtHub { - consignment.Inwardedat = &now + var tenant models.Tenant + tenantNeedsOTP := false + if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil { + tenantNeedsOTP = tenant.Requiredeliveryotp } + // Money collected at the door. Split across the legs below rather than + // stamped whole onto each one: a single payment covering a three-stop + // pickup must not appear three times in the books. var payment models.BookingPayment - if tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil { - if payment.Paymentstatus == constants.PaymentStatusPaid { - consignment.Codcollected = payment.Amount - } else { - consignment.Codamount = payment.Amount + hasPayment := tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil + + created := make([]models.Consignment, 0, len(legs)) + trackingNos := make([]string, 0, len(legs)) + riderMarkedBusy := false + assignmentStillOpen := false + + for i, leg := range legs { + totalDead, totalChargeable, maxL, maxW, maxH := cxLegWeights(leg) + trackingNo := generateTrackingNo() + + // A hub-routed parcel: with the hub-handover flow ON it stops at Created — + // collected, in the rider's hands, on its way to a base — and only reaches + // Inwarded_at_Hub when the handover is actually recorded. With it OFF + // (default, and what the current app expects) it is marked Inwarded_at_Hub + // here, which is not where the parcel physically is but is what the current + // app and the console's inbound views read. + consignmentStatus := constants.ConsignmentInwardedAtHub + if hubHandoverEnabled() { + consignmentStatus = constants.ConsignmentCreated + } + + // Hyperlocal shortcut: pickup and delivery in the same postal area mean no + // hub-to-hub tripsheet leg is needed, so the same miler carries it to the + // final mile instead of parking it at the hub. Decided per leg, because on + // a multi-destination pickup one parcel can be going round the corner while + // another is going to another state. + // + // With the collected-state flow ON it lands in Collected_By_Miler — collected + // but not yet out for delivery — and the rider taps start-delivery to move it + // to Out_for_Delivery, which lets the console tell "collected" from "actively + // delivering". With it OFF (default, and what the current app expects) it goes + // straight to Out_for_Delivery exactly as before. + if isHyperlocalBooking(booking.Pickuppincode, leg.DeliveryPincode, + booking.Pickuplatitude, booking.Pickuplongitude, + leg.DeliveryLatitude, leg.DeliveryLongitude) { + if collectedStateEnabled() { + consignmentStatus = constants.ConsignmentCollectedByMiler + } else { + consignmentStatus = constants.ConsignmentOutForDelivery + } + } + + // Carried over so the consignment stays traceable to the client site it was + // collected from — for a food client that's the kitchen, and "how many + // parcels went out of which kitchen" is unanswerable without it. + consignment := models.Consignment{ + Trackingno: trackingNo, + Tenantid: consignmentTenantID, + Pickuplocationid: booking.Pickuplocationid, + Tenantlocationid: booking.Tenantlocationid, + Pickuplatitude: booking.Pickuplatitude, + Pickuplongitude: booking.Pickuplongitude, + Deliverylatitude: leg.DeliveryLatitude, + Deliverylongitude: leg.DeliveryLongitude, + Pickuppincode: booking.Pickuppincode, + Deliverypincode: leg.DeliveryPincode, + Length: maxL, + Width: maxW, + Height: maxH, + Deadweight: totalDead, + Volumetricweight: totalChargeable - totalDead, + Chargeableweight: totalChargeable, + Paymentmode: "Prepaid", + Status: consignmentStatus, + Estimateddeliveryat: cxEstimatedDelivery(leg, now), + Createdby: milerUserID, + Originhubid: defaultHubID, + Currenthubid: defaultHubID, + } + + // COD the customer asked to have collected at THIS door, on their behalf. + // Doormile is the carrier, not the seller — this money is never Doormile's. + if leg.CodAmount > 0 { + consignment.Codamount = leg.CodAmount consignment.Paymentmode = "COD" } - } - // A parcel that goes straight out for delivery here (collected-state flow off) - // needs its receiver OTP before commit — same as before. When the flow is on, - // a hyperlocal parcel stops at Collected_By_Miler and its OTP is issued later - // at start-delivery instead, so this block simply doesn't fire. Only clients - // that ask for one get an OTP (Tenant.Requiredeliveryotp). - if consignmentStatus == constants.ConsignmentOutForDelivery { - var tenant models.Tenant - if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil && tenant.Requiredeliveryotp { + // Under the compatibility flow the parcel is treated as received at the base + // the moment it is collected, so the received-at fact is stamped here too — + // otherwise every parcel inwarded this way would have a null handover time + // and base reconciliation would have nothing to compare against. + if consignmentStatus == constants.ConsignmentInwardedAtHub { + consignment.Inwardedat = &now + } + + // The pickup fee the miler collected covers the whole visit, so it is + // recorded once — against the first order — rather than repeated on + // every leg. + if hasPayment && i == 0 { + if payment.Paymentstatus == constants.PaymentStatusPaid { + consignment.Codcollected = payment.Amount + } else { + consignment.Codamount += payment.Amount + consignment.Paymentmode = "COD" + } + } + + // A parcel that goes straight out for delivery here (collected-state flow off) + // needs its receiver OTP before commit — same as before. When the flow is on, + // a hyperlocal parcel stops at Collected_By_Miler and its OTP is issued later + // at start-delivery instead, so this block simply doesn't fire. Only clients + // that ask for one get an OTP (Tenant.Requiredeliveryotp). + if consignmentStatus == constants.ConsignmentOutForDelivery && tenantNeedsOTP { consignment.Deliveryotp = utils.GenerateNumericOTP(6) } + + if err := tx.Create(&consignment).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to convert booking to consignment") + } + + if err := cxLinkLegToOrder(tx, leg, consignment.Consignmentid, trackingNo, + consignment.Estimateddeliveryat, now); err != nil { + tx.Rollback() + utils.Error("BookingPickupComplete: could not link destination to order", "booking_id", bookingID, "error", err) + return utils.Internal(c, "failed to link the destination to its order") + } + + // What the miler weighed at this door, kept where the customer's receipt + // reads it. Without this the verification block stays empty and the + // receipt loses the evidence behind the settled price. + if leg.Destination != nil { + if err := cxRecordVerification(tx, leg.Destination.Bookingdestinationid, + totalChargeable, milerUserID, now); err != nil { + tx.Rollback() + utils.Error("BookingPickupComplete: could not record verification", "booking_id", bookingID, "error", err) + return utils.Internal(c, "failed to record the parcel weight") + } + } + + history := models.ConsignmentHistory{ + Consignmentid: consignment.Consignmentid, + Hubid: defaultHubID, + Userid: &milerUserID, + Eventstatus: consignmentStatus, + Remarks: "Package collected by miler and converted to consignment", + } + if err := tx.Create(&history).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to record consignment history") + } + + // Two separate questions, deliberately not merged. + // + // "Is the rider marked busy?" keeps the EXACT rule this endpoint has + // always applied — Created or Collected_By_Miler only. A hyperlocal + // parcel that goes straight to Out_for_Delivery has always left the + // rider Available here, even though the comment below says otherwise. + // That mismatch is pre-existing and is the default path today; changing + // it would alter live rider availability, which is not this work's + // business. Flagged in docs/customer-app-api.md, not silently fixed. + // + // "Is the assignment still open?" is the one that has to understand the + // fan-out: it closes only when every leg has been handed over at a base, + // which for a single-leg booking is identical to the previous behaviour. + if consignmentStatus == constants.ConsignmentCollectedByMiler || + consignmentStatus == constants.ConsignmentCreated { + riderMarkedBusy = true + } + if consignmentStatus != constants.ConsignmentInwardedAtHub { + assignmentStillOpen = true + } + + // The customer's per-order stage. in_transit is recorded here only on the + // compatibility flow, where the parcel really is treated as received at + // the base the instant it is collected; on the handover flow it waits for + // the rider to actually hand it over. + destinationID := cxDestinationIDFor(leg.Destination) + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: bookingID, + DestinationID: destinationID, + Stage: constants.CxStageOrderCreated, + ActorType: constants.CxActorMiler, + ActorID: &milerUserID, + Source: "POST /miler/bookings/{id}/pickup-complete", + At: utils.DBNow(), + }); err != nil { + tx.Rollback() + utils.Error("BookingPickupComplete: could not record order_created", "booking_id", bookingID, "error", err) + return utils.Internal(c, "failed to record the pickup") + } + + // On the compatibility flow a parcel is already past order_created the + // instant it is collected — hub-routed ones are stamped Inwarded_at_Hub + // here, and hyperlocal ones go straight to Out_for_Delivery. Recording + // what the status actually says keeps the timeline honest: those + // transitions really did happen at this moment, and omitting them would + // leave a parcel showing "Package collected" while the rider is already + // carrying it to the door. + if implied, ok := cxStageForConsignmentStatus(consignmentStatus); ok { + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: bookingID, + DestinationID: destinationID, + Stage: implied, + ActorType: constants.CxActorMiler, + ActorID: &milerUserID, + Source: "POST /miler/bookings/{id}/pickup-complete", + At: utils.DBNow(), + }); err != nil { + tx.Rollback() + utils.Error("BookingPickupComplete: could not record per-order stage", "booking_id", bookingID, "error", err) + return utils.Internal(c, "failed to record the pickup") + } + } + + created = append(created, consignment) + trackingNos = append(trackingNos, trackingNo) } - if err := tx.Create(&consignment).Error; err != nil { + // Recorded before order_created in wall-clock terms — the parcels were in the + // rider's hands before the orders existed — but written after, because the + // weights the price settles on are only known once the legs are built. + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: bookingID, + Stage: constants.CxStagePickedUp, + ActorType: constants.CxActorMiler, + ActorID: &milerUserID, + Source: "POST /miler/bookings/{id}/pickup-complete", + At: utils.DBNow(), + }); err != nil { tx.Rollback() - return utils.Internal(c, "failed to convert booking to consignment") + utils.Error("BookingPickupComplete: could not record picked_up", "booking_id", bookingID, "error", err) + return utils.Internal(c, "failed to record the pickup") } - booking.Consignmentid = &consignment.Consignmentid + // pickupbookings.consignmentid names the FIRST order only. It is kept for + // the console and the legacy reads that still join on it; anything that + // needs the whole set goes through bookingdestinations. + first := created[0] + booking.Consignmentid = &first.Consignmentid booking.Status = constants.BookingConvertedConsignment if err := tx.Save(&booking).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to link booking to consignment") } - history := models.ConsignmentHistory{ - Consignmentid: consignment.Consignmentid, - Hubid: defaultHubID, - Userid: &milerUserID, - Eventstatus: consignmentStatus, - Remarks: "Package collected by miler and converted to consignment", - } - if err := tx.Create(&history).Error; err != nil { - tx.Rollback() - return utils.Internal(c, "failed to record consignment history") - } - - // A hyperlocal parcel is still in the rider's hands (they will deliver it), so - // they stay Picked_Up and out of the assignment pool until they finish. A - // hub-routed parcel was dropped at the hub, so the rider frees up. + // Unchanged from before the fan-out: a parcel still on its way to a base + // keeps the rider marked busy; anything else frees them up. postPickupAvailability := constants.MilerAvailable - if consignmentStatus == constants.ConsignmentCollectedByMiler || - consignmentStatus == constants.ConsignmentCreated { - // Created here means hub-routed and still in the rider's hands: they are - // carrying it to a base, so they are not free yet. + if riderMarkedBusy { postPickupAvailability = constants.MilerPickedUp } @@ -1208,7 +1434,7 @@ func BookingPickupComplete(c *fiber.Ctx) error { // on the compatibility flow and the rider could not go off duty — MilerEndDuty // refuses while any assignment is still Assigned/Accepted. On the handover // flow the assignment stays open on purpose and closes at inward-at-hub. - if consignmentStatus == constants.ConsignmentInwardedAtHub { + if !assignmentStillOpen { if err := tx.Model(&models.BookingAssignment{}). Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?", bookingID, milerUserID, @@ -1231,25 +1457,16 @@ func BookingPickupComplete(c *fiber.Ctx) error { return utils.Internal(c, "failed to complete pickup") } + // One notification for the milestone, not one per order: three tracking + // numbers arriving as three buzzes for a single visit is noise, and + // order_created deliberately rolls up on the timeline. + go cxstage.Notify(bookingID, nil, constants.CxStagePickedUp) + // If an OTP was issued here (parcel went straight out for delivery), it goes to // the receiver in this notification — the rider is told it at the door. When // the collected-state flow is on, no OTP exists yet and the notification is // just "collected"; the OTP rides the start-delivery notification instead. - var customer models.AppCustomer - if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" { - body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo) - payload := map[string]string{ - "booking_id": strconv.Itoa(bookingID), - "tracking_no": trackingNo, - } - if consignment.Deliveryotp != "" { - body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignment.Deliveryotp) - payload["delivery_otp"] = consignment.Deliveryotp - } - if notifyErr := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); notifyErr != nil { - utils.Warn("FCM: failed to notify customer on pickup", "booking_id", bookingID, "error", notifyErr) - } - } + notifyCustomerOnPickup(&booking, created, trackingNos) // next_action says what the rider does next; next_hub says where. An // inward_at_hub with no base named leaves a rider holding a parcel with @@ -1259,22 +1476,74 @@ func BookingPickupComplete(c *fiber.Ctx) error { // // consignment_id is always present: the delivery leg is keyed on it, and // without it the app cannot name the parcel it is about to act on. + // + // The single-consignment fields still describe the FIRST order, unchanged, + // so the deployed rider app keeps working exactly as before. `consignments` + // is additive and carries the full set for a build that can show them. resp := fiber.Map{ - "tracking_no": trackingNo, - "consignment_id": consignment.Consignmentid, - "consignmentstatus": consignment.Status, - "status": consignment.Status, + "tracking_no": trackingNos[0], + "consignment_id": first.Consignmentid, + "consignmentstatus": first.Status, + "status": first.Status, "booking_no": booking.Bookingno, "booking_status": booking.Status, - "next_action": nextActionForConsignment(consignment.Status), + "next_action": nextActionForConsignment(first.Status), + "consignments": renderPickupOrders(created, trackingNos), } - if consignment.Status == constants.ConsignmentCreated || - consignment.Status == constants.ConsignmentInwardedAtHub { + if first.Status == constants.ConsignmentCreated || + first.Status == constants.ConsignmentInwardedAtHub { resp["next_hub"] = renderBase(handoverHub) } return utils.OK(c, resp) } +// renderPickupOrders lists every order a pickup produced, so a rider carrying +// three parcels from one visit can be shown three stops rather than one. +func renderPickupOrders(consignments []models.Consignment, trackingNos []string) []fiber.Map { + out := make([]fiber.Map, 0, len(consignments)) + for i := range consignments { + cn := consignments[i] + out = append(out, fiber.Map{ + "consignment_id": cn.Consignmentid, + "tracking_no": trackingNos[i], + "consignmentstatus": cn.Status, + "next_action": nextActionForConsignment(cn.Status), + "delivery_pincode": cn.Deliverypincode, + }) + } + return out +} + +// notifyCustomerOnPickup tells the customer their parcels were collected, and +// hands the receiver any delivery OTP that was issued. Best-effort: a push that +// fails must never fail a pickup that already committed. +func notifyCustomerOnPickup(booking *models.PickupBooking, consignments []models.Consignment, trackingNos []string) { + var customer models.AppCustomer + if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err != nil { + return + } + if customer.Devicetoken == "" { + return + } + + body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNos[0]) + if len(trackingNos) > 1 { + body = fmt.Sprintf("%d parcels picked up — first tracking no: %s", len(trackingNos), trackingNos[0]) + } + payload := map[string]string{ + "booking_id": strconv.Itoa(booking.Bookingid), + "tracking_no": trackingNos[0], + "reference": booking.Bookingno, + } + if len(consignments) > 0 && consignments[0].Deliveryotp != "" { + body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignments[0].Deliveryotp) + payload["delivery_otp"] = consignments[0].Deliveryotp + } + if err := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); err != nil { + utils.Warn("FCM: failed to notify customer on pickup", "booking_id", booking.Bookingid, "error", err) + } +} + func BookingVehicleRequiredEscalate(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) bookingID, err := strconv.Atoi(c.Params("bookingid")) diff --git a/controllers/otpController.go b/controllers/otpController.go deleted file mode 100644 index 2a4ac2b..0000000 --- a/controllers/otpController.go +++ /dev/null @@ -1,128 +0,0 @@ -package controllers - -import ( - "context" - "crypto/rand" - "fmt" - "math/big" - "time" - - "doormile/config" - "doormile/db" - "doormile/dto" - "doormile/internal/mail" - "doormile/utils" - - "github.com/gofiber/fiber/v2" - "github.com/redis/go-redis/v9" -) - -const ( - otpTTL = 5 * time.Minute - otpMaxAttempts = 5 - // otpVerifiedTTL is how long a successful email verification stays usable as - // proof of identity for a follow-up action such as a PIN reset. Long enough - // to type a new PIN, short enough that a stale verification can't be - // redeemed later. - otpVerifiedTTL = 10 * time.Minute -) - -func generateOtpCode() string { - n, err := rand.Int(rand.Reader, big.NewInt(1000000)) - if err != nil { - return "000000" - } - return fmt.Sprintf("%06d", n.Int64()) -} - -func otpKey(email string) string { return fmt.Sprintf("otp:email:%s", email) } -func otpAttemptsKey(email string) string { return fmt.Sprintf("otp:email:%s:attempts", email) } - -// otpVerifiedKey marks an email as recently proven. Verification previously -// left no trace at all, so nothing downstream could require it — which is why -// ResetCustomerPin was able to overwrite a PIN on nothing but a phone number. -func otpVerifiedKey(email string) string { return fmt.Sprintf("otp:email:%s:verified", email) } - -// ConsumeEmailVerification reports whether the email was verified recently, and -// clears the marker so a single verification can authorise exactly one action. -func ConsumeEmailVerification(email string) bool { - if db.Rdb == nil || email == "" { - return false - } - ctx := context.Background() - n, err := db.Rdb.Del(ctx, otpVerifiedKey(email)).Result() - return err == nil && n > 0 -} - -func SendCustomerEmailOtp(cfg *config.Config) fiber.Handler { - return func(c *fiber.Ctx) error { - req := new(dto.SendEmailOtpRequest) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") - } - if req.Email == "" { - return utils.BadRequest(c, "email is required") - } - - if db.Rdb == nil { - return utils.Internal(c, "verification service unavailable") - } - - code := generateOtpCode() - ctx := context.Background() - - if err := db.Rdb.Set(ctx, otpKey(req.Email), code, otpTTL).Err(); err != nil { - return utils.Internal(c, "failed to generate verification code") - } - db.Rdb.Del(ctx, otpAttemptsKey(req.Email)) - - if err := mail.SendOTPEmail(cfg, req.Email, code); err != nil { - utils.Warn("failed to send OTP email", "email", req.Email, "error", err) - return utils.Internal(c, "failed to send verification email") - } - - return utils.Message(c, "verification code sent") - } -} - -func VerifyCustomerEmailOtp() fiber.Handler { - return func(c *fiber.Ctx) error { - req := new(dto.VerifyEmailOtpRequest) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") - } - if req.Email == "" || req.Otp == "" { - return utils.BadRequest(c, "email and otp are required") - } - - if db.Rdb == nil { - return utils.Internal(c, "verification service unavailable") - } - - ctx := context.Background() - key := otpKey(req.Email) - - stored, err := db.Rdb.Get(ctx, key).Result() - if err == redis.Nil { - return utils.BadRequest(c, "verification code expired or not found, please resend") - } else if err != nil { - return utils.Internal(c, "failed to verify code") - } - - if stored != req.Otp { - attemptsKey := otpAttemptsKey(req.Email) - attempts, _ := db.Rdb.Incr(ctx, attemptsKey).Result() - db.Rdb.Expire(ctx, attemptsKey, otpTTL) - if attempts >= otpMaxAttempts { - db.Rdb.Del(ctx, key, attemptsKey) - return utils.BadRequest(c, "too many incorrect attempts, please request a new code") - } - return utils.Unauthorized(c, "incorrect verification code") - } - - db.Rdb.Del(ctx, key, otpAttemptsKey(req.Email)) - // Recorded so a follow-up PIN reset can prove this email was verified. - db.Rdb.Set(ctx, otpVerifiedKey(req.Email), "1", otpVerifiedTTL) - return utils.Message(c, "email verified successfully") - } -} diff --git a/docs/customer-api-testing.md b/docs/customer-api-testing.md new file mode 100644 index 0000000..645ed20 --- /dev/null +++ b/docs/customer-api-testing.md @@ -0,0 +1,392 @@ +# Customer API — file reference & test guide + +Companion to [`customer-app-api.md`](customer-app-api.md), which explains *why*. +This one is the flat reference: what each file is for, and how to call every +endpoint. + +**Base URL:** `https://api.doormile.com/api/v1` +**Staging:** `https://staging-api.doormile.com/api/v1` + +--- + +## 1. What `seed_customer_app.sql` is for + +**It is the only thing that makes the booking form work.** The customer app's +first four calls read from tables that ship empty, so without this file the app +opens to a blank state picker and nothing can be booked. + +Run it **once, after the Go service has started at least once** — the service +runs `AutoMigrate` on boot (`main.go:97`), and this file fills the tables that +creates. Running it before the first boot fails: the tables do not exist yet. + +```bash +psql "postgres://admin:PASSWORD@HOST:5433/logistics" -f seed_customer_app.sql +``` + +It is **idempotent** — safe to re-run. Every insert is `ON CONFLICT DO UPDATE` +or guarded by `WHERE NOT EXISTS`. + +### What it inserts, and why each part matters + +| Rows | Purpose | +|---|---| +| **5 states** (TN, KL, KA, TG, PY) | `GET /serviceability/states`. Puducherry is seeded serviceable with **no open district** on purpose — it is the only way to exercise the app's "Opening soon" screen. | +| **16 districts** | `GET /serviceability/states/{code}/districts`. Madurai, Kozhikode and Dakshina Kannada are seeded **unavailable with a reason**, because the app renders those names in a "coming soon" line. Each carries `centrelatitude`/`centrelongitude` — for most destinations that is the *only* geography the parcel has until the miler corrects it at the door, and it is what the fare estimate is priced against. | +| **6 pickup slot templates** | `GET /pickup-slots`. Template `t5` is seeded at **capacity 0** — the only way to reach the "Fully booked" state in design QA without actually filling a window. | +| **1 booking-limits row** | `GET /config/booking-limits`. **`maxdestinations` is seeded at 1, not 5** — see §4 below. | +| **2 test customers** | `+919999900001`, `+919999900002`. Pair with `CX_STAGING_OTP=1234` so automated tests can sign in without a real handset. | +| **A hub-attachment UPDATE** | Links each district to its nearest active hub by proximity, so the destination card can name a serving base. Done by distance rather than hard-coded ids because hub ids differ per environment. | + +It also **mirrors the client's in-app mock exactly**, because those cases back +the app's 18 widget tests and its design QA. Change the seeded availability and +a green client test suite stops meaning anything. + +--- + +## 2. Changed files — what each one is for + +### New files + +| File | What it is for | +|---|---| +| `models/customer_app.go` | The 9 new tables. `BookingDestination` is the one that makes multi-destination expressible. | +| `internal/cxstage/stage.go` | Writes the customer's stage timeline from the miler's operational writes. The one place a stage is recorded. | +| `internal/sms/sms.go` | The seam for an SMS gateway. **No provider is wired in** — codes go to the log until one is. | +| `controllers/cxAuthController.go` | OTP auth: request, signup, verify, refresh, logout, me. | +| `controllers/cxCatalogueController.go` | States, districts, pickup slots, booking limits. | +| `controllers/cxPlacesController.go` | Geocoder proxy + Redis cache. The app is never handed a map key. | +| `controllers/cxFareController.go` | Fare estimate for one pickup visit. | +| `controllers/cxBookingController.go` | Create, list, detail, cancel, patch destination, order-by-tracking-id. | +| `controllers/cxBookingView.go` | Builds the canonical booking JSON. Every read that returns a booking goes through it. | +| `controllers/cxPickupFanout.go` | Splits one pickup into N orders at pickup-complete. | +| `controllers/cxConsignmentHooks.go` | Maps a consignment status to a per-order customer stage. | +| `controllers/cxDeviceController.go` | Push token registration. | +| `controllers/cxIdentifiers.go` | Mints `DM-######` and `DMX########` from Postgres sequences. | +| `controllers/cxOpsController.go` | QA-only: force a booking to any stage. Double-gated. | +| `utils/response_cx.go` | The customer response envelope. Separate from `utils.OK`/`Fail` on purpose. | +| `utils/epoch.go` | IST→epoch-millis conversion. Every customer timestamp goes through it. | +| `middlewares/requestid.go` | Echoes `X-Request-Id` on every response. | +| `seed_customer_app.sql` | §1 above. | +| `docs/openapi-customer.yaml` | The spec — 24 paths, 28 operations. | +| `docs/customer-app-api.md` | The change record and reasoning. | +| `scratch/cx_readonly_probe.go` | Read-only check of whether the migration is additive against a real DB. Writes nothing. | +| `controllers/cxHttp_test.go` | HTTP status-code and envelope tests. | +| `controllers/cxCustomerApp_test.go` | Pure-logic and fan-out tests. | +| `routes/routes_customer_test.go` | Routing + the guard proving miler/console are untouched. | +| `internal/cxstage/stage_test.go`, `utils/epoch_test.go` | Stage rollup and timestamp tests. | + +### Modified files + +| File | What changed | +|---|---| +| `routes/routes.go` | Customer routes 19 → 28. | +| `controllers/customerController.go` | PIN auth + old booking handlers **deleted**; profile and locations kept. | +| `controllers/milerController.go` | Pickup-complete now fans out; parcel-confirm takes photos; stage hooks. | +| `controllers/milerAppController.go` | Rider queue emits one stop per order; two broken lookups fixed. | +| `controllers/logisticsHandoverController.go` | Consignment→booking lookup fixed; `in_transit` recorded. | +| `controllers/adminController.go` | Ops cancel now reaches the customer's projection. | +| `controllers/booking_assignment_service.go` | Records `assigned` on manual assignment. | +| `internal/assignment/crm_assignment.go` | Records `assigned` on auto-assignment. | +| `models/booking.go` | 10 new columns on `PickupBooking`, 1 on `BookingParcel`. | +| `constants/constants.go` | 9 stage keys, statuses, actor types. | +| `migrations/migrate.go` | 9 tables, 2 sequences, 2 indexes. | +| `middlewares/idempotency.go` | Anonymous-caller scoping (security fix). | +| `middlewares/city_gate.go` | Exported `PincodeInOperatingCity`. | +| `middlewares/logger.go` | Logs `client`, `platform`, `requestid`. | +| `internal/storage/spaces.go` | `PresignGet` for signed parcel photos. | +| `config/config.go` | `GEOCODER_URL`, `GEOCODER_EMAIL`. | +| `utils/helper.go` | `GenerateTokenWithTTL`. | +| `main.go` | Registers `RequestID()`. | +| `dto/auth.go` | Retired PIN DTOs removed. | +| `controllers/otpController.go` | **Deleted** — its two routes were customer-only and are superseded. | + +--- + +## 3. Every endpoint, with a request you can paste + +Every URL below is complete — copy and paste it. Production host shown; for +staging swap `api.doormile.com` for `staging-api.doormile.com`, nothing else +changes. + +Authenticated calls need one header, using the `accessToken` returned by +`/customer/auth/otp/verify`: + +``` +Authorization: Bearer +``` + +### 3.1 Auth — no token required + +**1. Request a sign-in code** +``` +POST https://api.doormile.com/api/v1/customer/auth/otp/request +``` +```json +{ "identifier": "+919999900001" } +``` + +**2. Sign up (creates the account AND sends the code)** +``` +POST https://api.doormile.com/api/v1/customer/auth/signup +``` +```json +{ "name": "Joe Oommen", "phone": "+919876543210", "email": "joe@example.com" } +``` + +**3. Verify the code → returns the session** +``` +POST https://api.doormile.com/api/v1/customer/auth/otp/verify +Header: Idempotency-Key: +``` +```json +{ "identifier": "+919999900001", "code": "1234" } +``` +> On staging `code` is whatever `CX_STAGING_OTP` is set to. Copy `data.accessToken` +> from the response — everything below needs it. + +**4. Rotate the session** +``` +POST https://api.doormile.com/api/v1/customer/auth/refresh +``` +```json +{ "refreshToken": "" } +``` + +**5. Who am I** — `GET https://api.doormile.com/api/v1/customer/auth/me` (token required, no body) + +**6. Sign out** +``` +POST https://api.doormile.com/api/v1/customer/auth/logout +``` +```json +{ "refreshToken": "", "deviceToken": "" } +``` + +### 3.2 Catalogue — no token required + +| # | Method | URL | +|---|---|---| +| 7 | GET | `https://api.doormile.com/api/v1/customer/serviceability/states` | +| 8 | GET | `https://api.doormile.com/api/v1/customer/serviceability/states/TN/districts` | +| 9 | GET | `https://api.doormile.com/api/v1/customer/pickup-slots?lat=11.0168&lng=76.9558` | +| 10 | GET | `https://api.doormile.com/api/v1/customer/config/booking-limits?lat=11.0168&lng=76.9558` | + +No bodies. Call **9** first in any booking test — you need a real `slotId` from +it, and slot ids expire. + +### 3.3 Places — token required + +| # | Method | URL | +|---|---|---| +| 11 | GET | `https://api.doormile.com/api/v1/customer/places/reverse-geocode?lat=11.0168&lng=76.9558` | +| 12 | GET | `https://api.doormile.com/api/v1/customer/places/search?q=brookefields&lat=11.0168&lng=76.9558` | + +Empty `q` on **12** returns the customer's saved and recent places. + +### 3.4 Fare estimate — token required + +**13.** +``` +POST https://api.doormile.com/api/v1/customer/fare/estimate +``` +```json +{ + "pickup": { "lat": 11.0168, "lng": 76.9558 }, + "destinations": [ + { "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 2 }, + { "stateCode": "KL", "districtCode": "KL-EKM", "packageCount": 1 } + ] +} +``` + +### 3.5 Bookings — token required + +**14. Create a pickup** +``` +POST https://api.doormile.com/api/v1/customer/bookings +Header: Idempotency-Key: +``` +```json +{ + "pickup": { + "title": "12 Nehru Street", + "sub": "Gandhipuram, Coimbatore 641012", + "lat": 11.0168, + "lng": 76.9558 + }, + "slotId": "PASTE_A_REAL_ID_FROM_ENDPOINT_9", + "destinations": [ + { + "stateCode": "TN", + "districtCode": "TN-MAA", + "packageCount": 2, + "details": { + "street": "12th Main", + "building": "3B", + "landmark": "Near bus stand", + "recipientName": "Meera S", + "recipientPhone": "+919884412210", + "instructions": "Call before delivery", + "codAmount": 1200, + "pin": { "lat": 13.0827, "lng": 80.2707 } + } + } + ], + "estimate": { "min": 167, "max": 267 } +} +``` +> ⚠️ **One destination only** while `maxdestinations = 1` (§4). Adding a second +> returns `400 "Up to 1 destinations per pickup"`. +> Returns `reference` (`DM-######`) and **no tracking number** — those are minted +> when the miler completes the pickup. + +**15. List** — `GET https://api.doormile.com/api/v1/customer/bookings?status=active&limit=20` +`status` is `active` | `completed` | `cancelled`. Page with `&cursor=`. + +**16. Detail** — `GET https://api.doormile.com/api/v1/customer/bookings/DM-482913` +The tracking screen and receipt both render from this. Supports `If-None-Match`. + +**17. Cancel** +``` +POST https://api.doormile.com/api/v1/customer/bookings/DM-482913/cancel +``` +```json +{ "reason": "Package not ready" } +``` +> Allowed through `arrived`. From `picked_up` onward returns `409`. + +**18. Fill in a destination's details after booking** +``` +PATCH https://api.doormile.com/api/v1/customer/bookings/DM-482913/destinations/0 +``` +```json +{ + "street": "12th Main", + "landmark": "Near bus stand", + "recipientName": "Meera S", + "recipientPhone": "+919884412210", + "instructions": "Call before delivery", + "pin": { "lat": 13.08, "lng": 80.27 } +} +``` +> `0` is the destination's position. Any subset of fields; `null` clears one. + +**19. One order by tracking number** — `GET https://api.doormile.com/api/v1/customer/orders/DMX10482913` + +### 3.6 Devices — token required + +**20. Register a push token** +``` +POST https://api.doormile.com/api/v1/customer/devices +``` +```json +{ "token": "", "platform": "android", "appVersion": "1.0.0+12" } +``` + +**21. Unregister** — `DELETE https://api.doormile.com/api/v1/customer/devices/` + +### 3.7 Account — token required + +**22. Profile** — `GET https://api.doormile.com/api/v1/customer/profile` + +**23. Update profile** +``` +PUT https://api.doormile.com/api/v1/customer/profile +``` +```json +{ "name": "Joe Oommen", "email": "joe@example.com", "defaultPincode": "641012" } +``` + +**24. Saved addresses** — `GET https://api.doormile.com/api/v1/customer/locations` + +**25. Save an address** +``` +POST https://api.doormile.com/api/v1/customer/locations +``` +```json +{ + "label": "Home", + "address": "12 Nehru Street, Gandhipuram", + "landmark": "Near the bus stand", + "city": "Coimbatore", + "state": "Tamil Nadu", + "pincode": "641012", + "latitude": 11.0168, + "longitude": 76.9558, + "receivername": "Joe Oommen", + "receiverphone": "+919876543210", + "isdefault": true +} +``` + +**26. Update an address** — `PUT https://api.doormile.com/api/v1/customer/locations/1` (same body) +**27. Delete an address** — `DELETE https://api.doormile.com/api/v1/customer/locations/1` + +### 3.8 QA only — staging + +**28. Force a booking to any stage** +``` +POST https://api.doormile.com/api/v1/customer/ops/bookings/DM-482913/stage +``` +```json +{ "stage": "out_for_delivery", "reason": "design QA" } +``` +> Returns `404` unless **both** `ENV != production` **and** +> `CX_ALLOW_STAGE_OVERRIDE=true`. Valid stages: `booked`, `assigned`, +> `on_the_way`, `arrived`, `picked_up`, `order_created`, `in_transit`, +> `out_for_delivery`, `delivered`. + +--- + +## 4. Before you test — two things that will bite you + +**`maxdestinations` is seeded at 1.** Not a bug. The fan-out works server-side, +but the deployed rider app keys its stop list on `orderid`, which is +booking-level — all three stops of a three-destination pickup collapse into one +in its local store, and two parcels would have no stop and no way to be closed. +Raise it only once a rider build keying on `consignmentid` is live: + +```sql +UPDATE customerbookinglimits SET maxdestinations = 5 WHERE applocationid IS NULL; +``` + +**There is no SMS provider.** OTP codes are written to the application log and +nowhere else. On staging set `CX_STAGING_OTP=1234` and use the seeded test +accounts. It is refused when `ENV=production`. + +--- + +## 5. Suggested test order + +1. `GET /serviceability/states` — proves the seed ran +2. `GET /serviceability/states/TN/districts` +3. `GET /pickup-slots?lat=&lng=` — **copy a real `slotId`** +4. `GET /config/booking-limits` — confirm `maxDestinations: 1` +5. `POST /auth/otp/request` with `+919999900001` +6. `POST /auth/otp/verify` with the staging code — **copy `accessToken`** +7. `GET /auth/me` +8. `POST /fare/estimate` +9. `POST /bookings` with the slot id from step 3 +10. `GET /bookings/{reference}` — the canonical object +11. `PATCH /bookings/{reference}/destinations/0` +12. `POST /ops/bookings/{reference}/stage` → `delivered`, then re-read step 10 +13. `POST /bookings/{reference}/cancel` on a fresh booking → expect `409` after + `picked_up`, `200` before + +## 6. Reading the responses + +Success: +```json +{ "success": true, "data": { ... }, "message": "" } +``` +List: +```json +{ "success": true, "data": [ ... ], "total": 9, "nextCursor": "1042", "message": "" } +``` +Failure: +```json +{ "success": false, "message": "Add at least one destination", "error": { "code": "invalid" } } +``` +Branch on `error.code`, never on the message text. Codes: `invalid`, +`invalid_name`, `invalid_otp`, `unauthorized`, `forbidden`, `not_found`, +`conflict`, `unserviceable`, `rate_limited`, `server_error`. diff --git a/docs/customer-app-api.md b/docs/customer-app-api.md new file mode 100644 index 0000000..3dbf850 --- /dev/null +++ b/docs/customer-app-api.md @@ -0,0 +1,928 @@ +# Customer App API (`doormile_cx`) — change record & integration guide + +**Audience:** the `doormile_cx` app developer, plus anyone reviewing this before +it goes to production. +**Backend:** Go + Fiber, `api.doormile.com/api/v1`, namespace `/customer/*`. +**Status:** code complete, compiler- and unit-verified, **not yet run against a +real database**. Read §11 before deploying. + +| | | +|---|---| +| OpenAPI 3.1 spec | [`openapi-customer.yaml`](openapi-customer.yaml) | +| Staging seed | [`../seed_customer_app.sql`](../seed_customer_app.sql) | +| Requirements this answers | *Doormile — Backend Requirements (Customer App v1)* | +| Reviewed | Once, by a reviewer with the Flutter apps but not this repo — **§13** records every finding and what changed | + +--- + +## 0. The one question first: does this affect the Miler app or the consoles? + +# YES. + +Not "no". Anyone telling you otherwise has not read the diff. The customer work +could not be built without touching shared code, because the thing it changes — +one pickup producing many orders — is a fact the rider app and the consoles both +have to cope with. + +Here is the complete list, with severity. Nothing is omitted. + +### 🔴 Behaviour changes those clients WILL observe + +| # | What changed | Who sees it | Action needed | +|---|---|---|---| +| 1 | **Booking references are now `DM-482913`**, not `DM-BK-A1B2C3D4-48291`. New bookings only; existing rows keep their old strings. | Admin console, hub console, Miler app — anywhere a booking number is displayed | None in code (nothing parses the format). Tell ops the format changed. | +| 2 | **Tracking numbers are now `DMX10482913`**, not `DM-TRK-A1B2C3D4-48291`. New consignments only. | Same as above, plus any printed label or customer-facing tracking link | Same. Check label templates for a hardcoded width. | +| 3 | **`GET /miler/bookings` can return more than one row per booking.** A customer-app pickup with 3 destinations becomes 3 stops *after* collection — each with its own `consignmentid`, `trackingno`, address and COD. Before collection it is still 1 row. Console/express bookings are still always 1 row. | Miler app | **A multi-file change, not a one-liner. See §0.6.** | +| 4 | **`POST /miler/bookings/{id}/pickup-complete` gained a `consignments` array** listing every order the pickup produced. All previous top-level fields still describe the first order, unchanged. | Miler app | Purely additive — safe to ignore, but a multi-destination build should read it. | +| 5 | **`POST /miler/bookings/{id}/parcel` accepts an optional `photos: []` per parcel** (storage keys from `/miler/uploads/sign`). Response shape unchanged. | Miler app | Optional. Without photos the customer receipt shows a weight and no evidence. | +| 6 | **`PickupBooking` JSON gained 10 fields** (`slotid`, `customerstage`, `customerstatus`, `estimateminrupees`, `estimatemaxrupees`, `routekm`, `pickuptitle`, `pickupsub`, `cancelreason`, `destinations`) and `BookingParcel` gained `bookingdestinationid`. These serialise on every admin/hub endpoint that returns the model raw. | Admin + hub consoles | Additive JSON. Safe for a JS client that reads named keys. | +| 7 | **Every response now carries an `X-Request-Id` header.** | Everyone | None. Log it — it is how a support report gets correlated. | +| 8 | **Unknown paths under `/customer/*` answer `401`, not `404`.** So the retired `doormile_customer_app` calling `POST /customer/login` gets `401 "authorization header is required"`. | The old customer app | Expected — that app is being retired. Pre-existing Fiber group behaviour, not introduced here. | + +### 🟡 Internal changes with no client-visible contract change + +| # | What changed | Why it is safe | +|---|---|---| +| 9 | Three miler endpoints now resolve a consignment through `bookingdestinations` instead of `pickupbookings.consignmentid` | Bug fix. Identical result for a single-destination booking; the old query returned *nothing* for orders 2..N. See §5. | +| 10 | `assignMilerTx` and `commitAssignment` record a customer stage | Guarded: returns immediately for any booking whose source is not `Customer_App`. An express booking does zero extra writes. | +| 11 | `AcceptMilerAssignment`, `BookingReachedCustomer`, `MilerCancelAssignment`, `MilerStartDelivery`, `MilerInwardConsignmentAtHub`, `MilerDeliverConsignment` record customer stages | Same guard. Response bodies unchanged. | +| 12 | `middlewares.Idempotency` scopes anonymous callers differently | Authenticated key format is **byte-identical** to before (`idem::`), so no in-flight rider key is orphaned at deploy. See §5.4. | +| 13 | Request logs gained `client`, `platform`, `requestid` fields | Log-only. | +| 14 | `utils.GenerateToken` delegates to a new `GenerateTokenWithTTL` | Still 24h for miler/admin/hub. Only the customer surface passes a different TTL. | +| 15 | `internal/storage` gained `PresignGet` | New function; nothing existing calls it. | +| 16 | `middlewares/city_gate.go` gained an exported `PincodeInOperatingCity` | New function; `CityGateMiddleware` itself is unchanged. | +| 17 | 9 new tables + 11 new columns via `AutoMigrate`; 2 sequences, 2 indexes | All additive and nullable/defaulted. See §11 for the deploy note. | + +### 🟢 Deliberately NOT changed, though I was tempted + +Two things I changed while building, then **reverted**, because they were miler +behaviour and out of scope: + +- **Rider availability after pickup-complete.** I had made a rider stay + `Picked_Up` when a hyperlocal parcel goes straight to `Out_for_Delivery`. The + endpoint has always set them `Available` there. The existing code comment says + the opposite of what the code does — a pre-existing inconsistency I have + flagged rather than silently "fixed", because changing it alters live rider + availability and the dispatch pool. **Ops decision, not mine.** +- **`POST /miler/consignments/{id}/deliver` error status.** I had changed a + "not yours" failure from `404` to `403` for consistency with start-delivery. + Reverted: the deployed rider app was built against `404` and its exact + messages. Only the broken *lookup* is fixed. + +--- + +## 0.5 Page-by-page: exactly which screens are affected + +Read from the client source in this workspace (`doormile_crm` = admin console, +`doormile_hub_console` = hub console), not inferred from the API. The Miler app +is not in this workspace, so its rows are derived from the endpoints it calls and +must be confirmed against the Flutter source. + +Legend: 🔴 needs a code change · 🟡 visible change, no code change · 🟢 fixed by +this work · ⚪ no impact + +### Miler app (Flutter — verify against the app source) + +| Screen | Endpoint behind it | Impact | +|---|---|---| +| **Home / my stops** | `GET /miler/bookings` | 🔴 **A pickup can now be several rows.** After collection, a 3-destination customer booking returns 3 stops sharing one `bookingid`. **Key the list on `consignmentid`.** New fields `destinationseq` / `destinationcount` give you "Stop 2 of 3", and `trackingno`, `recipientname`, `recipientphone` are now per stop. Before collection it is still exactly 1 row, and console/express bookings are always 1 row. | +| **Pickup — complete** | `POST /miler/bookings/{id}/pickup-complete` | 🟡 Response gained `consignments[]` (every order the visit produced). All existing top-level fields still describe the first order and are unchanged, so an old build keeps working. | +| **Pickup — weigh/photograph parcels** | `POST /miler/bookings/{id}/parcel` | 🟡 Each parcel now accepts an optional `photos: []` of storage keys from `/miler/uploads/sign`. Response shape unchanged. Without photos the customer's receipt shows a weight with no evidence behind it. | +| **Delivery — deliver / skip** | `POST /miler/consignments/{id}/deliver` | ⚪ Contract unchanged. The internal lookup was broken for orders 2..N and is fixed; status codes and messages are byte-identical. | +| **Base handover** | `POST /miler/consignments/{id}/inward-at-hub` | ⚪ Contract unchanged; same lookup fix. | +| **Earnings** | `GET /miler/earnings` | 🟢 **Fixed before it shipped.** Customer-app jobs would have recorded ₹0 — see §5.6. | +| **Any screen showing a tracking number** | — | 🟡 `DMX10482913` instead of `DM-TRK-A1B2C3D4-48291`. Ten characters shorter, so nothing overflows. | +| Duty, breaks, notifications, support, profile, bases | — | ⚪ Untouched. | + +### Admin console (`doormile_crm`) + +Only **one** of its screens reads booking shape at all. Verified by grep across +`src/pages`. + +| Screen | File | Impact | +|---|---|---| +| **Orders list** | `src/pages/bookings/Bookings.jsx` | 🟡 Four things: (1) the **Booking No** column now shows `DM-482913` — column is `proportional(1.5)`, the string is shorter, nothing overflows; (2) **search** filters `bookingno` by substring, so typing `DM-BK` finds no new bookings — tell ops; (3) the **Delivery City** column reads the flat `deliverycity`, which is **destination 0 only** — a 3-destination pickup shows "Chennai" and does not hint at the other two; (4) the **Price** column reads `serviceoptions[0].estimatedprice` and 🟢 **would have shown "N/A" for every customer booking** until §5.6 was fixed. | +| **Cancel order** (from the Orders list) | `POST /admin/bookings/{id}/cancel`, `.../bulk-cancel` | 🟢 **Fixed before it shipped.** An ops cancel never reached the customer's app — see §5.7. No console change needed; the fix is backend-side. | +| Dashboard, Tenants, Customers, Pricing, Riders, Reports, Invoices, Team, Settings, Survey, auth & error screens | — | ⚪ None of them read booking or consignment shape. | + +### Hub console (`doormile_hub_console`) + +| Screen | File | Reads | Impact | +|---|---|---|---| +| **Inbound** | `src/pages/operations/Inbound.jsx` | `trackingno` | 🟡 Tracking-number format change is visible in the table. Destination cell already ellipsises at `maxWidth: 200px`. | +| **Routing** | `src/pages/operations/Routing.jsx` | `trackingno` | 🟡 Format change visible in the package list and the detail card (monospace, `0.85rem`). Search-by-tracking-number still works — it matches the value the API returns, not a pattern. | +| **Order Assignment** | `src/pages/operations/OrderAssignment.jsx` | `bookingid`, `deliveryaddress` | 🟡 The row id uses the numeric `bookingid`, so the ID-format change does not touch it. But `drop` reads the flat `deliveryaddress`, which is **destination 0 only** — staff assigning a 3-destination pickup see one address. Not broken; incomplete. | +| **Rider Routes** | `src/pages/operations/RiderRoutes.jsx` | `bookingid`, `consignmentid`, `parcels` | 🟡 Reads `consignmentid` off the booking, which names only the **first** order. A rider carrying three parcels from one visit may render as one stop on this screen. | +| **Tracking Map** | `src/pages/operations/TrackingMap.jsx` | `bookingid` | ⚪ Numeric id only. | +| **Dashboard** | `src/pages/Dashboard.jsx` | `parcels` | ⚪ Counts only. Every parcel still hangs off the booking, so the totals stay correct. | +| **Dispatch** | `src/pages/operations/Dispatch.jsx` | `parcels` | ⚪ As above. | +| **Riders** | `src/pages/operations/Riders.jsx` | `parcels` | ⚪ As above. | +| Hub Settings, Login, Signup | — | ⚪ Untouched. | + +### What nobody has to change today + +Multi-destination pickups **only exist once the new customer app is live**. Every +booking in the database today has zero destination rows and therefore takes the +single-leg path everywhere — the rider queue returns one row, the consoles show +one address, and the fan-out never runs. The 🟡 "destination 0 only" rows above +become real on the day the first three-destination pickup is booked, not on the +day this deploys. + +The exceptions that are live immediately: the **ID formats** (§0 items 1–2) and +the extra **JSON fields** (§0 item 6). + +--- + +## 0.6 The rider-app change, scoped properly + +An earlier draft of this document said "key the list on `consignmentid`" as +though that were a small edit. It is not, and the correction came from a review +with access to the Flutter source that this backend repo does not have. Recorded +here in full because the item is assigned to the app team and was understated. + +**The rider app's identity key is `orderid`, not `bookingid`.** The adapter sets +it at `lib/data/api_config.dart:321`: + +```dart +'orderid': ref ?? id, // ref = bookingreference, id = bookingid +``` + +Both of those are **booking-level**, and all three destination rows of one +pickup carry the same `bookingid` and the same `bookingreference`. So `orderid` +is identical across the three — and `orderid` is what dedupes: + +- `lib/data/accepted_store.dart:527` — accepted stops live in a `Map` keyed on + `orderid`. Three stops collapse to one, and because that store is + SharedPreferences-backed, **the collapse survives an app restart.** +- `lib/data/work_repository.dart:314,321` and `order_events.dart` — per-order + event stamps key on `orderid`, so orders 2..N overwrite order 1's clocks. + +`consignmentid` is available to the adapter (`api_config.dart:443`), so the +change is feasible — but it lands across `accepted_store`, `work_repository`, +`order_events` and `assignment_lookup`. + +**Until it ships, a multi-destination pickup shows the rider one stop instead of +three** — the same customer-visible failure §5.1 fixed on the server. The server +being right is not enough here. + +**`destinationseq` / `destinationcount` are invisible to the app today.** +`pickupFromBooking` builds a fixed map, so a field the adapter does not name is a +field the app can never see (the file says as much around line 583). "Stop 2 of +3" needs those two keys added to the adapter as well. + +### The mitigation that needs no app release + +`maxDestinations` is **server configuration**, not a constant — the client reads +it from `GET /customer/config/booking-limits` and adapts. Setting +`customerbookinglimits.maxdestinations = 1` keeps every pickup single-destination +until the rider build lands, which means: + +- the customer app can ship and book end to end today; +- the fan-out code stays dormant, so no order can be stranded; +- lifting the cap later is one `UPDATE`, with no deploy on any side. + +**Recommendation: seed it at 1 and raise it only once a rider build that keys on +`consignmentid` is live.** This is the same discipline +`MILER_HUB_HANDOVER_ENABLED` already applies in this codebase — do not turn on a +server behaviour the deployed rider app cannot complete. + +--- + +## 0.7 Fan-out row audit — field by field + +Requested audit, run against the code and locked down by tests. `GET /miler/bookings` +after collection, one row per order. + +| Field | Source | Destination-specific? | Test | +|---|---|---|---| +| `consignmentid` | `bookingdestinations.consignmentid` | ✅ | `TestFanoutTrackingAndConsignmentIdsStayWithTheirStop` | +| `trackingno` | `bookingdestinations.trackingno` | ✅ | same | +| `deliveryaddress` | built from the destination's building / street / landmark / district / state | ✅ | `TestFanoutStopsHaveDistinctAddresses` | +| `deliverylatitude` | destination pin, else district centre | ✅ | `TestFanoutStopsHaveDistinctCoordinates` | +| `deliverylongitude` | same | ✅ | same | +| `recipientname` | `bookingdestinations.recipientname` | ✅ | `TestFanoutRecipientsStayWithTheirStop` | +| `recipientphone` | `bookingdestinations.recipientphone` | ✅ | same | +| `collectionamt` | consignment `codamount − codcollected`, falling back to the destination's own `codamount` | ✅ **newly added** | `TestFanoutCodIsNotCopiedAcrossDestinations` | +| `destinationseq` | `bookingdestinations.seq` | ✅ | `TestFanoutRouteOrderFollowsDestinationSeq` | +| `destinationcount` | number of stops on the pickup | ✅ uniform per booking | `TestFanoutDestinationCountIsUniform` | + +**Three findings from running this audit:** + +1. **`collectionamt` did not exist.** The endpoint emitted `codamount` only. + Added as an additional key rather than a rename — `codamount` is what this + endpoint has always sent and the deployed rider build parses it, so removing + it would break every stop on every existing device. Both are written from one + variable and cannot drift. + +2. **COD was not on the stop at all.** It was resolved in the handler from the + consignment map, so it could not be unit-proven, and it fell back to **0** if + the consignment row failed to load. A rider shown ₹0 collects nothing. The + stop now carries the destination's own `codamount` as the fallback. + +3. **Stop ordering was not guaranteed.** `milerStopsForBooking` iterated the + slice as given and relied entirely on the caller's `ORDER BY seq`. It worked, + but the ordering *is the route the rider drives*, and leaving it as an + unstated precondition means the next caller silently reorders someone's + afternoon. It now sorts by `seq` on a copy. + +**Booking-level fallback is confined to one place and one case:** a booking with +**no destination rows** — every console/express booking and every row written +before this table existed — produces exactly one stop from the booking's flat +columns. A booking that *has* destination rows never reads them. Destination 0's +coordinates are used as a fallback for stop 0 only, and only when that +destination has no pin of its own, because that is the same row mirrored. + +--- + +## 1. v1 requirements → what actually shipped + +The requirements doc described the target. This is the delta between it and the +code as it now stands. + +### 1.1 Endpoints — all 22 requirement endpoints implemented + +Route arithmetic, since three numbers in this document have to reconcile: +**28 registered customer routes** = 8 pre-auth (4 auth + 4 catalogue) + 20 +authenticated. The 22 below is the count of endpoints the *requirements* asked +for; the remaining 6 are the profile and saved-address routes in the table under +it. The OpenAPI spec now documents all 28 (24 paths / 28 operations). + +| § | Endpoint | Status | Deviation from the requirement | +|---|---|---|---| +| 4.1 | `POST /customer/auth/otp/request` | ✅ | Answers identically whether or not the account exists — telling an anonymous caller "no account found" would make this a directory of who is registered. | +| 4.2 | `POST /customer/auth/signup` | ✅ | Existing phone treated as sign-in, as specified. | +| 4.3 | `POST /customer/auth/otp/verify` | ✅ | Idempotency-Key honoured. | +| 4.4 | `POST /customer/auth/refresh` | ✅ | Rotation. A **revoked** token replayed revokes the whole chain. | +| 4.4 | `POST /customer/auth/logout` | ✅ | No `refreshToken` in the body ⇒ signs out everywhere. | +| 4.4 | `GET /customer/auth/me` | ✅ | — | +| 5.1 | `GET /customer/serviceability/states` | ✅ | + ETag/304. | +| 5.2 | `GET .../states/{code}/districts` | ✅ | + ETag/304. | +| 5.3 | `GET /customer/pickup-slots` | ✅ | Slot ids encode their date (`slot_20260905_t1`) so a stale cached slot resolves to *that* day and is refused, not silently booked today. | +| 5.4 | `GET /customer/config/booking-limits` | ✅ | Accepts `lat`/`lng` for per-city limits. | +| 6.1 | `GET /customer/places/reverse-geocode` | ✅ | On upstream failure returns a coordinate label, **not** an error. | +| 6.2 | `GET /customer/places/search` | ✅ | Empty `q` ⇒ saved + recent places. | +| 7 | `POST /customer/fare/estimate` | ✅ | — | +| 9.1 | `POST /customer/bookings` | ✅ | — | +| 9.2 | `GET /customer/bookings` | ✅ | **Keyset** pagination, not offset. | +| 9.3 | `GET /customer/bookings/{reference}` | ✅ | + ETag/304 for polling. | +| 9.4 | `POST .../cancel` | ✅ | Cancelling an already-cancelled booking returns **200**, not 409. | +| 9.5 | `PATCH .../destinations/{index}` | ✅ | — | +| 9.6 | `GET /customer/orders/{trackingId}` | ✅ | Returns the full booking object (the requirement offered a slimmer shape; the client already parses this one). | +| 10 | `POST /customer/devices` | ✅ | + `DELETE /customer/devices/{token}`. | +| 10 | Push on milestone change | ✅ | `on_the_way` and `order_created` deliberately silent, per §10. | +| 11 | `POST /customer/ops/bookings/{ref}/stage` | ✅ | Double-gated. Walks every intermediate stage rather than jumping. | + +**Also kept** (not in the requirements, not removed): `GET/PUT +/customer/profile`, `GET/POST/PUT/DELETE /customer/locations` — these answer §13.5 +"saved addresses". + +### Identifier scrambling — sequence-backed, but not readable + +Both identifiers come off a Postgres sequence, because a sequence is the only +generator here that can promise uniqueness: the columns are `UNIQUE`, and a +random 8-digit id collides with ~43% probability by the ten-thousandth parcel — +a rider unable to complete a pickup. + +But a raw sequence is readable. `DMX10000042` and `DMX10000043` are visibly +adjacent, so anyone holding two numbers learns the throughput between them, and +anyone holding one can guess its neighbours. So the index goes through a keyed +4-round Feistel permutation before it is formatted +(`controllers/cxIdentifierScramble.go`): + +``` +seq 10004200 -> DMX35031894 seq 100000 -> DM-499505 +seq 10004201 -> DMX31070203 seq 100001 -> DM-341775 +seq 10004202 -> DMX62788115 seq 100002 -> DM-106366 +``` + +A Feistel network is a bijection for **any** round function, so uniqueness is +untouched. `TestBookingScrambleIsCollisionFree` proves it **exhaustively across +all 900,000 booking references**; the tracking test covers 200,000 consecutive +values; `TestFeistelIsAPermutation` checks the primitive itself exhaustively. + +Deliberately **not** the obvious one-liner (multiply by a coprime). That is also +a bijection, but it is *linear* — and a multi-destination pickup hands one +customer three consecutive sequence values, so the differences between their +three tracking numbers would all equal the multiplier. A single booking would +reveal the mapping and make the whole range walkable. +`TestOneBookingDoesNotLeakTheMapping` rejects that design. + +Past the fixed-width range (900,000 bookings / 90,000,000 orders) the identifier +grows a digit rather than wrapping onto one already issued. Uniqueness is never +traded for appearance. + +**This is not a security boundary.** Every route that resolves a tracking number +is already authenticated and owner-scoped — that is what stops a stranger reading +someone's parcel. This removes the leak in the identifier itself, so +authorisation is not the only thing standing between an outsider and your volume +figures. Key: `CX_ID_SCRAMBLE_KEY`; a working default ships so the scrambling can +never be silently off. + +### 1.2 Contract decisions the requirements left open + +| Question | Decision | Consequence for the app | +|---|---|---| +| §3.2 field naming | **camelCase**, as preferred | **No mapping layer needed.** Served from purpose-built projections, so `/miler/*` keeps its lowercase keys. | +| §3.3 timestamps | **Epoch milliseconds, UTC, integer** | `DateTime.fromMillisecondsSinceEpoch(int)` works as-is. | +| §3.1 envelope | Payload in `data` on **every** response, auth included | One parser for the whole surface. | +| §9.6 order shape | Full booking object | ⚠️ **Not "reuse `Booking.fromJson`" as written earlier.** That parser exists but today reads only `reference`, `stage`, `cancellable`, `createdAt`, `pickup`, `slotId` and four destination fields. It ignores `status`, `miler`, `deliveryAgent`, `fare`, `amountPaid`, `deliveredAt`, `history`, `verification`, `details`, per-destination `stage`, `routeKm` and `expectedDelivery`. The server shape is right; the client parser has to be finished. Appendix B of the requirements said so and this document should not have glossed it. | +| §9.3 `miler.phone` | Proxy when `MILER_CALL_PROXY` is set, real number otherwise | **Currently unset ⇒ real number.** See §10.2. | +| Identifier format | Sequence-backed `DM-######` / `DMX########` | Matches the designs. | + +### 1.3 Timestamps — the defect that would have shipped silently + +This database stores **IST wall-clock digits** in its timestamp columns (the DSN +sets `TimeZone=Asia/Kolkata`; `utils.DBNow` exists because of it). Calling +`.UnixMilli()` on a value read back from those columns is **off by 5h30m** — the +same class of fault as a naive local string with a `Z` on it, which already +produced "yesterday's work shown as today" on the Miler app. + +Every timestamp leaving `/customer/*` goes through `utils.EpochMillis`, which +reinterprets the wall clock in IST first. It is correct for **both** shapes the +driver can produce (UTC-tagged-with-IST-digits, and correctly `+05:30`-tagged), +so a future column-type change cannot silently shift the tracking screen. +Asserted in `utils/epoch_test.go`. + +--- + +## 2. The structural change: one pickup → many orders + +`pickupbookings` carried exactly **one** delivery address in its own columns. +There was nowhere to put a second, so the "one visit, three orders" model in §1 +of the requirements was not expressible at all. + +``` +Pickup booking DM-482913 + ├── destination 0 : Chennai, TN · 2 packages + ├── destination 1 : Ernakulam, KL · 1 package + └── destination 2 : Bengaluru Urban, KA· 1 package + │ miler collects everything in ONE visit + ▼ + pickup-complete fans out → 3 consignments, 3 tracking numbers + DMX10482913 DMX10559120 DMX10662004 +``` + +**The compatibility rule that makes this safe — do not break it:** destination 0 +is mirrored onto the booking's flat `delivery*` columns. The Miler app, the hub +console, the routing code and the hyperlocal check all read those columns and +**none of them changed**. A booking with *no* destination rows — every +console-created express booking and every pre-existing row — produces exactly one +consignment through the same loop, byte-for-byte as before. + +Single-destination is one leg, not a special case, in either direction. + +--- + +## 3. File-by-file: what changed and why + +### 3.1 New files (23) + +| File | Lines | Purpose | +|---|---|---| +| `models/customer_app.go` | 241 | The 9 new tables. `BookingDestination` is the one that makes multi-destination expressible. | +| `internal/cxstage/stage.go` | 405 | **The core of §8.3.** Records one append-only stage row per transition, in the caller's transaction. Owns the slowest-order rollup and the `Release` walk-back. | +| `controllers/cxBookingView.go` | 744 | The canonical §9.3 booking object. Every read that returns a booking goes through `renderCxBooking`; `loadCxBundle` batches so a tracking poll is a fixed number of queries. | +| `controllers/cxBookingController.go` | 819 | §9.1–9.6 — create, list, detail, cancel, patch, order-by-tracking-id. | +| `controllers/cxAuthController.go` | 614 | §4 — OTP over phone/email, rotation-backed refresh, E.164 normalisation. | +| `controllers/cxCatalogueController.go` | 516 | §5 — serviceability, capacity-aware slots, limits, ETag support. | +| `controllers/cxPlacesController.go` | 350 | §6 — geocoder proxy + Redis cache. | +| `controllers/cxFareController.go` | 266 | §7 — estimate range, multi-stop uplift. | +| `controllers/cxPickupFanout.go` | 251 | Splits a booking into the journeys created at pickup-complete. Decides single-vs-fan-out; nothing downstream needs to know which. | +| `controllers/cxOpsController.go` | 160 | §11 QA stage override, double-gated. | +| `controllers/cxConsignmentHooks.go` | 100 | Maps a consignment status onto a customer stage for one order. | +| `controllers/cxDeviceController.go` | 85 | §10 push registration. | +| `controllers/cxIdentifiers.go` | 80 | Sequence-backed `DM-######` / `DMX########`. | +| `utils/epoch.go` | 103 | IST→epoch conversion. **Read §1.3.** | +| `utils/response_cx.go` | 97 | The customer envelope. Separate from `utils.OK/Fail` on purpose. | +| `internal/sms/sms.go` | 105 | The seam for an SMS gateway. **No provider is wired in.** | +| `middlewares/requestid.go` | 33 | `X-Request-Id` echo. | +| `seed_customer_app.sql` | 153 | Staging data mirroring the client mock. | +| `controllers/cxHttp_test.go` | 606 | HTTP status-code + envelope contract tests. | +| `controllers/cxCustomerApp_test.go` | 473 | Pure-logic unit tests. | +| `routes/routes_customer_test.go` | 323 | Routing + **the regression guard proving miler/console are untouched.** | +| `internal/cxstage/stage_test.go` | 126 | Stage rollup and cancellation-window tests. | +| `utils/epoch_test.go` | 116 | Timestamp conversion tests. | + +### 3.2 Modified files (20) + +| File | Δ | What changed | Why | +|---|---|---|---| +| `controllers/customerController.go` | +98 / −593 | PIN auth + single-destination booking handlers **deleted**; profile and locations kept, moved to the customer envelope | Replaced by the new surface; the old request shape is no longer a valid booking | +| `controllers/milerController.go` | +593 | `BookingPickupComplete` rewritten for fan-out; `BookingParcelConfirm` accepts photos + writes transactionally; stage hooks added to accept/reached/cancel | §8.3 derivation and the §1 fan-out | +| `controllers/milerAppController.go` | +410 | `MilerGetMyBookings` emits one stop per order after collection; `MilerDeliverConsignment` + `MilerStartDelivery` lookups fixed; stage hooks | **Without this, orders 2..N are undeliverable** — see §5.1 | +| `routes/routes.go` | +82 | Customer routes replaced: 19 → 28 | The new contract | +| `models/booking.go` | +60 | 10 columns on `PickupBooking`, 1 on `BookingParcel`, `Destinations` relation | Storage for the customer projection | +| `constants/constants.go` | +71 | 9 stage keys, 3 statuses, 4 actor types, rank/cancellable helpers | The wire contract; never inline these | +| `migrations/migrate.go` | +55 | 9 tables, 2 sequences, 2 indexes | Schema | +| `internal/storage/spaces.go` | +74 | `PresignGet` | Signed parcel-photo URLs | +| `dto/auth.go` | −41 | Retired customer PIN DTOs removed | Dead after the auth replacement | +| `middlewares/logger.go` | +38 | Logs `client`, `platform`, `requestid` | §3 "accept and log" | +| `middlewares/idempotency.go` | +34 | Anonymous-caller scoping | **Security fix** — see §5.4 | +| `controllers/logisticsHandoverController.go` | +24 | Consignment→booking lookup fixed; `in_transit` recorded | Fan-out correctness | +| `middlewares/city_gate.go` | +18 | Exported `PincodeInOperatingCity` | **Security fix** — see §5.3 | +| `internal/assignment/crm_assignment.go` | +17 | `assigned` recorded on auto-assign | Most B2C bookings get a rider this way | +| `controllers/booking_assignment_service.go` | +16 | `assigned` recorded on manual assign | Console assignment must reach the customer too | +| `config/config.go` | +13 | `GEOCODER_URL`, `GEOCODER_EMAIL` | §6 proxy | +| `utils/helper.go` | +13 | `GenerateTokenWithTTL` | 1h access tokens for customers only | +| `main.go` | +7 | `RequestID()` registered | §3 | +| `controllers/otpController.go` | −128 | **Deleted** | Its two routes were customer-only and are superseded | +| `CLAUDE.md` | +147 | Project memory updated | Continuity | + +--- + +## 4. Stage derivation (§8.3) + +The operational writes already existed and were already correct. What did not +exist was any record of *when* a parcel reached a stage in the vocabulary the +customer sees. A consignment status says where a parcel is **now**; it cannot say +when it got there. + +| Existing Miler write | Customer stage produced | +|---|---| +| `assignMilerTx` / `commitAssignment` | `assigned` | +| `POST /miler/assignments/{id}/accept` | `on_the_way` (re-asserts `assigned`; deduped) | +| `POST /miler/bookings/{id}/reached` | `arrived` — **cancellation closes here**, recorded in the same transaction as the arrival fact so there is no window to cancel through | +| `POST /miler/bookings/{id}/parcel` | populates `verification` (weight per destination + photos) | +| `POST /miler/bookings/{id}/pickup-complete` | `picked_up`, then `order_created` **per destination** | +| `POST /miler/consignments/{id}/inward-at-hub` | `in_transit`, per order | +| `POST /miler/consignments/{id}/start-delivery` | `out_for_delivery`, per order | +| `POST /miler/consignments/{id}/deliver` | `delivered`, per order | +| `POST /miler/bookings/{id}/cancel` | **`Release`**, not cancel | + +**Four rules worth knowing as the app developer:** + +1. **Nothing is backfilled.** A booking that predates this work has a short + history. A short honest history beats a long invented one — the customer + cannot tell which entries were guessed. +2. **A booking rolls up from its SLOWEST order.** Taking the maximum would show + "Delivered" while one of three parcels was still at a hub. +3. **`on_the_way` comes from accept, not the GPS stream.** Deriving it from + location pings means re-deriving on every ping. Distance and ETA are still + live from the rider's Redis position. +4. **`Release` is the one place a stage moves backwards.** A miler cancelling + returns the pickup to the pool. Without walking the stage back, the customer + keeps seeing a rider card for someone who is not coming. The `assigned` / + `on_the_way` history rows stay — those things happened. + +### §8.4 gaps — all five closed + +| Gap in the requirements | Status | +|---|---| +| No per-destination consignment id | ✅ `bookingdestinations.consignmentid` + `trackingno` | +| No COD amount on the booking | ✅ `bookingdestinations.codamount` → `consignments.codamount`. **Per destination**, because COD is collected per delivery | +| No per-stop attribution for stages 6–8 | ✅ Every per-order stage records against its destination | +| No customer-side cancel | ✅ Server-enforced through `arrived` | +| Parcel weight/photos not exposed | ✅ `verification` block, photos as signed URLs | + +--- + +## 5. Bugs found and fixed (not requested — found because the fan-out forced every consignment lookup to be re-read) + +### 5.1 🔴 The rider could never deliver orders 2..N + +`GET /miler/bookings` emitted one row per booking, keyed on +`pickupbookings.consignmentid` — a column that names only the **first** order. +On a three-destination pickup, two parcels would sit in the rider's bag with no +stop, no deliver button and no way to close them. + +**Without this fix the entire multi-destination feature would have created orders +nobody could deliver.** `milerStopsForBooking` now emits one stop per order after +collection (and still exactly one visit before it — the rider goes to the door +once). Tested in `controllers/cxCustomerApp_test.go`. + +### 5.2 🔴 Three endpoints broken for orders 2..N (same root cause) + +- **`MilerDeliverConsignment`** returned `404 "assigned consignment not found"` — + the rider **could not complete the delivery at all**. +- **`MilerStartDelivery`** sent no push and no receiver OTP. +- **`MilerInwardConsignmentAtHub`** left the assignment open (so the rider could + not go off duty — `MilerEndDuty` refuses on an open assignment) and recorded + the leg's distance and earnings as **zero**. + +All three now resolve through `cxDestinationForConsignment`. + +### 5.3 🟠 `CityGateMiddleware` was a no-op for customer bookings + +It sniffs the request body for `pickuppincode`. The new request shape does not +carry one — its pickup is a `title/sub/lat/lng` from the place search — so +**every customer booking sailed past the operating-city gate.** Now checked in +the handler against the pincode resolved from the coordinates; a pickup outside +an operating city returns `422 unserviceable`. + +### 5.4 🔴 Idempotency key collision (introduced by me, caught in review) + +Keys were namespaced by user id. On the **unauthenticated** +`POST /customer/auth/otp/verify` that id is `0` — so two customers who happened +to pick the same `Idempotency-Key` would collide and **the second would be handed +the first's access token, refresh token and customer record.** + +Fixed: anonymous callers are scoped by request path + body hash. The +authenticated key format is left byte-identical, so no in-flight rider key is +orphaned at deploy. + +### 5.9 🟠 Fan-out row audit: three gaps closed + +Found by auditing every row-level field the rider app reads, rather than +assuming the fan-out was complete: + +- **`collectionamt` was never emitted** — only `codamount`. Added alongside it, + not as a rename, so the deployed build keeps working. +- **COD fell back to 0** when a consignment row did not load, instead of to the + destination's own figure. A rider shown ₹0 collects nothing. +- **Stop ordering depended on the caller's `ORDER BY`.** Now guaranteed inside + `milerStopsForBooking`, because the ordering is the route. + +Also hardened at the same time: `CreateCxBooking` now rejects an absurd +destination count **before** any database work (`cxAbsoluteMaxDestinations`), +because the district lookup built a `WHERE districtcode IN (...)` from unbounded +caller input; and `cxDefaultMaxDestinations` dropped from 5 to **1**, so a +missing configuration row resolves to the safest cap rather than the most +permissive — a gate that opens when its config is absent is not a gate. + +### 5.10 🟠 Two different mistakes shared one error message + +`POST /customer/bookings` answered an **empty** `destinations` array with +*"Every destination needs a serviceable state and district"* — the same string +it uses when a destination is present but missing its state or district. + +The contract renders `message` verbatim, so a customer who had added nothing was +told to go and fix details on destinations they did not have. The message +described a problem they did not have and hid the one they did. Both cases also +carried the same `error.code`, so the app could not tell them apart either. + +Now *"Add at least one destination"*, matching what `/customer/fare/estimate` +already said for the identical mistake. `TestEmptyDestinationsSaysAddOneNotFixTheirDetails` +asserts the two endpoints agree and that the message names the actual fix. + +Found by reading the messages rather than the status codes — every one of them +is customer-facing copy, and a 400 being *correct* says nothing about whether it +is *useful*. + +### 5.5 🟡 Pre-existing, flagged not fixed + +`BookingPickupComplete`'s comment says a hyperlocal parcel keeps the rider +`Picked_Up`; the code sets them `Available`. This is the default path today. +Changing it alters live rider availability and the dispatch pool — **ops +decision.** + +--- + +### 5.6 🔴 Rider earnings would have read ₹0 on every customer job (introduced by me, caught while mapping screens) + +`CreateCxBooking` stored the estimate in its own new columns and did **not** +create a `BookingServiceOption` row — but three places still read one: + +- `MilerDeliverConsignment` and `MilerInwardConsignmentAtHub` copy + `Estimatedprice` onto `BookingAssignment.ridercharges` when a leg closes. +- `GET /miler/earnings` sums that column. +- The admin console's Orders list renders `serviceoptions[0].estimatedprice` as + its **Price** column. + +So every customer-app job a rider completed would have recorded **zero earnings** +on their Earnings screen, and the admin Orders list would have shown **"N/A"** +for the price of every customer booking. Fixed: the booking now creates a service +option carrying the midpoint of the band the customer was shown, which is what +`lookupDoormilePrice` returns everywhere else in this codebase. + +### 5.7 🔴 An ops cancellation never reached the customer + +`AdminCancelBooking`, `AdminBulkCancelBookings` and `AdminUpdateBookingStatus` +all cancel by writing `pickupbookings.status` directly. None of them knows the +customer projection exists — and because `customerstatus` is written as +`"active"` at booking time, the projection's empty-string fallback never fired. + +**A customer whose pickup ops cancelled would have kept seeing it as active and +cancellable, indefinitely.** They would also still have received stage +notifications for it. + +Fixed in two places, deliberately: the projection now treats the **operational** +status as the authority on cancellation, which covers all three paths and any +added later; and the two real cancel paths call `cxstage.Cancel` so the customer +gets a reason and the audit trail records which ops user did it. Both are no-ops +for console-created bookings. + +### 5.8 🟠 An expired slot was reported as a full one + +`CreateCxBooking` answered a slot whose window had already passed with +`409 "That pickup window just filled up"`. That is untrue and points the +customer at the wrong recovery — they need to re-fetch the slot list, not retry +for a place in a queue. A client that caches slots for a session and is left open +across midnight hits this on the first booking of the day. + +Now `400 invalid` with *"That pickup time has passed — pick a new slot"*, which +is what the contract already maps to "Pick a pickup slot". A stale **date** is +also now caught by `CxSlotDateIsPast` before any database access, since the slot +id carries its own date — the cheapest validation in the path, and the one that +actually fires. `409` is reserved for the genuine capacity race. + +Found by a reviewer noticing that §5.3 and §7.7 of this document could not both +be comfortable at once. They were right. + +## 6. Tests + +`go build ./...`, `go vet ./...`, `go test ./...` all pass. +**305 passing assertions/subtests**, all runnable with no database. + +| File | What it proves | +|---|---| +| `controllers/cxHttp_test.go` | **Status codes and envelopes through a real router.** Every validation path answers 4xx with a machine-readable `error.code` and customer-safe English — never 500, never a router 404. Malformed JSON is 400. All 9 contract error codes map to the right status. `CxInternal` never leaks the cause. The QA override is invisible unless both gates are open. | +| `routes/routes_customer_test.go` | All **20** authenticated routes exist and return **401 without a token** (not 404, not 500). All refuse roles 1/3/4/5/6 with **403**. Pre-auth routes are reachable and answer from the handler. Retired PIN routes no longer mint a credential. **`TestMilerSurfaceIsUnchanged` / `TestConsoleSurfaceIsUnchanged` are the regression guard for §0.** | +| `controllers/cxCustomerApp_test.go` | Phone normalisation (7 spellings → one stored value, so a build change cannot create a duplicate account); the fan-out stop split; timeline dedup and per-order timestamping; `deliveredAt` waiting for the last parcel; pickup title/sub never empty. | +| `internal/cxstage/stage_test.go` | Slowest-order rollup; stage ordering as a wire contract; the cancellation window closing exactly after `arrived`. | +| `utils/epoch_test.go` | IST→epoch for both driver taggings; null stays null; slot window and day formatting. | + +### What the tests do NOT prove + +**No happy path is asserted.** A `200` from `CreateCxBooking` needs Postgres, +Redis and NATS. Mocking them would test the mock. Everything from the handler +inwards is still unverified — that is the integration pass in §11. + +--- + +## 7. Answers to the requirements' §13 open questions + +*(“§13” here and in §9 means section 13 of the requirements document. Section 13 +of THIS document, at the bottom, is the review log.)* + +**1. Payment.** v1 is cash/UPI at the door, settled outside the app. No payment +step exists and there is no payment contract to hand you. `amountPaid` is the sum +of `bookingpayments` rows the miler recorded, in whole rupees, present from +`picked_up`. Two different pots of money: the **pickup fee** is Doormile's, +collected once per visit and recorded against the first order; **COD at the +destination** is the customer's own collection, per destination. Doormile is the +carrier, never the seller. ⚠️ *No settlement/remittance flow exists — money can be +collected and recorded, nothing pays it back to the customer.* + +**2. Masked calling.** Implemented as a switch: `MILER_CALL_PROXY` returns a proxy +when set, the rider's real number otherwise. **Recommendation: set it before +launch.** A customer handed a rider's personal mobile has it permanently. +⚠️ *Currently unset.* + +**3. Partial pickup.** Not modelled, not guessed at. Pickup-complete is +all-or-nothing. The fan-out makes it *expressible* (legs are built per +destination), but there is no stage key, no miler endpoint and no screen. +⚠️ *Product decision needed.* + +**4. Failed delivery / reattempt.** Exists operationally — +`POST /miler/consignments/{id}/skip` increments `attemptcount` and leaves the +parcel `Out_for_Delivery`. **Deliberately not mapped to a customer stage**: there +is no key for it in the nine, and an unknown key renders as `booked` on the +client. So today a failed attempt is **invisible to the customer** — their parcel +stays "Out for delivery". ⚠️ *This is the most customer-visible gap in v1. Needs a +tenth stage key + a client release.* + +**5. Saved addresses / notification preferences / payment methods.** Saved +addresses **built** (capped at 10, same `title`/`sub` shape as a place search +result). Notification preferences **not built**. Payment methods **not built**, +correctly, while v1 settles at the door. + +**6. Support.** Not built for the customer. `/miler/support` exists and the shape +would extend. ⚠️ *A phone number is the cheapest thing that is not a dead end.* + +**7. Slot capacity semantics.** **Per zone.** A window's remaining capacity counts +live bookings whose `preferredpickupfrom` falls inside it, within 12km of the +pickup point, excluding cancelled and completed. The list read is advisory and +cached 30s; capacity is **re-checked at confirm** and a lost race returns `409`. +**The client does not need to re-fetch before confirming.** + +**8. Pricing.** Reuses the existing `doormile_pricing` slab (zone × service type × +weight band → min/max), with a distance-and-weight fallback for unpriced lanes. +Additional destinations carry a **+35% uplift each**. Weight assumed 3kg/package +until weighed. ⚠️ *The 35% is a placeholder, and there is **no breakdown when +`amountPaid` exceeds `fare.max`** — the receipt can show `amountPaid − fare.min` +as a weight adjustment, but nothing explains an overage. Product owns both.* + +**9. Cancellation fee.** **Confirmed free through `arrived`.** No fee is computed, +charged or recorded anywhere in the cancel path. The UI copy is accurate. From +`picked_up` onward the server returns `409`. + +**10. Retention.** **Not implemented — no policy exists to implement.** Parcel +photos stored indefinitely, PII stored indefinitely, `bookingstageevents` never +pruned. The 30-minute signed-URL TTL limits *link* lifetime, not object lifetime. +⚠️ *This is the answer most likely to matter to a regulator and it is currently +blank.* + +**11. Tenancy.** **Logistics only in v1.** Customer JWTs carry `tenantid: 0` and +B2C bookings leave `pickupbookings.tenantid` null. The customer app does not +switch mode on `tenantid` the way the Miler app does. ⚠️ *Decide before revenue +reporting depends on it.* + +--- + +## 8. Non-functional (§3.5) + +- **Idempotency** — `POST /customer/bookings` and `POST /customer/auth/otp/verify`. + 24h replay. +- **Caching** — serviceability `ETag`/`If-None-Match` → 304, `max-age=300`. Slots + `max-age=30`. Booking detail supports 304 (it is polled). +- **Rate limits** — OTP ≤5 per identifier per hour, ≤3 verify attempts per code, + 30s resend cooldown that does **not** consume one of the five. Plus the shared + 10/minute per-IP credential budget. +- **Pagination** — `?limit=&cursor=`, default 20, max 50. **Keyset**, so a new + booking landing mid-scroll cannot show the same row twice. `total` reflects the + filtered tab. +- **PII** — phone masked in SMS logs; parcel photos as signed URLs. +- **Audit** — actor type, actor id, source endpoint and timestamp on every + transition. +- **Tenancy** — every customer read is scoped by `appcustomerid` in the query + itself, never by a check afterwards. +- ⚠️ **Latency is UNMEASURED.** Reads are batched (`loadCxBundle` is a fixed query + count regardless of page size) and pricing is Redis-warmed — but p95 ≤ 400ms is + an argument, not a measurement. + +--- + +## 9. Deliverables (§11) + +| Asked for | Status | +|---|---| +| OpenAPI 3.1 spec | ✅ `openapi-customer.yaml` — 24 paths, 28 operations, validated. Covers every registered route including profile and saved addresses. | +| Postman collection | ❌ Not produced. The OpenAPI file imports directly into Postman. | +| Staging seed mirroring the mock | ✅ TN/KL/KA open, PY serviceable with no open district, Madurai/Kozhikode/Mangaluru unavailable with reasons, slot `t5` at capacity 0 | +| Test accounts + fixed staging OTP | ✅ Two accounts seeded; `CX_STAGING_OTP=1234`, refused when `ENV=production` | +| Force a booking to any stage | ✅ Double-gated; walks every intermediate stage | +| Documented error responses | ✅ Implemented verbatim; asserted in tests | +| Error injection | ❌ Not built. Happy-path states are forceable; failure states are not. | +| Written answer to the requirements' §13 | ✅ §7 above | + +--- + +## 10. Environment variables + +```bash +GEOCODER_URL=https://nominatim.openstreetmap.org # place search / reverse geocode upstream +GEOCODER_EMAIL=ops@doormile.com # Nominatim policy requires a contact +MILER_CALL_PROXY= # ⚠️ empty exposes the rider's real number +CX_STAGING_OTP=1234 # refused when ENV=production +CX_ID_SCRAMBLE_KEY= # keys the identifier permutation; a working default ships +CX_ALLOW_STAGE_OVERRIDE=false # also requires ENV != production +``` + +--- + +## 11. Before this goes to production — read this + +**Blockers, stated plainly:** + +1. 🔴 **No SMS provider exists.** `internal/sms` is the seam (a `Sender` + interface, a logging sink, `sms.Register()`). Until a gateway is plugged in, + **OTP codes go to the application log and nowhere else.** Real customer + sign-in is blocked on this. It is the same wall the previous end-to-end test + hit. `sms.Configured()` reports it. +2. 🔴 **No integration test has touched any of this.** The 305 passing tests cover + pure logic, routing, status codes and envelopes. Nothing has run against a + real Postgres, Redis or NATS. **A clean compile says the code is well-formed, + not that it works.** +3. 🟠 **The migration has not run against a real database.** It is additive + (`AutoMigrate` + `CREATE SEQUENCE`/`CREATE INDEX IF NOT EXISTS`) and Postgres + 11+ adds nullable/defaulted columns without a table rewrite — but *should be + safe* is not *has been observed*. Run it on staging first and watch + `pickupbookings`. +4. 🟠 **`MILER_CALL_PROXY` is unset**, so the tracking screen will show riders' + personal mobile numbers. +5. 🟠 **No retention policy** for parcel photos or PII. +6. 🟡 **Failed delivery is invisible to the customer** (§7.4). +7. 🟡 **Latency unmeasured** (§8). +8. 🔴 **Multi-destination pickups must stay capped at 1 destination** until a + rider build keying on `consignmentid` ships (§0.6). The cap is a database + value, not code — verify it, do not assume it. + +**Suggested deploy order:** + +1. Staging: deploy, let `AutoMigrate` run, apply `seed_customer_app.sql`, set + `CX_STAGING_OTP=1234`. +2. **Confirm `maxdestinations = 1`** in `customerbookinglimits` (the seed sets + it). This is the gate that keeps the fan-out dormant until the rider app can + handle it — see §0.6. Nothing else prevents a stranded parcel. + + ```sql + SELECT applocationid, maxpackages, maxdestinations FROM customerbookinglimits; + ``` +3. Smoke the customer surface end to end — this is the pass that has never + happened. +4. **Regression-test the Miler app against staging**, specifically: pickup-complete + on a single-destination booking, deliver, inward-at-hub, and off-duty. Those + are the paths §0 items 3–5 and §5.2 touch. +5. Plug in the SMS gateway before any production customer traffic. +6. **Only after a rider build keying on `consignmentid` is live**, raise the cap + and test a multi-destination pickup — the genuinely new path: + + ```sql + UPDATE customerbookinglimits SET maxdestinations = 5 WHERE applocationid IS NULL; + ``` + +--- + +## 11.5 Client-side prerequisites — none of §12 works until these land + +§12 below is a correct set of instructions for a client that can make requests. +Today it cannot, and these are outstanding on the app side (all four are in +Appendix B of the requirements, and this document should have carried them +forward rather than assuming them done): + +1. **`pubspec.yaml` has no `http` and no `shared_preferences`.** Every rule about + headers, idempotency keys and token rotation presumes a network layer that is + not yet added. +2. **`Booking.fromJson` parses roughly a third of §9.3.** See the §9.6 row above. +3. **`PickupSlot.fromJson` drops `tag`, `milersNearby` and `caption`.** The + server sends all three; the picker will not show any of them. +4. **`AppState.loadSlots` caches for the whole session and `startBooking` clears + only `districtCache`** (`lib/state/app_state.dart:89`). An app left open + across midnight books against yesterday's slot. The server now answers that + with `400` and *"That pickup time has passed — pick a new slot"* rather than + the misleading "just filled up" it used to (§5.8) — but the client fix is to + clear `slotsCache` in `startBooking` so the customer never reaches that error. + +--- + +## 12. Quick reference for the app developer + +- **Base:** `https://api.doormile.com/api/v1` +- **Auth:** `Authorization: Bearer ` on everything except + `/customer/auth/otp/request`, `/auth/signup`, `/auth/otp/verify`, + `/auth/refresh`, and the four catalogue reads. +- **Access token 1h, refresh 60 days, rotated on every use.** A revoked refresh + token replayed kills the whole chain — always store the newest one. +- **Send `X-Client: doormile-cx/+` and `X-Platform: android|ios`.** + They are logged and are the only way to tell one build's failures from another's. +- **Send `Idempotency-Key` on booking create and OTP verify.** +- **Every timestamp is epoch milliseconds, UTC, integer.** +- **Every string in `message` is safe to render verbatim.** Branch on + `error.code`, never on the message text. +- **`pickup`, `slotId`, `destinations[].stateName` and `districtName` are never + null.** The rest of `details` may be absent — that is the "Not added" state, not + an error. +- **`trackingId` and `stage` on a destination are null until `order_created`.** + There is no order to track before the parcels are collected. +- **An unknown stage key renders as `booked`.** If you see that on a moving + parcel, the backend sent a key this build does not know — tell us; it means a + new stage shipped without a client release. + +--- + +## 13. Review log + +This document has been reviewed once, by a reviewer with access to the two +Flutter apps but **not** to this Go backend. That split matters and is why the +review was useful: every claim it made about the apps was checkable by them and +not by me, and every claim about this repo was checkable by me and not by them. +The findings below are recorded verbatim in substance, with what was done about +each. + +**Standing caveat the reviewer stated and this document repeats:** the 305 tests, +the fan-out, the idempotency fix and the epoch conversion are unverified from +their side. Nothing in §11 has changed — this code has still never run against a +real database. + +### 13.1 Findings — all upheld + +| # | Finding | Status | What changed | +|---|---|---|---| +| 1 | **The rider-app action item is materially understated.** The app's identity key is `orderid` (`api_config.dart:321`, `'orderid': ref ?? id`), not `bookingid`. Both are booking-level, so all three fan-out rows collapse — in `accepted_store.dart:527` (SharedPreferences-backed, survives restart) and in `work_repository.dart:314,321` / `order_events.dart`. Keying on `consignmentid` touches four files. | **Upheld.** Consistent with what this server sends: the three rows share `bookingid` *and* `bookingreference`. | New **§0.6** replaces the one-line framing. §0 item 3 now points at it. Mitigation added: `maxdestinations` seeded at **1**, and a deploy-checklist step to verify it. | +| 2 | `destinationseq` / `destinationcount` are invisible — `pickupFromBooking` builds a fixed map, so a field the adapter does not name cannot reach the app. | **Upheld.** | Recorded in §0.6. Adding fields server-side does not help until the adapter names them. | +| 3 | **"Reuse `Booking.fromJson`" is optimistic.** It parses only `reference`, `stage`, `cancellable`, `createdAt`, `pickup`, `slotId` and four destination fields — ignoring `status`, `miler`, `deliveryAgent`, `fare`, `amountPaid`, `deliveredAt`, `history`, `verification`, `details`, per-destination `stage`, `routeKm`, `expectedDelivery`. | **Upheld, and it was avoidable** — Appendix B of the requirements said exactly this and this document glossed it. | §1.2's §9.6 row rewritten to state the gap instead of assuming the parser. | +| 4 | **Slot staleness is a trap handed to the client.** §5.3 and §7.7 could not both be comfortable: `AppState.loadSlots` caches for a session, `startBooking` clears only `districtCache` (`app_state.dart:89`), so an app open across midnight books yesterday's slot. | **Upheld — and it exposed a server defect.** The server answered `409 "That pickup window just filled up"`, which is untrue and points at the wrong recovery. | **Code fixed** (§5.8): now `400` with *"That pickup time has passed — pick a new slot"*, plus a new DB-free `CxSlotDateIsPast` guard that runs before any query. Test added. Client-side fix (clear `slotsCache`) recorded in §11.5. | +| 5 | §12's header/idempotency/rotation rules assume a client that can make requests; `pubspec.yaml` has no `http`, no `shared_preferences`. | **Upheld.** | New **§11.5** lists the client prerequisites ahead of §12. | +| 6 | `PickupSlot.fromJson` drops `tag`, `milersNearby`, `caption` — the server now sends all three. | **Upheld.** | §11.5 item 3. | +| 7 | §3.2 contains a literal unfilled placeholder: `customerController.go │ −691/+?`. | **Upheld.** | Corrected to the real churn, **+98 / −593**. | +| 8 | The counts do not reconcile: 19→28 routes, 8 exempt from auth ⇒ 20 authenticated, but §6 asserts 22. | **Upheld.** Verified: 28 registered = 8 pre-auth + **20** authenticated; the routing test has exactly 20 entries. | §6 corrected to 20. §1.1 now states the arithmetic explicitly so the three numbers reconcile on the page. | +| 9 | The OpenAPI file's 22 operations imply `/customer/profile` and `/customer/locations` are absent, though §1.1 relies on them to answer §13.5. | **Upheld, and the worst of the three** — the app developer works from the spec and would not have found saved addresses. | **6 endpoints added to the spec** with `SavedAddress` / `SavedAddressInput` schemas. Now **24 paths / 28 operations**, matching the 28 registered routes exactly. | + +### 13.2 What the review confirmed as correct + +Recorded because it is the half that did not change, and it is the half the app +is built against: + +- camelCase throughout — no mapping layer needed on the client. +- Epoch-millis UTC — `DateTime.fromMillisecondsSinceEpoch` works as-is. +- Payload always in `data`, auth included. +- `pickup`, `slotId`, `stateName`, `districtName` non-null — the client types + these non-nullable and would throw otherwise. +- "An unknown stage key renders as `booked`" is accurate against `models.dart` + (`JourneyStage.fromKey` falls back to `booked`). +- The §1.3 IST timestamp defect is real and is the bug that already bit the + Miler app. +- §8.4's five gaps match independently. +- The two reverts — rider availability, and the 404→403 change — were the right + call. +- §11's honesty about what is unverified was called the most useful section here. + It has not been softened. + +### 13.3 Reviewer's verdict, unedited in substance + +> The backend contract is sound and answers the requirements; the impact +> assessment is accurate in direction but under-scopes the rider-app work, and +> its "reuse the existing parser" claims describe the client we intend to have, +> not the one in the repo. Nothing here contradicts the requirements doc. + +Both criticisms are now addressed in the text: §0.6 for the rider-app scope, +§11.5 and the §9.6 row for the parser claims. + +### 13.4 What still cannot be checked from either side + +- Nobody has run this against a real Postgres, Redis or NATS. +- The reviewer could not verify the Go changes; this document's author could not + open the Flutter apps. **Nothing in §0.6 or §11.5 has been executed** — those + file and line references come from the review, not from this repo. +- The single integration pass in §11 remains the thing that would close both + gaps at once. diff --git a/docs/openapi-customer.yaml b/docs/openapi-customer.yaml new file mode 100644 index 0000000..800e093 --- /dev/null +++ b/docs/openapi-customer.yaml @@ -0,0 +1,1495 @@ +openapi: 3.1.0 + +info: + title: Doormile — Customer App API + version: "1.0.0" + description: | + The `/customer/*` namespace consumed by the doormile_cx Flutter app. + + **A customer books a pickup, not a shipment.** One booking has 1..N + destinations; each destination becomes its own order, with its own tracking + number and its own journey, when the miler completes the pickup. No tracking + number exists at booking time — `POST /customer/bookings` returns a + reference only. + + **Weight is never collected from the customer.** The miler weighs and + photographs each package at the door, and that is when the price settles. + Everything shown before then is an estimate range. + + **Doormile is the carrier, not the seller.** Any money at the destination + door is the customer's own collection, made on their behalf. + + Every response — auth included — puts its payload in `data`. Timestamps are + UTC epoch milliseconds. Money is whole rupees, never paise. + + Implementation notes, decisions and the answers to the open questions: + `docs/customer-app-api.md`. + +servers: + - url: https://api.doormile.com/api/v1 + description: Production + - url: https://staging-api.doormile.com/api/v1 + description: Staging — same contract, seeded to mirror the client mock + +tags: + - name: Auth + description: 4-digit code to a phone or an email address. No password anywhere. + - name: Catalogue + description: Serviceability, pickup slots and limits. Drives the whole booking form. + - name: Places + description: Geocoding, proxied — the app is never handed a map key. + - name: Fare + description: Estimate range for one pickup visit. + - name: Bookings + description: Create, list, track, cancel and complete the details of a pickup. + - name: Devices + description: Push registration. + - name: Account + description: Profile and saved addresses. Answers §13.5 of the requirements. + - name: Ops + description: Staging-only QA support. + +security: + - bearerAuth: [] + +paths: + + # ── §4 Auth ──────────────────────────────────────────────────────────────── + + /customer/auth/otp/request: + post: + tags: [Auth] + summary: Send a sign-in code + security: [] + description: | + Answers identically whether or not the identifier has an account. + Telling an anonymous caller "no account found" would turn this endpoint + into a directory of who is registered. + + `sent: false` with a `resendAfterSeconds` is not an error — the caller + is inside the 30-second cooldown and the screen already renders a + countdown. It does not consume one of the five hourly codes. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [identifier] + properties: + identifier: + type: string + description: E.164 phone or email. `+91 98765 43210` with spaces is also accepted and normalised. + examples: ["+919876543210", "you@example.com"] + responses: + "200": + description: Code sent, or the caller is inside the cooldown + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + required: [sent, resendAfterSeconds, codeLength] + properties: + sent: { type: boolean } + resendAfterSeconds: + type: integer + description: Server-driven. The client stops hardcoding 30. + examples: [30] + codeLength: { type: integer, examples: [4] } + "400": { $ref: "#/components/responses/Invalid" } + "429": { $ref: "#/components/responses/RateLimited" } + + /customer/auth/signup: + post: + tags: [Auth] + summary: Create the account and send the code + security: [] + description: | + An existing phone number is **not** an error: it is treated as a sign-in + and a code is sent. The app has no "account already exists" screen, and + inventing one would strand a returning customer who tapped Sign up out + of habit. + + The account row is created here, but no session exists until the code + comes back — an abandoned signup leaves a row and nothing else. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, phone] + properties: + name: { type: string, minLength: 2, examples: ["Joe Oommen"] } + phone: { type: string, examples: ["+919876543210"] } + email: { type: string, examples: ["joe@example.com"] } + responses: + "200": + description: Account ready and code sent + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + properties: + sent: { type: boolean } + resendAfterSeconds: { type: integer } + "400": + description: Validation failed. `invalid_name` when the name is under two characters. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "403": { $ref: "#/components/responses/Forbidden" } + "429": { $ref: "#/components/responses/RateLimited" } + + /customer/auth/otp/verify: + post: + tags: [Auth] + summary: Exchange a code for a session + security: [] + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + description: | + A code is single-use and dies after three wrong attempts. Verifying a + phone with no account behind it completes a signup, and `name` is + required in that case. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [identifier, code] + properties: + identifier: { type: string } + code: { type: string, examples: ["4821"] } + name: + type: string + description: Required only when this verify is completing a signup. + responses: + "200": + description: Signed in + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Session" } + "401": + description: "`invalid_otp` — wrong or expired code" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "403": { $ref: "#/components/responses/Forbidden" } + + /customer/auth/refresh: + post: + tags: [Auth] + summary: Rotate the session + security: [] + description: | + Rotation, not reuse: the presented token is revoked and a new pair + issued, so a token captured from an old device stops working the moment + the real device refreshes. A **revoked** token coming back revokes the + customer's whole session chain — a stale client and a stolen one are + indistinguishable, and killing the chain costs the honest customer one + sign-in and costs an attacker the session. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [refreshToken] + properties: + refreshToken: { type: string } + responses: + "200": + description: New access/refresh pair + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Session" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /customer/auth/logout: + post: + tags: [Auth] + summary: Sign out + description: | + Revokes the refresh token and unregisters the device's push token, so a + signed-out phone stops receiving another person's parcel updates. With + no `refreshToken` supplied it signs out everywhere, rather than leaving + a session the customer believes they ended. + requestBody: + content: + application/json: + schema: + type: object + properties: + refreshToken: { type: string } + deviceToken: { type: string } + responses: + "200": + description: Signed out + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + properties: + signedOut: { type: boolean } + "401": { $ref: "#/components/responses/Unauthorized" } + + /customer/auth/me: + get: + tags: [Auth] + summary: The signed-in customer, for cold-start session restore + responses: + "200": + description: The customer + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Customer" } + "401": { $ref: "#/components/responses/Unauthorized" } + + # ── §5 Catalogue ─────────────────────────────────────────────────────────── + + /customer/serviceability/states: + get: + tags: [Catalogue] + summary: States a destination may be sent to + security: [] + description: | + `districtCount` counts **available** districts only; the client hides + any state showing 0. A state with nothing open is still returned, so it + can carry its "Opening soon" transit tag. + + An empty list is a legitimate answer — the app has a designed no-service + state for it. + parameters: + - $ref: "#/components/parameters/IfNoneMatch" + responses: + "200": + description: Serviceable states + headers: + ETag: { schema: { type: string } } + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ListEnvelope" + - type: object + properties: + data: + type: array + items: + type: object + required: [code, name, districtCount] + properties: + code: { type: string, examples: ["TN"] } + name: { type: string, examples: ["Tamil Nadu"] } + districtCount: { type: integer, examples: [6] } + transitTag: + type: string + maxLength: 22 + examples: ["Ultra-fast transit"] + "304": { description: Unchanged since the caller's ETag } + + /customer/serviceability/states/{stateCode}/districts: + get: + tags: [Catalogue] + summary: Districts in a state, unavailable ones included + security: [] + description: | + Unavailable districts are returned deliberately. The picker filters them + out, but the app shows their names in a quiet "coming soon" line — + omitting them would delete real copy from the screen. `note` says why + one is closed, so the app never has to invent a reason. + parameters: + - name: stateCode + in: path + required: true + schema: { type: string } + examples: { tamilNadu: { value: "TN" } } + - $ref: "#/components/parameters/IfNoneMatch" + responses: + "200": + description: Districts + headers: + ETag: { schema: { type: string } } + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ListEnvelope" + - type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/District" } + "304": { description: Unchanged since the caller's ETag } + "404": + description: Unknown or retired state + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /customer/pickup-slots: + get: + tags: [Catalogue] + summary: Pickup windows offered at a location + security: [] + description: | + Capacity- and location-aware. The id resolves to a real + pickup window on the booking, which is what the assignment engine + consumes — a slot the customer can pick is a slot ops can staff. + + Windows already past, or starting within 45 minutes, are **not returned + at all** rather than returned as unavailable: a greyed-out 8am slot at + 6pm is noise. At most one slot carries `tag`, and only when it can + actually be booked. + + The list is advisory and cached 30s; capacity is re-checked at confirm + time and a lost race returns `409`. + parameters: + - { name: lat, in: query, schema: { type: number }, description: Pickup point latitude } + - { name: lng, in: query, schema: { type: number }, description: Pickup point longitude } + responses: + "200": + description: Available windows, roughly today and tomorrow + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ListEnvelope" + - type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/PickupSlot" } + + /customer/config/booking-limits: + get: + tags: [Catalogue] + summary: Caps on a single pickup + security: [] + description: | + Nothing in the UI hardcodes these; they live server-side so ops can vary + them by city without an app release. Keyed off the pickup location when + one is supplied, so the client can re-fetch when the pickup point moves. + Never returns 0 for either — a zero cap would reject every booking on the + platform. + parameters: + - { name: lat, in: query, schema: { type: number } } + - { name: lng, in: query, schema: { type: number } } + responses: + "200": + description: Limits + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + required: [maxPackages, maxDestinations] + properties: + maxPackages: { type: integer, examples: [20] } + maxDestinations: { type: integer, examples: [5] } + + # ── §6 Places ────────────────────────────────────────────────────────────── + + /customer/places/reverse-geocode: + get: + tags: [Places] + summary: Turn device coordinates into a pickup label + description: | + Proxied, never keyed — the legacy rider app shipped a Maps key in the + binary and it had to be revoked, so the customer app is handed results + rather than credentials. + + On an upstream failure this returns a coordinate-derived label rather + than an error: the coordinates are what the rider navigates to, the text + is what the customer reads, and they can edit it. A blocked booking form + would be worse. + parameters: + - { name: lat, in: query, required: true, schema: { type: number } } + - { name: lng, in: query, required: true, schema: { type: number } } + responses: + "200": + description: The place + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Place" } + "400": { $ref: "#/components/responses/Invalid" } + + /customer/places/search: + get: + tags: [Places] + summary: Search for a pickup point + description: | + An empty `q` is not an error: the search sheet opens on it and is + answered with the customer's own saved and recently used places + (at most 4). Results are biased to `lat`/`lng`. + parameters: + - { name: q, in: query, schema: { type: string } } + - { name: lat, in: query, schema: { type: number } } + - { name: lng, in: query, schema: { type: number } } + responses: + "200": + description: Matches, or recent places when `q` is empty + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ListEnvelope" + - type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Place" } + + # ── §7 Fare ──────────────────────────────────────────────────────────────── + + /customer/fare/estimate: + post: + tags: [Fare] + summary: Estimate the whole pickup + description: | + An estimate **range**, never a final price — the miler weighs each + package at the door and that is when the price settles. Called on every + route and package-count change, so it is cheap and cacheable. + + The result is the combined price for one visit, including the multi-stop + uplift for additional destinations. A failure here must not block a + booking; the client swallows it. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [destinations] + properties: + pickup: + type: object + properties: + lat: { type: number } + lng: { type: number } + destinations: + type: array + minItems: 1 + items: + type: object + required: [stateCode, districtCode, packageCount] + properties: + stateCode: { type: string, examples: ["TN"] } + districtCode: { type: string, examples: ["TN-MAA"] } + packageCount: { type: integer, minimum: 1 } + responses: + "200": + description: The estimate + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/FareEstimate" } + "400": { $ref: "#/components/responses/Invalid" } + + # ── §9 Bookings ──────────────────────────────────────────────────────────── + + /customer/bookings: + post: + tags: [Bookings] + summary: Create the pickup + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + description: | + Returns a `reference` (`DM-######`) and **no tracking number on any + destination** — those are minted per destination when the miler + completes the pickup. + + Only `stateCode`, `districtCode` and `packageCount` are required per + destination. Street, building, landmark, recipient and instructions are + optional and may be completed later by the customer + (`PATCH .../destinations/{index}`) or by the miler at the door. The UI + renders a missing one as "Not added — the Miler can confirm this at + pickup". + + `estimate` is what the customer was shown on Review. It is stored + verbatim for dispute audit and kept on the booking forever, including + after settlement — the receipt renders `amountPaid − fare.min` as the + weight adjustment. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [pickup, slotId, destinations] + properties: + pickup: + type: object + required: [title, sub, lat, lng] + properties: + title: { type: string, maxLength: 32, examples: ["12 Nehru Street"] } + sub: { type: string, examples: ["Gandhipuram, Coimbatore 641012"] } + lat: { type: number } + lng: { type: number } + slotId: { type: string, examples: ["slot_20260905_t1"] } + destinations: + type: array + minItems: 1 + items: + type: object + required: [stateCode, districtCode, packageCount] + properties: + stateCode: { type: string } + districtCode: { type: string } + packageCount: { type: integer, minimum: 1 } + details: { $ref: "#/components/schemas/DeliveryDetailsInput" } + estimate: + type: object + properties: + min: { type: integer } + max: { type: integer } + responses: + "201": + description: | + The full booking, with `stage: "booked"`, `status: "active"`, + `cancellable: true`, a one-entry history and no `trackingId`. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Booking" } + "400": + description: | + Validation failed. The `message` is shown verbatim — e.g. "Every + destination needs a serviceable state and district", "Pick a pickup + slot", "Up to 20 packages per pickup", "Up to 5 destinations per + pickup". + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "409": + description: "`conflict` — that pickup window just filled up" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: "`unserviceable` — a district closed, or the pickup point is outside an operating city" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + get: + tags: [Bookings] + summary: List the customer's pickups, newest first + description: | + Backs the Orders tabs, Home's recent list and pull-to-refresh. + **Keyset** pagination, not offset: an offset drifts when a new booking + lands mid-scroll and shows the customer the same row twice. + parameters: + - name: status + in: query + schema: { type: string, enum: [active, completed, cancelled] } + - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 50 } } + - { name: cursor, in: query, schema: { type: string }, description: "`nextCursor` from the previous page" } + responses: + "200": + description: A page of bookings + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ListEnvelope" + - type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Booking" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /customer/bookings/{reference}: + get: + tags: [Bookings] + summary: The canonical booking object + description: | + The tracking screen and the receipt are both rendered from this, and it + is polled while tracking is open. Supports `If-None-Match` → `304`, so + most of those polls are a header exchange. + parameters: + - $ref: "#/components/parameters/Reference" + - $ref: "#/components/parameters/IfNoneMatch" + responses: + "200": + description: The booking + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Booking" } + "304": { description: Unchanged since the caller's ETag } + "404": { $ref: "#/components/responses/NotFound" } + + /customer/bookings/{reference}/cancel: + post: + tags: [Bookings] + summary: Cancel the whole pickup + description: | + Cancels every destination — there is no partial cancellation in v1. + + Allowed up to and including `arrived`; from `picked_up` onward returns + `409`. `cancellable` on the booking mirrors the same policy so the UI can + hide the button, but **the server is the authority and re-checks** — the + button state is rendered from a response that may be seconds old. + + Free of charge in that window; no fee is computed or recorded anywhere. + + Cancelling a booking that is already cancelled returns `200`, not a + conflict: the customer asked for a state it is already in, and a retry + over a flaky network must not read as a failure. + parameters: + - $ref: "#/components/parameters/Reference" + requestBody: + content: + application/json: + schema: + type: object + properties: + reason: + type: string + description: | + Optional. Free text, or one of the app's five presets: + Booked by mistake · Package not ready · Sending it another + day · Changed the destination · Other. + responses: + "200": + description: Cancelled + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + properties: + reference: { type: string } + status: { type: string, const: cancelled } + cancelReason: { type: [string, "null"] } + "404": { $ref: "#/components/responses/NotFound" } + "409": + description: "`conflict` — this pickup can no longer be cancelled" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /customer/bookings/{reference}/destinations/{index}: + patch: + tags: [Bookings] + summary: Fill in a destination's details after booking + description: | + Any subset of fields. An omitted key leaves the stored value alone; an + explicit `null` clears it, which is how the customer removes a landmark + they no longer want the rider to use. + + Accepted until the parcels are collected; from `picked_up` onward + returns `409`, because after that the shipment's addresses are frozen on + the consignment and an edit would change what the customer sees without + changing where the parcel goes. + + The write lands on the same record the miler app reads addresses from, + so a correction made while the rider is en route reaches them. + parameters: + - $ref: "#/components/parameters/Reference" + - name: index + in: path + required: true + schema: { type: integer, minimum: 0 } + description: 0-based position of the destination within the booking. Stable for its lifetime. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DeliveryDetailsInput" } + responses: + "200": + description: The updated booking + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Booking" } + "404": { $ref: "#/components/responses/NotFound" } + "409": + description: "`conflict` — the packages have been collected" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /customer/orders/{trackingId}: + get: + tags: [Bookings] + summary: One order by tracking number + description: | + For push deep links (`doormile://track/DMX10482913`). Returns the full + booking object focused on that order — the client already parses this + shape, and a second shape for the same data is a second parser to keep + in step. + + A tracking number belonging to another customer answers `404`, not + `403`: confirming that a tracking number is real tells an enumerating + caller something they should not learn. + parameters: + - name: trackingId + in: path + required: true + schema: { type: string } + examples: { order: { value: "DMX10482913" } } + responses: + "200": + description: The booking that owns this order + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Booking" } + "404": { $ref: "#/components/responses/NotFound" } + + # ── §10 Devices ──────────────────────────────────────────────────────────── + + /customer/devices: + post: + tags: [Devices] + summary: Register a push token + description: | + One row per device token, not one column per customer — a phone and a + tablet both have to receive the delivery notification. + + A token that already exists is **reassigned** to the calling customer. + A shared handset, or one customer signing out and another in, is the only + case that matters here, and reassignment is the only outcome that does + not send one person's parcel updates to another person's phone. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: { type: string } + platform: { type: string, enum: [android, ios] } + appVersion: { type: string, examples: ["1.0.0+12"] } + responses: + "200": + description: Registered + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + properties: + registered: { type: boolean } + "400": { $ref: "#/components/responses/Invalid" } + + /customer/devices/{token}: + delete: + tags: [Devices] + summary: Unregister a push token + parameters: + - { name: token, in: path, required: true, schema: { type: string } } + responses: + "200": + description: Unregistered + content: + application/json: + schema: { $ref: "#/components/schemas/Envelope" } + + + # ── Account (§13.5) ──────────────────────────────────────────────────────── + + /customer/profile: + get: + tags: [Account] + summary: The signed-in customer's profile + responses: + "200": + description: The customer + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Customer" } + "401": { $ref: "#/components/responses/Unauthorized" } + put: + tags: [Account] + summary: Update the profile + description: | + Every field is optional and nullable-by-omission: an omitted key leaves + the stored value alone, an explicit value overwrites it. This matters — + the previous version cleared `email` and the last name on any call that + did not resend them, so editing a name silently wiped the email. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string, minLength: 2 } + email: { type: string } + defaultLatitude: { type: number } + defaultLongitude: { type: number } + defaultPincode: { type: string } + responses: + "200": + description: The updated customer + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Customer" } + "400": + description: "`invalid_name` when the name is under two characters" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /customer/locations: + get: + tags: [Account] + summary: Saved addresses + description: | + Returned in the same `title`/`sub` shape as a place search result, so an + address picked from Saved and one picked from Search are the same object + to the client. + responses: + "200": + description: Saved addresses, default first + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ListEnvelope" + - type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/SavedAddress" } + "401": { $ref: "#/components/responses/Unauthorized" } + post: + tags: [Account] + summary: Save an address + description: Capped at 10 per customer. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SavedAddressInput" } + responses: + "201": + description: Saved + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/SavedAddress" } + "400": + description: "`invalid` — missing fields, or the 10-address cap is reached" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /customer/locations/{id}: + put: + tags: [Account] + summary: Update a saved address + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SavedAddressInput" } + responses: + "200": + description: Updated + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/SavedAddress" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [Account] + summary: Remove a saved address + description: Soft delete — the row is retained, the address stops being offered. + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + "200": + description: Removed + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: + type: object + properties: + id: { type: string } + deleted: { type: boolean } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + + # ── §11 QA ───────────────────────────────────────────────────────────────── + + /customer/ops/bookings/{reference}/stage: + post: + tags: [Ops] + summary: Force a booking to a stage (staging only) + description: | + Exists so every tracking state is reachable for design QA and the app's + debug stepper can be deleted. Reaching `out_for_delivery` honestly needs + a rider to accept, drive, weigh a parcel, hand it to a hub and start a + delivery run. + + **Double-gated**: refused unless `ENV != production` **and** + `CX_ALLOW_STAGE_OVERRIDE=true`. Two independent switches, because either + being wrong in production would let any customer mark their own parcel + delivered. Returns `404` when disabled, so its existence is not + advertised. + + It walks every intermediate stage rather than jumping, and writes through + the same recorder every real transition uses — so QA sees the real + projection over real event rows, not a special rendering path that could + pass while production is broken. + parameters: + - $ref: "#/components/parameters/Reference" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [stage] + properties: + stage: { $ref: "#/components/schemas/Stage" } + reason: + type: string + description: Recorded on the audit row, so a forced transition stays distinguishable from a real one forever after. + responses: + "200": + description: The booking at the requested stage + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + properties: + data: { $ref: "#/components/schemas/Booking" } + "404": { description: Disabled in this environment, or unknown reference } + +components: + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + Reference: + name: reference + in: path + required: true + schema: { type: string, pattern: "^DM-" } + examples: { booking: { value: "DM-482913" } } + IdempotencyKey: + name: Idempotency-Key + in: header + required: false + schema: { type: string } + description: | + Any stable unique string the client picks per logical action. The first + request runs; a retry with the same key replays the stored response for + 24 hours, so a dropped ack on a bad network never becomes a duplicate + pickup. + IfNoneMatch: + name: If-None-Match + in: header + required: false + schema: { type: string } + + responses: + Invalid: + description: "`invalid` — validation failed" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Unauthorized: + description: "`unauthorized` — missing or expired access token" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Forbidden: + description: "`forbidden` — the token is valid but the resource is not the caller's" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + NotFound: + description: "`not_found`" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + RateLimited: + description: "`rate_limited` — includes `Retry-After`" + headers: + Retry-After: { schema: { type: integer } } + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + schemas: + + Envelope: + type: object + required: [success, data, message] + properties: + success: { type: boolean, const: true } + data: {} + message: + type: string + description: Always present on success, always empty. The field is never omitted. + const: "" + + ListEnvelope: + type: object + required: [success, data, total, nextCursor] + properties: + success: { type: boolean, const: true } + data: + type: array + description: Always an array, never null — the client types it as a list. + total: { type: integer } + nextCursor: + type: [string, "null"] + description: Null on the last page. + message: { type: string } + + Error: + type: object + required: [success, message, error] + properties: + success: { type: boolean, const: false } + message: + type: string + description: | + Customer-safe English, shown verbatim in the app's single error + state. Never an enum key, a stack trace or an HTML page. + error: + type: object + required: [code] + properties: + code: + type: string + enum: [invalid, invalid_name, invalid_otp, unauthorized, forbidden, + not_found, conflict, unserviceable, rate_limited, server_error] + + Customer: + type: object + required: [id, name, phone, email] + properties: + id: { type: string, examples: ["cust_10241"] } + name: { type: string, examples: ["Joe Oommen"] } + phone: { type: string, examples: ["+919876543210"] } + email: + type: string + description: Never null — the client types it as a non-nullable String. Empty when unknown. + + Session: + type: object + required: [accessToken, refreshToken, expiresIn, customer] + properties: + accessToken: { type: string } + refreshToken: { type: string } + expiresIn: { type: integer, description: Seconds. Access tokens last an hour; refresh tokens 60 days. } + customer: { $ref: "#/components/schemas/Customer" } + + District: + type: object + required: [code, name, available] + properties: + code: { type: string, examples: ["TN-CBE"] } + name: { type: string, examples: ["Coimbatore"] } + available: + type: boolean + description: False districts are not offered in the picker but are still listed. + note: { type: string, examples: ["Opening soon", "Paused this week"] } + hub: { type: string, examples: ["Coimbatore Central Hub"] } + promise: { type: string, examples: ["Next-day delivery", "2-day delivery"] } + lat: + type: number + description: | + The district's centre. Only `stateCode` and `districtCode` are + required at booking time, so for most destinations this is the ONLY + geography the parcel has until the miler corrects it at the door — + it is what places the destination on a map and what the fare + estimate is priced against. **Omitted** rather than sent as 0 when + unknown: null island is a real coordinate and renders as a pin off + the coast of Africa. + examples: [13.082680] + lng: { type: number, examples: [80.270718] } + + PickupSlot: + type: object + required: [id, day, window, available] + properties: + id: + type: string + description: Opaque; sent back as `slotId`. Encodes its date, so a stale slot resolves to that date and is rejected rather than silently booking today. + examples: ["slot_20260905_t1"] + day: { type: string, examples: ["Today", "Tomorrow", "Mon, 8 Sep"] } + window: { type: string, description: "En dash, spaced.", examples: ["2:00 – 4:00 PM"] } + available: { type: boolean } + note: { type: string, examples: ["Fully booked"] } + tag: + type: string + description: At most one slot in the list carries this, and only when it can actually be booked. + examples: ["Fastest pickup"] + milersNearby: { type: integer, description: Omitted at 0; the client hides the line. } + caption: { type: string, examples: ["Arriving in approx. 45 mins"] } + + Place: + type: object + required: [title, sub, lat, lng] + properties: + title: { type: string, maxLength: 32, examples: ["Brookefields Mall"] } + sub: { type: string, examples: ["Brookebond Road, Coimbatore 641001"] } + lat: { type: number } + lng: { type: number } + + FareEstimate: + type: object + required: [min, max, paymentMethod, parcel, routeKm] + properties: + min: { type: integer, description: "Whole rupees. 49 renders as ₹49.", examples: [167] } + max: { type: integer, examples: [267] } + paymentMethod: { type: string, examples: ["UPI · Cash at doorstep"] } + parcel: { type: string, examples: ["3 boxes (up to 3 kg each)"] } + routeKm: { type: number, examples: [6.4] } + + Stage: + type: string + description: | + The nine operational stages. The client rolls these into seven + milestones itself and silently falls back to `booked` on an unknown key, + so a new value needs a client release. + + Stages 0–5 belong to the booking. Stages 6–8 belong to each order and + may differ between destinations of the same booking. + enum: [booked, assigned, on_the_way, arrived, picked_up, order_created, + in_transit, out_for_delivery, delivered] + + DeliveryDetailsInput: + type: object + description: Every field optional. `null` clears a stored value; an omitted key leaves it alone. + properties: + street: { type: [string, "null"] } + building: { type: [string, "null"] } + landmark: { type: [string, "null"] } + recipientName: { type: [string, "null"] } + recipientPhone: { type: [string, "null"] } + instructions: { type: [string, "null"] } + pin: + type: [object, "null"] + properties: + lat: { type: number } + lng: { type: number } + codAmount: + type: [number, "null"] + description: | + Money the miler collects at this door **on the customer's behalf**. + Doormile is the carrier, not the seller — this is never Doormile's + money. + + Agent: + type: object + description: A miler, or the rider making the final delivery. + properties: + name: { type: string, examples: ["Arun Kumar"] } + vehicle: { type: string, examples: ["TN 37 BX 4412"] } + phone: + type: string + description: A masked-calling proxy when `MILER_CALL_PROXY` is configured, otherwise the rider's real number. + rating: { type: number, examples: [4.9] } + trips: { type: integer, examples: [1240] } + vehicleType: { type: string, examples: ["E-Scooter"] } + + Verification: + type: object + description: | + What the miler recorded at the door. Null until `picked_up` — the weight + and photographs cannot exist before someone was standing next to the + parcel. + required: [weightKg, photos, capturedAt, capturedBy] + properties: + weightKg: + type: number + description: The weight the price settled on. Rendered as "2.8 kg". + examples: [2.8] + photos: + type: array + description: Signed URLs, 30-minute TTL. One per package. Never permanent CDN links — a parcel photo can show the inside of someone's doorway. + items: { type: string, format: uri } + capturedAt: { type: integer, description: Epoch milliseconds, UTC. } + capturedBy: { type: string, examples: ["Arun Kumar"] } + + Destination: + type: object + required: [stateCode, stateName, districtCode, districtName, packageCount] + properties: + stateCode: { type: string, examples: ["TN"] } + stateName: + type: string + description: Always populated. The client renders "Chennai, Tamil Nadu" from these and never looks a code up. + examples: ["Tamil Nadu"] + districtCode: { type: string, examples: ["TN-MAA"] } + districtName: { type: string, examples: ["Chennai"] } + packageCount: { type: integer, examples: [2] } + district: + oneOf: + - { $ref: "#/components/schemas/District" } + - { type: "null" } + details: + type: object + description: Only the fields actually filled in. A missing one renders as "Not added — the Miler can confirm this at pickup". + trackingId: + type: [string, "null"] + description: Null until `order_created`. + examples: ["DMX10482913"] + stage: + oneOf: + - { $ref: "#/components/schemas/Stage" } + - { type: "null" } + description: Null until `order_created`; this order's own journey from there on. + verification: + oneOf: + - { $ref: "#/components/schemas/Verification" } + - { type: "null" } + + Booking: + type: object + description: | + The single most important response in this API — the tracking screen and + the receipt are both rendered from it. + required: [reference, stage, status, cancellable, createdAt, pickup, slotId, + destinations, routeKm, fare, history] + properties: + reference: { type: string, examples: ["DM-482913"] } + stage: { $ref: "#/components/schemas/Stage" } + status: + type: string + enum: [active, completed, cancelled] + description: Derived, but sent explicitly — a client that has to infer it will eventually infer it differently. + cancellable: + type: boolean + description: Mirrors the server policy so the UI can hide the button. The server re-checks on the cancel call; this is a hint, never the authority. + createdAt: { type: integer, description: Epoch milliseconds, UTC. } + pickup: + type: object + description: Present on EVERY booking, cancelled ones included. The client types it non-nullable and throws on null. + required: [title, sub, lat, lng] + properties: + title: { type: string } + sub: { type: string } + lat: { type: number } + lng: { type: number } + slotId: + type: string + description: Present on every booking, as above. + destinations: + type: array + items: { $ref: "#/components/schemas/Destination" } + miler: + oneOf: + - { $ref: "#/components/schemas/Agent" } + - { type: "null" } + description: Present from `assigned`. + deliveryAgent: + oneOf: + - { $ref: "#/components/schemas/Agent" } + - { type: "null" } + description: Present from `out_for_delivery`. + milerDistanceKm: + type: [number, "null"] + description: Live, from the rider's current position. Set during `on_the_way` and `arrived` only — after pickup it would describe a journey that already ended. + milerEtaMinutes: { type: [integer, "null"] } + milersInZone: + type: integer + description: How many riders are nearby. Shown while still finding a Miler. + routeKm: { type: number, examples: [6.4] } + expectedDelivery: + type: string + description: Display string, formatted server-side in IST. The latest promise across the destinations. + examples: ["Thu, 12 Sep"] + fare: + type: object + description: | + Kept on the booking forever, including after settlement — the + receipt renders `amountPaid − fare.min` as the weight adjustment, so + losing the original estimate would lose the explanation for the + difference. + properties: + min: { type: integer } + max: { type: integer } + paymentMethod: { type: string } + parcel: { type: string } + amountPaid: + type: [integer, "null"] + description: Settled total in whole rupees. Present from `picked_up`. + deliveredAt: + type: [integer, "null"] + description: When the LAST parcel landed. Null while any is still moving. + cancelReason: { type: [string, "null"] } + history: + type: array + description: | + Append-only and ordered oldest-first. One entry per stage actually + reached, with the real timestamp. **Nothing is synthesised or + backfilled** — a booking from before this surface existed has a short + history, and a short honest one beats a long invented one. + items: + type: object + required: [stage, at] + properties: + stage: { $ref: "#/components/schemas/Stage" } + at: { type: integer, description: Epoch milliseconds, UTC. } + + SavedAddress: + type: object + description: Same two-line shape as a Place, plus the recipient and the default flag. + required: [id, title, sub, lat, lng, isDefault] + properties: + id: { type: string } + label: { type: string, examples: ["Home", "Office"] } + title: { type: string } + sub: { type: string } + recipientName: { type: string } + recipientPhone: { type: string } + lat: { type: number } + lng: { type: number } + isDefault: { type: boolean } + + SavedAddressInput: + type: object + required: [address, pincode, latitude, longitude] + properties: + label: { type: string } + address: { type: string } + landmark: { type: string } + city: { type: string } + state: { type: string } + pincode: { type: string } + latitude: { type: number } + longitude: { type: number } + receivername: { type: string } + receiverphone: { type: string } + isdefault: { type: boolean } + + PushPayload: + type: object + description: | + FCM/APNs data payload for a customer-visible milestone change. Not an + endpoint — documented here because the client parses it. + + `on_the_way` and `order_created` deliberately do NOT produce a + notification; they roll up on the timeline. A customer buzzed for every + operational transition stops reading them and misses the one that + mattered. + properties: + type: { type: string, const: stage_change } + reference: { type: string, examples: ["DM-482913"] } + trackingId: + type: [string, "null"] + description: Null before `order_created`. + stage: { $ref: "#/components/schemas/Stage" } + title: { type: string, examples: ["Out for delivery"] } + body: { type: string, examples: ["Arriving today at the delivery address."] } + deepLink: + type: string + description: Sent from day one even though the client has no intent filter yet — a notification already in someone's tray should open the right screen once it does. + examples: ["doormile://track/DM-482913"] diff --git a/dto/auth.go b/dto/auth.go index 1d910f7..411b04f 100644 --- a/dto/auth.go +++ b/dto/auth.go @@ -1,41 +1,10 @@ package dto -type CustomerRegisterRequest struct { - Firstname string `json:"firstname" xml:"firstname" form:"firstname"` - Lastname string `json:"lastname" xml:"lastname" form:"lastname"` - Phone string `json:"phone" xml:"phone" form:"phone"` - Email string `json:"email" xml:"email" form:"email"` - Pin string `json:"pin" xml:"pin" form:"pin"` // 4 or 6 digit PIN - Configid int `json:"configid"` -} - -type CustomerLoginRequest struct { - Phone string `json:"phone" xml:"phone" form:"phone"` - Configid int `json:"configid"` -} - -type CustomerPinVerifyRequest struct { - Phone string `json:"phone" xml:"phone" form:"phone"` - Pin string `json:"pin" xml:"pin" form:"pin"` - Configid int `json:"configid"` - DeviceToken string `json:"device_token"` -} - -type CustomerResetPinRequest struct { - Phone string `json:"phone" xml:"phone" form:"phone"` - NewPin string `json:"new_pin" xml:"new_pin" form:"new_pin"` - Configid int `json:"configid"` -} - -type SendEmailOtpRequest struct { - Email string `json:"email" xml:"email" form:"email"` - Phone string `json:"phone" xml:"phone" form:"phone"` -} - -type VerifyEmailOtpRequest struct { - Email string `json:"email" xml:"email" form:"email"` - Otp string `json:"otp" xml:"otp" form:"otp"` -} +// The customer app has no password and no PIN: it authenticates on a 4-digit +// code sent to a phone or an email address, and its request shapes are declared +// inline in controllers/cxAuthController.go alongside the handlers that read +// them. The PIN register/login/verify/reset request types that used to live +// here went with that flow. type MilerLoginRequest struct { Phone string `json:"phone" xml:"phone" form:"phone"` diff --git a/internal/assignment/crm_assignment.go b/internal/assignment/crm_assignment.go index 621d778..925920e 100644 --- a/internal/assignment/crm_assignment.go +++ b/internal/assignment/crm_assignment.go @@ -8,6 +8,7 @@ import ( "doormile/constants" "doormile/db" + "doormile/internal/cxstage" "doormile/internal/routing" "doormile/models" "doormile/utils" @@ -227,8 +228,24 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate, return fmt.Errorf("update MilerProfile availability: %w", err) } + // The customer's "Miler assigned" milestone, in the same transaction as the + // assignment it describes. The auto-assignment path is how most B2C + // bookings get a rider, so without this the customer app's timeline would + // only ever advance for manually assigned pickups. + if err := cxstage.Record(tx, cxstage.Event{ + BookingID: booking.Bookingid, + Stage: constants.CxStageAssigned, + ActorType: constants.CxActorSystem, + Source: "internal/assignment.commitAssignment", + }); err != nil { + tx.Rollback() + return fmt.Errorf("record assigned stage: %w", err) + } + tx.Commit() + go cxstage.Notify(booking.Bookingid, nil, constants.CxStageAssigned) + utils.Info("CRMAssignment: assigned", "booking_id", booking.Bookingid, "miler_id", milerUserID, diff --git a/internal/cxstage/stage.go b/internal/cxstage/stage.go new file mode 100644 index 0000000..316eff6 --- /dev/null +++ b/internal/cxstage/stage.go @@ -0,0 +1,425 @@ +// Package cxstage derives the customer app's view of a pickup from the writes +// the miler app already makes. +// +// This is the part of the customer API that is not a new endpoint. Every +// operational write it reacts to — accept, reached, parcel, pickup-complete, +// inward-at-hub, start-delivery, deliver, cancel — already existed and was +// already correct. What did not exist was any record of when a parcel reached a +// stage, in the vocabulary the customer is shown. A consignment status says +// where a parcel is now; it cannot say when it got there, and a timeline +// assembled from "now" plus guesses is the thing this package exists to avoid. +// +// So: one append-only row per stage actually reached, with the real time and +// the real actor, and the booking's current stage kept alongside it so a +// tracking poll is one row rather than a replay. Nothing here is backfilled. +// A booking that predates this package has a short history, and a short honest +// history beats a long invented one — the customer cannot tell which entries +// were guessed. +package cxstage + +import ( + "fmt" + "strconv" + "time" + + "doormile/constants" + "doormile/db" + "doormile/internal/notify" + "doormile/models" + "doormile/utils" + + "gorm.io/gorm" +) + +// Event is one stage transition to record. +type Event struct { + BookingID int + // DestinationID is set for the per-order stages (in_transit onward), which + // may differ between destinations of the same booking, and nil for the + // booking-level stages that apply to the whole pickup. + DestinationID *int + Stage string + ActorType string // constants.CxActor* + ActorID *int + // Source names the write that caused this — the endpoint path or the job. + // It is what makes an audit trail answerable a year later. + Source string + Remarks string + // At is when it actually happened. Zero means now. + At time.Time +} + +// Record appends the event and advances the stored stage, inside the caller's +// transaction. +// +// Pass the same *gorm.DB the operational write is using. A stage event that +// commits while the write it describes rolls back is a customer being told +// their parcel was collected when it was not — so the two share a transaction +// or neither happens. +// +// Recording is idempotent per (booking, destination, stage): a rider tapping +// "arrived" twice on bad signal produces one timeline entry, not two. +func Record(tx *gorm.DB, in Event) error { + if tx == nil || in.BookingID == 0 || in.Stage == "" { + return fmt.Errorf("cxstage: incomplete event") + } + if constants.CxStageRank(in.Stage) < 0 { + return fmt.Errorf("cxstage: unknown stage %q", in.Stage) + } + at := in.At + if at.IsZero() { + at = utils.DBNow() + } + if in.ActorType == "" { + in.ActorType = constants.CxActorSystem + } + + // Only a booking the customer app is watching gets a projection. + // + // This guard is about blast radius, not tidiness. Record runs inside the + // caller's transaction and returns its error, so a failure here fails the + // operational write — and the operational writes it hooks into + // (assignMilerTx, commitAssignment, pickup-complete) also serve + // console-created express bookings, which no customer app has ever seen. + // Without this, a problem in the customer projection could refuse a hub + // staff member's manual assignment of a booking that has no customer + // attached to it at all. + var booking models.PickupBooking + if err := tx.Select("bookingid, bookingsource, customerstage, customerstatus, status"). + First(&booking, in.BookingID).Error; err != nil { + return fmt.Errorf("cxstage: load booking: %w", err) + } + if booking.Bookingsource != constants.BookingSourceCustomerApp { + return nil + } + + var existing int64 + q := tx.Model(&models.BookingStageEvent{}). + Where("bookingid = ? AND stage = ?", in.BookingID, in.Stage) + if in.DestinationID != nil { + q = q.Where("bookingdestinationid = ?", *in.DestinationID) + } else { + q = q.Where("bookingdestinationid IS NULL") + } + if err := q.Count(&existing).Error; err != nil { + return fmt.Errorf("cxstage: count existing: %w", err) + } + if existing == 0 { + event := models.BookingStageEvent{ + Bookingid: in.BookingID, + Bookingdestinationid: in.DestinationID, + Stage: in.Stage, + Actortype: in.ActorType, + Actorid: in.ActorID, + Source: in.Source, + Remarks: in.Remarks, + Occurredat: at, + } + if err := tx.Create(&event).Error; err != nil { + return fmt.Errorf("cxstage: append event: %w", err) + } + } + + if in.DestinationID != nil { + if err := advanceDestination(tx, *in.DestinationID, in.Stage, at); err != nil { + return err + } + } + return advanceBooking(tx, &booking, in.Stage) +} + +// advanceDestination moves one order forward. Never backwards: a re-delivery +// attempt that re-issues out_for_delivery must not un-deliver a sibling, and a +// late-arriving event must not rewind a parcel the customer has already been +// told arrived. +func advanceDestination(tx *gorm.DB, destinationID int, stage string, at time.Time) error { + var dest models.BookingDestination + if err := tx.First(&dest, destinationID).Error; err != nil { + return fmt.Errorf("cxstage: load destination: %w", err) + } + if constants.CxStageRank(stage) <= constants.CxStageRank(dest.Stage) { + return nil + } + updates := map[string]interface{}{"stage": stage, "updatedat": utils.DBNow()} + if stage == constants.CxStageDelivered { + updates["deliveredat"] = at + } + return tx.Model(&models.BookingDestination{}). + Where("bookingdestinationid = ?", destinationID).Updates(updates).Error +} + +// advanceBooking recomputes the booking's own stage and status. +// +// For stages 0-5 that is simply "the furthest stage reached". For 6-8 it is the +// LEAST-advanced destination, because a booking is only out for delivery when +// everything in it is, and only delivered when the last parcel lands. Taking +// the maximum instead would show a customer "Delivered" while one of their +// three parcels was still at a hub. +func advanceBooking(tx *gorm.DB, booking *models.PickupBooking, stage string) error { + bookingID := booking.Bookingid + if booking.Customerstatus == constants.CxStatusCancelled { + // A cancelled pickup is terminal. Late telemetry from a rider who was + // already stood down must not resurrect it. + return nil + } + + next := stage + if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStageInTransit] { + var destinations []models.BookingDestination + if err := tx.Select("bookingdestinationid, stage"). + Where("bookingid = ?", bookingID).Find(&destinations).Error; err != nil { + return fmt.Errorf("cxstage: load destinations: %w", err) + } + next = slowestDestinationStage(destinations, stage) + } + + if constants.CxStageRank(next) <= constants.CxStageRank(booking.Customerstage) { + return nil + } + + status := constants.CxStatusActive + if next == constants.CxStageDelivered { + status = constants.CxStatusCompleted + } + + return tx.Model(&models.PickupBooking{}). + Where("bookingid = ?", bookingID). + Updates(map[string]interface{}{ + "customerstage": next, + "customerstatus": status, + "updatedat": utils.DBNow(), + }).Error +} + +// slowestDestinationStage returns the least-advanced stage across a booking's +// orders, which is the booking's real progress. +func slowestDestinationStage(destinations []models.BookingDestination, fallback string) string { + if len(destinations) == 0 { + return fallback + } + slowest := "" + for _, d := range destinations { + s := d.Stage + if s == "" { + // An order that has not reported anything yet holds the booking at + // order_created — it exists, it just has not moved. + s = constants.CxStageOrderCreated + } + if slowest == "" || constants.CxStageRank(s) < constants.CxStageRank(slowest) { + slowest = s + } + } + if slowest == "" { + return fallback + } + return slowest +} + +// Release walks a booking BACK to booked because its rider let it go. +// +// A miler cancelling or skipping does not cancel the pickup — it returns it to +// the pool for reassignment. Without this the customer keeps seeing "Miler +// assigned" against a booking that has no miler, and the screen shows a rider +// card for someone who is no longer coming. Stage is the only thing that moves +// backwards: the history entries for assigned and on_the_way stay, because +// those things did happen, and when a new rider accepts, Record's dedupe means +// no second copy is appended. +// +// This is the one exception to "stages only advance", and it is deliberate: +// the alternative is showing the customer a rider who is not on their way. +func Release(tx *gorm.DB, bookingID int, reason, actorType string, actorID *int, source string) error { + if !isCustomerBooking(tx, bookingID) { + return nil + } + now := utils.DBNow() + + if err := tx.Model(&models.PickupBooking{}). + Where("bookingid = ?", bookingID). + Updates(map[string]interface{}{ + "customerstage": constants.CxStageBooked, + "customerstatus": constants.CxStatusActive, + "updatedat": now, + }).Error; err != nil { + return fmt.Errorf("cxstage: release booking: %w", err) + } + + event := models.BookingStageEvent{ + Bookingid: bookingID, + Stage: constants.CxStageBooked, + Actortype: actorType, + Actorid: actorID, + Source: source, + Remarks: "released: " + reason, + Occurredat: now, + } + if err := tx.Create(&event).Error; err != nil { + return fmt.Errorf("cxstage: record release: %w", err) + } + return nil +} + +// Cancel marks a whole pickup cancelled and records who did it. +// +// Cancellation is whole-pickup: there is no partial cancel in v1, so every +// destination goes with it. The reason is stored because a customer who did not +// press the button — a miler skip, an ops stand-down — is otherwise told their +// pickup vanished with no explanation. +func Cancel(tx *gorm.DB, bookingID int, reason, actorType string, actorID *int, source string) error { + if !isCustomerBooking(tx, bookingID) { + return nil + } + now := utils.DBNow() + if err := tx.Model(&models.PickupBooking{}). + Where("bookingid = ?", bookingID). + Updates(map[string]interface{}{ + "status": constants.BookingCancelled, + "customerstatus": constants.CxStatusCancelled, + "cancelreason": reason, + "updatedat": now, + }).Error; err != nil { + return fmt.Errorf("cxstage: cancel booking: %w", err) + } + + // Recorded on the timeline as an event with a real actor, not as a silent + // status flip. "cancelled" is not one of the nine stages the client parses, + // so it rides the event log for the audit trail and the client reads + // status/cancelReason for the display. + event := models.BookingStageEvent{ + Bookingid: bookingID, + Stage: constants.CxStageBooked, + Actortype: actorType, + Actorid: actorID, + Source: source, + Remarks: "cancelled: " + reason, + Occurredat: now, + } + // Deliberately not deduped against the booked event: this row is the + // cancellation record, distinguished by its remarks, and losing it would + // leave the cancellation unattributed. + if err := tx.Create(&event).Error; err != nil { + return fmt.Errorf("cxstage: record cancellation: %w", err) + } + return nil +} + +// ── Notification ───────────────────────────────────────────────────────────── + +// Notify pushes a stage change to the customer's devices. Call it AFTER the +// transaction commits — a notification for a rolled-back write cannot be +// recalled. +// +// Only customer-visible milestones are sent. on_the_way and order_created roll +// up on the timeline rather than earning their own notification: a customer who +// gets a buzz for every operational transition stops reading them, and then +// misses the one that mattered. +func Notify(bookingID int, destinationID *int, stage string) { + title, body, send := milestoneCopy(stage) + if !send { + return + } + + var booking models.PickupBooking + if err := db.DB.Select("bookingid, bookingno, appcustomerid"). + First(&booking, bookingID).Error; err != nil { + utils.Warn("cxstage: could not load booking for notification", "booking_id", bookingID, "error", err) + return + } + + trackingID := "" + if destinationID != nil { + var dest models.BookingDestination + if err := db.DB.Select("trackingno").First(&dest, *destinationID).Error; err == nil { + trackingID = dest.Trackingno + } + } + + payload := map[string]string{ + "type": "stage_change", + "reference": booking.Bookingno, + "stage": stage, + "title": title, + "body": body, + // Sent from day one even though the client has no intent filter yet — + // the app starts honouring it in a later build, and a notification + // already in someone's tray should still open the right screen then. + "deepLink": "doormile://track/" + booking.Bookingno, + } + if trackingID != "" { + payload["trackingId"] = trackingID + payload["deepLink"] = "doormile://track/" + trackingID + } + payload["booking_id"] = strconv.Itoa(bookingID) + + for _, token := range deviceTokens(booking.Appcustomerid) { + if err := notify.SendToDevice(token, title, body, payload); err != nil { + utils.Warn("cxstage: push failed", "booking_id", bookingID, "error", err) + } + } +} + +// milestoneCopy maps a stage to the notification the customer reads, and says +// whether it earns one at all. +func milestoneCopy(stage string) (title, body string, send bool) { + switch stage { + case constants.CxStageAssigned: + return "Miler assigned", "Your Miler is on the way to collect your packages.", true + case constants.CxStageArrived: + return "Your Miler has arrived", "They are at your pickup address now.", true + case constants.CxStagePickedUp: + return "Packages collected", "Your Miler has collected and weighed your packages.", true + case constants.CxStageInTransit: + return "In transit", "Your package is on its way to the destination.", true + case constants.CxStageOutForDelivery: + return "Out for delivery", "Arriving today at the delivery address.", true + case constants.CxStageDelivered: + return "Delivered", "Your package has been handed over.", true + default: + // booked (the customer just made it), on_the_way and order_created all + // roll up on the timeline. + return "", "", false + } +} + +// deviceTokens returns every device the customer is signed in on. One row per +// device, not one column on the customer, so a phone and a tablet both get the +// update instead of only whichever registered last. +func deviceTokens(customerID int) []string { + var devices []models.CustomerDevice + if err := db.DB.Select("token").Where("appcustomerid = ?", customerID). + Find(&devices).Error; err != nil { + utils.Warn("cxstage: device lookup failed", "customer_id", customerID, "error", err) + return nil + } + tokens := make([]string, 0, len(devices)) + for _, d := range devices { + if d.Token != "" { + tokens = append(tokens, d.Token) + } + } + return tokens +} + +// RecordAndNotify is the convenience the operational handlers use: record +// inside the transaction, then push once it has committed. The caller is +// responsible for calling the returned function only after a successful commit. +func RecordAndNotify(tx *gorm.DB, in Event) (afterCommit func(), err error) { + if err := Record(tx, in); err != nil { + return func() {}, err + } + bookingID, destinationID, stage := in.BookingID, in.DestinationID, in.Stage + return func() { go Notify(bookingID, destinationID, stage) }, nil +} + +// isCustomerBooking reports whether a booking is one the customer app is +// watching. Cancel and Release are called from paths that also serve +// console-created express bookings, and those have no customer projection to +// keep in step — writing one would put stage rows against bookings nobody will +// ever read them for, and would fail an ops action if that write failed. +func isCustomerBooking(tx *gorm.DB, bookingID int) bool { + var booking models.PickupBooking + if err := tx.Select("bookingid, bookingsource").First(&booking, bookingID).Error; err != nil { + utils.Warn("cxstage: could not read booking source", "booking_id", bookingID, "error", err) + return false + } + return booking.Bookingsource == constants.BookingSourceCustomerApp +} diff --git a/internal/cxstage/stage_test.go b/internal/cxstage/stage_test.go new file mode 100644 index 0000000..ebf8173 --- /dev/null +++ b/internal/cxstage/stage_test.go @@ -0,0 +1,126 @@ +package cxstage + +import ( + "testing" + + "doormile/constants" + "doormile/models" +) + +// A booking's own stage, once its parcels have split into separate orders, is +// the LEAST-advanced of them. Taking the maximum instead would tell a customer +// "Delivered" while one of their three parcels was still sitting at a hub — +// which is the single most damaging thing this projection could get wrong. + +func TestSlowestDestinationStageHoldsAtTheLaggingOrder(t *testing.T) { + destinations := []models.BookingDestination{ + {Bookingdestinationid: 1, Stage: constants.CxStageDelivered}, + {Bookingdestinationid: 2, Stage: constants.CxStageInTransit}, + {Bookingdestinationid: 3, Stage: constants.CxStageOutForDelivery}, + } + + got := slowestDestinationStage(destinations, constants.CxStageDelivered) + if got != constants.CxStageInTransit { + t.Errorf("slowestDestinationStage = %q, want %q — a booking is only as far along as its slowest parcel", + got, constants.CxStageInTransit) + } +} + +func TestSlowestDestinationStageCompletesOnlyWhenAllLand(t *testing.T) { + destinations := []models.BookingDestination{ + {Bookingdestinationid: 1, Stage: constants.CxStageDelivered}, + {Bookingdestinationid: 2, Stage: constants.CxStageDelivered}, + } + + if got := slowestDestinationStage(destinations, constants.CxStageDelivered); got != constants.CxStageDelivered { + t.Errorf("slowestDestinationStage = %q, want %q", got, constants.CxStageDelivered) + } +} + +func TestSlowestDestinationStageTreatsSilentOrderAsOrderCreated(t *testing.T) { + // An order that exists but has reported nothing yet holds the booking at + // order_created. An empty stage must never sort as "furthest along". + destinations := []models.BookingDestination{ + {Bookingdestinationid: 1, Stage: constants.CxStageOutForDelivery}, + {Bookingdestinationid: 2, Stage: ""}, + } + + got := slowestDestinationStage(destinations, constants.CxStageOutForDelivery) + if got != constants.CxStageOrderCreated { + t.Errorf("slowestDestinationStage with a silent order = %q, want %q", got, constants.CxStageOrderCreated) + } +} + +func TestSlowestDestinationStageFallsBackWithNoDestinations(t *testing.T) { + // A console-created booking has no destination rows at all. It must keep + // the stage the caller was recording, not collapse to order_created. + got := slowestDestinationStage(nil, constants.CxStageOutForDelivery) + if got != constants.CxStageOutForDelivery { + t.Errorf("slowestDestinationStage(nil) = %q, want the fallback %q", got, constants.CxStageOutForDelivery) + } +} + +// The nine stage keys are a wire contract: the client parses them verbatim and +// silently falls back to `booked` on anything it does not recognise, so a +// renamed or reordered key makes a moving parcel look un-started. +func TestStageRankOrderingIsTheContract(t *testing.T) { + ordered := []string{ + constants.CxStageBooked, + constants.CxStageAssigned, + constants.CxStageOnTheWay, + constants.CxStageArrived, + constants.CxStagePickedUp, + constants.CxStageOrderCreated, + constants.CxStageInTransit, + constants.CxStageOutForDelivery, + constants.CxStageDelivered, + } + + for i := 1; i < len(ordered); i++ { + if constants.CxStageRank(ordered[i]) <= constants.CxStageRank(ordered[i-1]) { + t.Errorf("stage %q does not rank after %q", ordered[i], ordered[i-1]) + } + } + + // An unknown stage must sort strictly BEHIND booked, not tie with it — a + // booking written before this surface existed has no stage, and a tie would + // let advanceBooking refuse to move it off nothing. + if constants.CxStageRank("") >= constants.CxStageRank(constants.CxStageBooked) { + t.Errorf("an empty stage ranks %d, which is not behind booked (%d)", + constants.CxStageRank(""), constants.CxStageRank(constants.CxStageBooked)) + } + if constants.CxStageRank("teleported") != -1 { + t.Errorf("an unknown stage ranks %d, want -1", constants.CxStageRank("teleported")) + } +} + +// Cancellation is allowed up to and including arrived, and refused from +// picked_up onward. The UI mirrors this to hide the button; the server is the +// authority, so the boundary is asserted here rather than trusted to a comment. +func TestCancellationClosesAfterArrived(t *testing.T) { + cancellable := []string{ + "", // a pre-surface booking is still cancellable + constants.CxStageBooked, + constants.CxStageAssigned, + constants.CxStageOnTheWay, + constants.CxStageArrived, + } + for _, stage := range cancellable { + if !constants.CxCancellable(stage) { + t.Errorf("stage %q should still be cancellable", stage) + } + } + + closed := []string{ + constants.CxStagePickedUp, + constants.CxStageOrderCreated, + constants.CxStageInTransit, + constants.CxStageOutForDelivery, + constants.CxStageDelivered, + } + for _, stage := range closed { + if constants.CxCancellable(stage) { + t.Errorf("stage %q must not be cancellable — the parcel is already collected", stage) + } + } +} diff --git a/internal/sms/sms.go b/internal/sms/sms.go new file mode 100644 index 0000000..241e829 --- /dev/null +++ b/internal/sms/sms.go @@ -0,0 +1,105 @@ +// Package sms delivers one-time codes to a phone number. +// +// There is no SMS provider wired into this backend yet — the miler app +// authenticates on a PIN and the console on a password, so nothing has ever +// needed to send a text. The customer app's only credential is a code sent to a +// phone, which makes this the one piece of the auth flow that cannot be +// finished from inside this repository. +// +// So this package is the seam, not the integration: a small interface, a +// logging sink that lets the whole flow be exercised end to end without a +// provider, and a fixed-code mode for staging. Plugging in a real gateway +// (MSG91, Gupshup, Twilio) means adding one Sender and selecting it here — +// nothing above this package changes. +package sms + +import ( + "fmt" + "os" + "strings" + + "doormile/utils" +) + +// Sender delivers a message to an E.164 phone number. +type Sender interface { + Send(phone, message string) error + // Name identifies the transport in logs and in the readiness probe, so + // "OTP not arriving" can be answered without reading code. + Name() string +} + +// logSender writes the code to the application log instead of sending it. +// +// This is what runs until a gateway is configured. It is deliberately loud and +// deliberately marked: an OTP in a log file is a credential in a log file, and +// nobody should be able to reach production with this active and not know. +type logSender struct{} + +func (logSender) Name() string { return "log" } + +func (logSender) Send(phone, message string) error { + utils.Warn("SMS NOT CONFIGURED — code written to the log instead of being sent", + "phone", maskPhone(phone), "message", message) + return nil +} + +var active Sender = logSender{} + +// Register installs the real gateway. Call it from main() once a provider is +// configured; until then the log sink stays in place. +func Register(s Sender) { + if s == nil { + return + } + active = s + utils.Info("SMS sender registered", "transport", s.Name()) +} + +// Transport reports which sender is active, for the readiness probe. +func Transport() string { return active.Name() } + +// Configured reports whether a real gateway is in place. False means codes are +// only reaching the log. +func Configured() bool { return active.Name() != "log" } + +// SendOTP delivers a login code. +func SendOTP(phone, code string) error { + if strings.TrimSpace(phone) == "" { + return fmt.Errorf("sms: empty phone number") + } + msg := fmt.Sprintf("%s is your Doormile verification code. It expires in 5 minutes. Do not share it with anyone.", code) + return active.Send(phone, msg) +} + +// maskPhone keeps the country code and the last two digits so a log line can be +// matched to a support call without recording the number itself. +func maskPhone(phone string) string { + if len(phone) < 5 { + return "***" + } + return phone[:3] + strings.Repeat("*", len(phone)-5) + phone[len(phone)-2:] +} + +// StagingCode returns the fixed verification code for non-production +// environments, or "" when none is set. +// +// Automated tests and design QA cannot receive a real text, and the previous +// end-to-end attempt on this system stalled for exactly that reason: customer +// login needed an OTP on a real handset and could not be scripted. CX_STAGING_OTP +// closes that. +// +// It is refused outright when ENV is production, because a fixed code is a +// permanent skeleton key for every account on the platform. +func StagingCode() string { + code := strings.TrimSpace(os.Getenv("CX_STAGING_OTP")) + if code == "" { + return "" + } + if strings.EqualFold(strings.TrimSpace(os.Getenv("ENV")), "production") { + utils.Error("CX_STAGING_OTP is set in a production environment and has been ignored — " + + "a fixed verification code would accept a login for every account on the platform") + return "" + } + return code +} diff --git a/internal/sms/sms_test.go b/internal/sms/sms_test.go new file mode 100644 index 0000000..b384bd9 --- /dev/null +++ b/internal/sms/sms_test.go @@ -0,0 +1,140 @@ +package sms + +import ( + "os" + "strings" + "testing" +) + +// StagingCode is a permanent skeleton key for every account on the platform if +// it ever reaches production. The guard against that is the only thing standing +// between a convenience for QA and a total auth bypass, so it is tested rather +// than trusted. + +func setEnv(t *testing.T, key, value string) { + t.Helper() + previous, had := os.LookupEnv(key) + if value == "" { + _ = os.Unsetenv(key) + } else { + _ = os.Setenv(key, value) + } + t.Cleanup(func() { + if had { + _ = os.Setenv(key, previous) + } else { + _ = os.Unsetenv(key) + } + }) +} + +// A fixed OTP is refused in production however the environment is spelt. +func TestStagingCodeIsRefusedInProduction(t *testing.T) { + for _, env := range []string{"production", "PRODUCTION", "Production", " production "} { + setEnv(t, "CX_STAGING_OTP", "1234") + setEnv(t, "ENV", env) + + if got := StagingCode(); got != "" { + t.Errorf("ENV=%q returned the fixed code %q — that is a skeleton key "+ + "for every account on the platform", env, got) + } + } +} + +// And is available everywhere else, which is what unblocks automated sign-in. +func TestStagingCodeIsAvailableOutsideProduction(t *testing.T) { + for _, env := range []string{"development", "staging", ""} { + setEnv(t, "CX_STAGING_OTP", "1234") + setEnv(t, "ENV", env) + + if got := StagingCode(); got != "1234" { + t.Errorf("ENV=%q returned %q, want the configured staging code", env, got) + } + } +} + +// Unset means unset — no accidental default. +func TestStagingCodeIsEmptyWhenNotConfigured(t *testing.T) { + setEnv(t, "ENV", "development") + setEnv(t, "CX_STAGING_OTP", "") + + if got := StagingCode(); got != "" { + t.Errorf("StagingCode() = %q with nothing configured, want empty", got) + } +} + +// Until a gateway is registered, Configured() must report false. Shipping while +// this quietly said true would mean nobody noticed OTP codes were only reaching +// the application log. +func TestTransportReportsThatNoGatewayIsWired(t *testing.T) { + if Configured() { + t.Error("Configured() = true with no gateway registered — " + + "the log sink must never claim to be a real transport") + } + if Transport() != "log" { + t.Errorf("Transport() = %q, want \"log\"", Transport()) + } +} + +// Registering a gateway flips both, and Register(nil) is ignored rather than +// silently disabling delivery. +func TestRegisterInstallsAGatewayAndIgnoresNil(t *testing.T) { + original := active + t.Cleanup(func() { active = original }) + + Register(nil) + if Transport() != "log" { + t.Errorf("Register(nil) changed the transport to %q", Transport()) + } + + fake := &recordingSender{} + Register(fake) + if !Configured() || Transport() != "test" { + t.Fatalf("after Register: configured=%v transport=%q", Configured(), Transport()) + } + + if err := SendOTP("+919876543210", "4821"); err != nil { + t.Fatalf("SendOTP: %v", err) + } + if fake.phone != "+919876543210" { + t.Errorf("phone = %q, want the number passed in", fake.phone) + } + if !strings.Contains(fake.message, "4821") { + t.Errorf("message %q does not carry the code", fake.message) + } + if !strings.Contains(fake.message, "Do not share") { + t.Errorf("message %q is missing the do-not-share warning", fake.message) + } +} + +// An empty number is refused rather than handed to a gateway that will bill for +// it and fail. +func TestSendOTPRefusesAnEmptyNumber(t *testing.T) { + if err := SendOTP(" ", "4821"); err == nil { + t.Error("SendOTP accepted an empty phone number") + } +} + +// The log sink masks the number. An OTP in a log file is already bad enough +// without the number it belongs to sitting beside it. +func TestMaskPhoneHidesTheSubscriberDigits(t *testing.T) { + got := maskPhone("+919876543210") + if strings.Contains(got, "9876543") { + t.Errorf("maskPhone = %q, still exposes the subscriber digits", got) + } + if !strings.HasSuffix(got, "10") { + t.Errorf("maskPhone = %q, should keep the last two digits so a support "+ + "call can be matched", got) + } +} + +type recordingSender struct { + phone string + message string +} + +func (r *recordingSender) Name() string { return "test" } +func (r *recordingSender) Send(phone, message string) error { + r.phone, r.message = phone, message + return nil +} diff --git a/internal/storage/spaces.go b/internal/storage/spaces.go index c2f5922..5848d5d 100644 --- a/internal/storage/spaces.go +++ b/internal/storage/spaces.go @@ -223,3 +223,77 @@ func awsEncode(s string, encodeSlash bool) string { } return b.String() } + +// PresignGet issues a short-lived, signed GET URL for one object. +// +// Parcel photographs are shown to the customer on the receipt, and a parcel +// photograph frames the inside of someone's doorway. A permanent CDN link to +// one is a permanent link anybody who ever saw it can keep, so the customer +// surface serves these through a signature that expires instead. +// +// Falls back to the plain CDN URL when the bucket credentials are not +// configured: an unsigned photo the customer can see beats a receipt with a +// missing image, and the objects are currently written public-read anyway. +// Once parcel photos are switched to a private ACL this becomes the only way +// to read one — which is the point of routing them through here now. +func PresignGet(objectKey string, expiry time.Duration) (string, error) { + cfg := loadSpacesConfig() + if cfg.accessKey == "" || cfg.secretKey == "" || cfg.bucket == "" { + if cfg.cdnBase != "" { + return cfg.cdnBase + "/" + objectKey, nil + } + return "", fmt.Errorf("object storage not configured") + } + + const ( + service = "s3" + algorithm = "AWS4-HMAC-SHA256" + ) + + host := cfg.bucket + "." + cfg.endpoint + + now := time.Now().UTC() + amzDate := now.Format("20060102T150405Z") + dateStamp := now.Format("20060102") + expSecs := int(expiry.Seconds()) + if expSecs <= 0 { + expSecs = 900 + } + + canonicalURI := "/" + encodePath(objectKey) + credentialScope := dateStamp + "/" + cfg.region + "/" + service + "/aws4_request" + credential := cfg.accessKey + "/" + credentialScope + signedHeaders := "host" + + q := [][2]string{ + {"X-Amz-Algorithm", algorithm}, + {"X-Amz-Credential", credential}, + {"X-Amz-Date", amzDate}, + {"X-Amz-Expires", fmt.Sprintf("%d", expSecs)}, + {"X-Amz-SignedHeaders", signedHeaders}, + } + canonicalQuery := canonicalizeQuery(q) + canonicalHeaders := "host:" + host + "\n" + + canonicalRequest := strings.Join([]string{ + "GET", + canonicalURI, + canonicalQuery, + canonicalHeaders, + signedHeaders, + "UNSIGNED-PAYLOAD", + }, "\n") + + stringToSign := strings.Join([]string{ + algorithm, + amzDate, + credentialScope, + hexSHA256(canonicalRequest), + }, "\n") + + signingKey := deriveSigningKey(cfg.secretKey, dateStamp, cfg.region, service) + signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign)) + + return "https://" + host + canonicalURI + "?" + canonicalQuery + + "&X-Amz-Signature=" + signature, nil +} diff --git a/internal/storage/spaces_test.go b/internal/storage/spaces_test.go new file mode 100644 index 0000000..2dc40ee --- /dev/null +++ b/internal/storage/spaces_test.go @@ -0,0 +1,182 @@ +package storage + +import ( + "net/url" + "os" + "strings" + "testing" + "time" +) + +// PresignGet is what serves a customer their parcel photographs. A parcel photo +// frames the inside of someone's doorway, so the properties tested here are +// privacy properties, not formatting ones: the link must expire, and it must +// never carry the bucket's secret key. + +func withSpaces(t *testing.T, access, secret string) { + t.Helper() + for _, kv := range [][2]string{ + {"DO_SPACES_ACCESS_KEY", access}, + {"DO_SPACES_SECRET_KEY", secret}, + {"DO_SPACES_REGION", "sgp1"}, + {"DO_SPACES_ENDPOINT", "sgp1.digitaloceanspaces.com"}, + {"DO_SPACES_BUCKET", "nearle"}, + {"DO_SPACES_CDN_BASE", "https://images.nearle.app"}, + } { + key, value := kv[0], kv[1] + previous, had := os.LookupEnv(key) + if value == "" { + _ = os.Unsetenv(key) + } else { + _ = os.Setenv(key, value) + } + t.Cleanup(func() { + if had { + _ = os.Setenv(key, previous) + } else { + _ = os.Unsetenv(key) + } + }) + } +} + +// The signed URL must never contain the secret key. A leaked secret is the +// whole bucket, not one photo — and this is exactly the mistake the legacy +// rider app made by shipping the key inside the binary. +func TestPresignGetNeverLeaksTheSecretKey(t *testing.T) { + const secret = "s3cr3t-do-not-emit-this-anywhere" + withSpaces(t, "AKIAEXAMPLE", secret) + + signed, err := PresignGet("pv/booking-70/parcel-1.jpg", 30*time.Minute) + if err != nil { + t.Fatalf("PresignGet: %v", err) + } + if strings.Contains(signed, secret) { + t.Fatal("the signed URL contains the secret key") + } + if strings.Contains(strings.ToLower(signed), "secret") { + t.Errorf("suspicious content in the signed URL: %s", signed) + } + // The ACCESS key is expected — it identifies the caller, it is not a + // credential on its own. + if !strings.Contains(signed, "AKIAEXAMPLE") { + t.Error("the signed URL carries no credential scope, so it cannot authenticate") + } +} + +// A link that does not expire is a permanent link, which defeats the point of +// signing it at all. +func TestPresignGetCarriesAnExpiry(t *testing.T) { + withSpaces(t, "AKIAEXAMPLE", "shhh") + + signed, err := PresignGet("pv/abc.jpg", 30*time.Minute) + if err != nil { + t.Fatalf("PresignGet: %v", err) + } + parsed, err := url.Parse(signed) + if err != nil { + t.Fatalf("the signed URL does not parse: %v", err) + } + q := parsed.Query() + + if got := q.Get("X-Amz-Expires"); got != "1800" { + t.Errorf("X-Amz-Expires = %q, want 1800 (30 minutes)", got) + } + for _, param := range []string{"X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-SignedHeaders", "X-Amz-Signature"} { + if q.Get(param) == "" { + t.Errorf("missing %s — the URL would be rejected by the object store", param) + } + } + if q.Get("X-Amz-Algorithm") != "AWS4-HMAC-SHA256" { + t.Errorf("unexpected algorithm %q", q.Get("X-Amz-Algorithm")) + } +} + +// A non-positive expiry must fall back to a real one rather than minting a link +// that is already dead, or worse, one the store treats as unbounded. +func TestPresignGetDefaultsAZeroExpiry(t *testing.T) { + withSpaces(t, "AKIAEXAMPLE", "shhh") + + signed, err := PresignGet("pv/abc.jpg", 0) + if err != nil { + t.Fatalf("PresignGet: %v", err) + } + parsed, _ := url.Parse(signed) + if got := parsed.Query().Get("X-Amz-Expires"); got != "900" { + t.Errorf("X-Amz-Expires = %q on a zero expiry, want the 900s default", got) + } +} + +// Two different objects must produce different signatures. A signature that +// does not cover the key would let one link fetch any file in the bucket. +func TestPresignGetSignatureCoversTheObjectKey(t *testing.T) { + withSpaces(t, "AKIAEXAMPLE", "shhh") + + a, err := PresignGet("pv/booking-70/parcel-1.jpg", time.Hour) + if err != nil { + t.Fatalf("PresignGet: %v", err) + } + b, err := PresignGet("pv/booking-99/parcel-4.jpg", time.Hour) + if err != nil { + t.Fatalf("PresignGet: %v", err) + } + + sigA, _ := url.Parse(a) + sigB, _ := url.Parse(b) + if sigA.Query().Get("X-Amz-Signature") == sigB.Query().Get("X-Amz-Signature") { + t.Fatal("two different objects produced the same signature — the key is not signed") + } + if !strings.Contains(a, "booking-70") || !strings.Contains(b, "booking-99") { + t.Error("the object key is missing from the URL path") + } +} + +// With no credentials configured it degrades to the plain CDN link rather than +// failing. An unsigned photo the customer can see beats a receipt with a broken +// image — and the objects are currently written public-read anyway. +func TestPresignGetFallsBackToTheCdnWhenUnconfigured(t *testing.T) { + withSpaces(t, "", "") + + got, err := PresignGet("pv/abc.jpg", time.Hour) + if err != nil { + t.Fatalf("PresignGet should degrade, not fail: %v", err) + } + if got != "https://images.nearle.app/pv/abc.jpg" { + t.Errorf("fallback URL = %q, want the plain CDN link", got) + } +} + +// Configured() gates the presign path; it must not claim to be configured on a +// half-set environment. +func TestConfiguredRequiresBothKeys(t *testing.T) { + withSpaces(t, "AKIAEXAMPLE", "") + if Configured() { + t.Error("Configured() = true with no secret key") + } + + withSpaces(t, "", "shhh") + if Configured() { + t.Error("Configured() = true with no access key") + } + + withSpaces(t, "AKIAEXAMPLE", "shhh") + if !Configured() { + t.Error("Configured() = false with both keys present") + } +} + +// Object keys reach this from user-influenced paths, so the encoder has to +// survive spaces and reserved characters without breaking the signature. +func TestEncodePathHandlesAwkwardKeys(t *testing.T) { + cases := []struct{ in, want string }{ + {"pv/abc.jpg", "pv/abc.jpg"}, + {"pv/a b.jpg", "pv/a%20b.jpg"}, + {"pv/a+b.jpg", "pv/a%2Bb.jpg"}, + {"pv/sub dir/x.jpg", "pv/sub%20dir/x.jpg"}, + } + for _, tc := range cases { + if got := encodePath(tc.in); got != tc.want { + t.Errorf("encodePath(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/main.go b/main.go index 6566c26..4ddc62e 100644 --- a/main.go +++ b/main.go @@ -167,6 +167,13 @@ func main() { AllowMethods: "GET,POST,PUT,DELETE,PATCH,OPTIONS", })) + // Echo X-Request-Id on every response, errors included, minting one when the + // caller did not send it. The customer app funnels every failure into a + // single retryable error state, so without this a support conversation + // about "it failed" has nothing to correlate against the logs. Registered + // before the logger so the id is available to it. + app.Use(middlewares.RequestID()) + // Structured Zap logger middleware app.Use(middlewares.ZapLogger()) diff --git a/middlewares/city_gate.go b/middlewares/city_gate.go index 9f15c5b..eed5b2b 100644 --- a/middlewares/city_gate.go +++ b/middlewares/city_gate.go @@ -39,3 +39,21 @@ func CityGateMiddleware(c *fiber.Ctx) error { "code": "CITY_NOT_SUPPORTED", }) } + +// PincodeInOperatingCity reports whether a pincode falls in a city Doormile +// runs in, and names it. +// +// Exported because the customer app's booking request does not carry a +// pickuppincode at all — its pickup point is a title/sub/lat/lng from the place +// search, so CityGateMiddleware's body sniff finds nothing and waves it +// through. A middleware that silently no-ops on the one caller it matters most +// for is worse than no middleware, so the customer handler resolves the pickup +// point to a pincode itself and asks this directly. +func PincodeInOperatingCity(pincode string) (string, bool) { + pincode = strings.TrimSpace(pincode) + if len(pincode) < 3 { + return "", false + } + city, ok := operatingCityPrefixes[pincode[:3]] + return city, ok +} diff --git a/middlewares/idempotency.go b/middlewares/idempotency.go index 83665ea..785d685 100644 --- a/middlewares/idempotency.go +++ b/middlewares/idempotency.go @@ -2,6 +2,8 @@ package middlewares import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "strconv" "strings" @@ -37,8 +39,7 @@ func Idempotency() fiber.Handler { if key == "" || db.Rdb == nil { return c.Next() } - uid, _ := c.Locals("userid").(int) - base := fmt.Sprintf("idem:%d:%s", uid, key) + base := "idem:" + idempotencyScope(c) + ":" + key ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -80,3 +81,32 @@ func Idempotency() fiber.Handler { return nil } } + +// idempotencyScope namespaces a key so one caller's stored response can never +// be replayed to another. +// +// For an authenticated request the caller's own user id is the scope, which is +// what this middleware has always used. +// +// An UNAUTHENTICATED request has no user id, and defaulting to 0 would put +// every anonymous caller in one namespace: two customers who happened to pick +// the same Idempotency-Key on POST /customer/auth/otp/verify would collide, and +// the second would be handed the first's access token, refresh token and +// customer record. So an anonymous request is scoped by the request path and a +// hash of its body instead — the same body replays, a different body does not, +// and one person's session can never be served to another. +// The authenticated form is byte-identical to what this middleware has always +// produced ("idem::"). Changing it would orphan every in-flight key +// in Redis at deploy time, and a rider retrying a pickup-complete across that +// boundary would execute it a second time instead of replaying — the exact +// double-collection this middleware exists to prevent. +func idempotencyScope(c *fiber.Ctx) string { + if uid, ok := c.Locals("userid").(int); ok && uid != 0 { + return fmt.Sprintf("%d", uid) + } + // Anonymous callers get their own namespace shape, which no previous key + // can collide with: every key written before this change had a numeric + // scope, and this one never is. + sum := sha256.Sum256(append([]byte(c.Path()+"\x00"), c.Body()...)) + return "anon-" + hex.EncodeToString(sum[:16]) +} diff --git a/middlewares/idempotency_test.go b/middlewares/idempotency_test.go new file mode 100644 index 0000000..a9db02f --- /dev/null +++ b/middlewares/idempotency_test.go @@ -0,0 +1,122 @@ +package middlewares + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" +) + +// The idempotency key namespace is a security boundary, not bookkeeping. +// +// POST /customer/auth/otp/verify is UNAUTHENTICATED, so c.Locals("userid") is +// absent there. Scoping on it anyway put every anonymous caller in one +// namespace: two customers picking the same Idempotency-Key would collide and +// the second would be handed the first's access token, refresh token and +// customer record. These tests pin the fix. + +// scopeFor runs idempotencyScope inside a real request and returns what it +// produced. +func scopeFor(t *testing.T, authedUserID int, path, body string) string { + t.Helper() + + app := fiber.New(fiber.Config{DisableStartupMessage: true}) + app.Post("/*", func(c *fiber.Ctx) error { + if authedUserID != 0 { + c.Locals("userid", authedUserID) + } + return c.SendString(idempotencyScope(c)) + }) + + req := httptest.NewRequest("POST", path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, 5000) + if err != nil { + t.Fatalf("test request: %v", err) + } + defer resp.Body.Close() + + buf := make([]byte, 256) + n, _ := resp.Body.Read(buf) + return string(buf[:n]) +} + +// Two anonymous callers sending DIFFERENT bodies must never share a namespace, +// even with an identical Idempotency-Key. This is the session-handover bug. +func TestAnonymousCallersNeverShareAnIdempotencyNamespace(t *testing.T) { + alice := scopeFor(t, 0, "/customer/auth/otp/verify", + `{"identifier":"+919876543210","code":"1111"}`) + bob := scopeFor(t, 0, "/customer/auth/otp/verify", + `{"identifier":"+919000000001","code":"2222"}`) + + if alice == bob { + t.Fatalf("two different anonymous requests share the scope %q — "+ + "one customer's session could be replayed to another", alice) + } + if alice == "" || bob == "" { + t.Fatal("empty scope: every request must land in some namespace") + } +} + +// The same anonymous caller repeating the SAME request must replay — that is +// the whole point of idempotency. +func TestIdenticalAnonymousRequestReplays(t *testing.T) { + body := `{"identifier":"+919876543210","code":"4821"}` + first := scopeFor(t, 0, "/customer/auth/otp/verify", body) + again := scopeFor(t, 0, "/customer/auth/otp/verify", body) + + if first != again { + t.Errorf("the same request produced two scopes (%q, %q) — a retry would "+ + "re-execute instead of replaying", first, again) + } +} + +// The authenticated format is byte-identical to what this middleware has always +// produced. Changing it would orphan every in-flight key in Redis at deploy +// time, and a rider retrying a pickup-complete across that boundary would +// collect COD twice instead of replaying. +func TestAuthenticatedScopeFormatIsUnchanged(t *testing.T) { + got := scopeFor(t, 42, "/miler/bookings/7/pickup-complete", `{}`) + if got != "42" { + t.Errorf("authenticated scope = %q, want %q — the stored key format must "+ + "not change across a deploy", got, "42") + } +} + +// An anonymous scope can never collide with an authenticated one: the old +// format is always numeric, the new one never is. +func TestAnonymousScopeCannotCollideWithAnAuthenticatedOne(t *testing.T) { + anon := scopeFor(t, 0, "/customer/auth/otp/verify", `{"code":"1"}`) + if !strings.HasPrefix(anon, "anon-") { + t.Errorf("anonymous scope = %q, want an 'anon-' prefix so it cannot look "+ + "like a user id", anon) + } +} + +// The operating-city gate, exported because the customer booking shape carries +// no pincode for CityGateMiddleware to sniff. +func TestPincodeInOperatingCity(t *testing.T) { + cases := []struct { + pincode string + wantCity string + wantOK bool + }{ + {"641012", "Coimbatore", true}, + {"600001", "Chennai", true}, + {"560034", "Bengaluru", true}, + {"500081", "Hyderabad", true}, + {"629001", "Nagercoil", true}, + {"110001", "", false}, // Delhi — not an operating city + {"12", "", false}, // too short to classify + {"", "", false}, + } + + for _, tc := range cases { + city, ok := PincodeInOperatingCity(tc.pincode) + if ok != tc.wantOK || city != tc.wantCity { + t.Errorf("PincodeInOperatingCity(%q) = (%q, %v), want (%q, %v)", + tc.pincode, city, ok, tc.wantCity, tc.wantOK) + } + } +} diff --git a/middlewares/logger.go b/middlewares/logger.go index 26fee72..5994a9a 100644 --- a/middlewares/logger.go +++ b/middlewares/logger.go @@ -19,21 +19,33 @@ func ZapLogger() fiber.Handler { method := c.Method() path := c.Path() + // Client identity and the request id travel with every log line. + // + // X-Client / X-Platform are what the customer app sends + // ("doormile-cx/1.0.0+12", "android"), and they are the only way to + // answer "is this failing on one build or everywhere" — which is the + // first question asked when an app-side report arrives. The request id + // is what correlates a customer saying "it failed" with the line that + // recorded the real error behind the customer-safe message. + // + // Recorded as empty rather than omitted when absent, so a caller that + // sends nothing is visibly a caller that sends nothing. + fields := []interface{}{ + "method", method, + "path", path, + "status", status, + "latency", latency, + "client", c.Get("X-Client"), + "platform", c.Get("X-Platform"), + } + if id, ok := c.Locals("requestid").(string); ok && id != "" { + fields = append(fields, "requestid", id) + } + if err != nil { - utils.Error("API request error", - "method", method, - "path", path, - "status", status, - "latency", latency, - "error", err.Error(), - ) + utils.Error("API request error", append(fields, "error", err.Error())...) } else { - utils.Info("API request success", - "method", method, - "path", path, - "status", status, - "latency", latency, - ) + utils.Info("API request success", fields...) } return err diff --git a/middlewares/requestid.go b/middlewares/requestid.go new file mode 100644 index 0000000..172ad08 --- /dev/null +++ b/middlewares/requestid.go @@ -0,0 +1,33 @@ +package middlewares + +import ( + "crypto/rand" + "encoding/hex" + + "github.com/gofiber/fiber/v2" +) + +// RequestID echoes the caller's X-Request-Id on every response, errors +// included, and mints one when the caller did not send it. The customer app +// funnels all failures into a single retryable error state, so a support +// conversation about "it failed" has nothing to correlate on unless the id the +// client already holds comes back on the failing response too. +// +// Also stashed in c.Locals("requestid") so handlers can log it alongside the +// real error they are hiding behind a customer-safe message. +func RequestID() fiber.Handler { + return func(c *fiber.Ctx) error { + id := c.Get("X-Request-Id") + if id == "" { + b := make([]byte, 8) + if _, err := rand.Read(b); err == nil { + id = hex.EncodeToString(b) + } + } + if id != "" { + c.Locals("requestid", id) + c.Set("X-Request-Id", id) + } + return c.Next() + } +} diff --git a/migrations/migrate.go b/migrations/migrate.go index e26f5b7..d943141 100644 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -47,6 +47,19 @@ func Migrate(db *gorm.DB) error { &models.MilerDutyLog{}, &models.MilerBreakLog{}, &models.MilerSupportTicket{}, + + // Customer app (doormile_cx). All additive: new tables plus new + // nullable/zero-default columns on pickupbookings, so an existing row + // and every console-created booking stay valid with no backfill. + &models.ServiceableState{}, + &models.ServiceableDistrict{}, + &models.PickupSlotTemplate{}, + &models.CustomerBookingLimit{}, + &models.BookingDestination{}, + &models.BookingParcelPhoto{}, + &models.BookingStageEvent{}, + &models.CustomerRefreshToken{}, + &models.CustomerDevice{}, ) if err != nil { @@ -94,5 +107,47 @@ func Migrate(db *gorm.DB) error { utils.Info("✅ consignments_status_check constraint includes Collected_By_Miler") } + // Human-facing identifiers for the customer app: DM-###### for a pickup + // booking, DMX######## for an order. Sequence-backed rather than random, + // because both columns are UNIQUE and a random short id collides long + // before a short id runs out — a collision here is a failed booking at the + // moment of payment, not a retry. + // + // No CYCLE and no MAXVALUE on purpose: past 999999 the format simply grows + // a digit (DM-1000000) instead of wrapping onto an id that already exists. + // Existing rows keep their old DM-BK-/DM-TRK- strings; nothing parses + // either format, so the two coexist safely. + if res := db.Exec(`CREATE SEQUENCE IF NOT EXISTS cx_booking_reference_seq START 100000 INCREMENT 1`); res.Error != nil { + utils.Error("❌ Failed to create cx_booking_reference_seq", "error", res.Error) + } else { + utils.Info("✅ cx_booking_reference_seq ready") + } + if res := db.Exec(`CREATE SEQUENCE IF NOT EXISTS cx_tracking_seq START 10000000 INCREMENT 1`); res.Error != nil { + utils.Error("❌ Failed to create cx_tracking_seq", "error", res.Error) + } else { + utils.Info("✅ cx_tracking_seq ready") + } + + // One booking must not hold two destinations at the same position: the + // customer addresses a destination by index in + // PATCH /customer/bookings/{ref}/destinations/{index}, so a duplicate seq + // makes that route ambiguous and would let an edit land on the wrong + // address. + if res := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bookingdestinations_booking_seq + ON bookingdestinations (bookingid, seq)`); res.Error != nil { + utils.Error("❌ Failed to create bookingdestinations (bookingid, seq) unique index", "error", res.Error) + } else { + utils.Info("✅ bookingdestinations (bookingid, seq) unique index ready") + } + + // The timeline is read as "every event for this booking, oldest first" on + // every tracking poll, which is the hottest customer read there is. + if res := db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookingstageevents_booking_time + ON bookingstageevents (bookingid, occurredat)`); res.Error != nil { + utils.Error("❌ Failed to create bookingstageevents (bookingid, occurredat) index", "error", res.Error) + } else { + utils.Info("✅ bookingstageevents (bookingid, occurredat) index ready") + } + return nil } diff --git a/models/booking.go b/models/booking.go index d1d4205..f506811 100644 --- a/models/booking.go +++ b/models/booking.go @@ -83,10 +83,58 @@ type PickupBooking struct { Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` + // ── Customer-app (doormile_cx) columns ────────────────────────────────── + // All additive and nullable/zero-valued, so every existing row and every + // console-created booking stays valid without a backfill. + + // Slotid is the pickup window the customer chose, as the opaque id the app + // sent back ("slot_20260905_t1"). Preferredpickupfrom/to carry the same + // window as real times for the assignment engine; this keeps the customer's + // own choice recorded verbatim, so a slot definition edited later cannot + // silently rewrite what they picked. + Slotid string `json:"slotid" gorm:"column:slotid;size:40"` + + // Customerstage is the operational stage the customer app renders, one of + // the nine keys in constants.CxStage*. Distinct from Status, which is the + // booking lifecycle the console and the miler app read: Status stops moving + // at Converted_To_Consignment while the parcels keep going, and the two + // vocabularies are not a renaming of each other. Derived from the same + // writes, stored so a read is one row rather than a replay of the history. + Customerstage string `json:"customerstage" gorm:"column:customerstage;size:24"` + // Customerstatus is active / completed / cancelled. Derived, but sent + // explicitly — making the client infer it from a stage is how two surfaces + // end up disagreeing about whether an order is finished. + Customerstatus string `json:"customerstatus" gorm:"column:customerstatus;size:16"` + + // The estimate range the customer was actually shown at booking time, in + // whole rupees. Kept forever, including after settlement: the receipt + // renders amountPaid − estimate min as the weight adjustment, and a price + // dispute needs the number that was on screen, not a re-run of today's + // pricing rules. + Estimateminrupees int `json:"estimateminrupees" gorm:"column:estimateminrupees;default:0"` + Estimatemaxrupees int `json:"estimatemaxrupees" gorm:"column:estimatemaxrupees;default:0"` + // Routekm is the pickup→destination distance the estimate was priced on; + // it drives the route outline and the receipt. + Routekm float64 `json:"routekm" gorm:"column:routekm;default:0"` + + // Pickuptitle/Pickupsub are the two-line pickup label the customer picked + // from the place search ("12 Nehru Street" / "Gandhipuram, Coimbatore + // 641012"). Pickupaddress remains the single flat string the rest of the + // system uses; these keep the split the app renders without it having to + // re-parse one back into two. + Pickuptitle string `json:"pickuptitle" gorm:"column:pickuptitle;size:64"` + Pickupsub string `json:"pickupsub" gorm:"column:pickupsub"` + + // Cancelreason is free text or one of the app's five presets. Nullable in + // spirit — an empty string means the customer gave no reason, which is + // allowed. + Cancelreason string `json:"cancelreason" gorm:"column:cancelreason;size:120"` + // Relations Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"` ServiceOptions []BookingServiceOption `json:"serviceoptions" gorm:"foreignKey:Bookingid"` Payments []BookingPayment `json:"payments" gorm:"foreignKey:Bookingid"` + Destinations []BookingDestination `json:"destinations" gorm:"foreignKey:Bookingid"` } func (PickupBooking) TableName() string { @@ -94,8 +142,16 @@ func (PickupBooking) TableName() string { } type BookingParcel struct { - Bookingparcelid int `json:"bookingparcelid" gorm:"primaryKey;column:bookingparcelid"` - Bookingid int `json:"bookingid" gorm:"column:bookingid"` + Bookingparcelid int `json:"bookingparcelid" gorm:"primaryKey;column:bookingparcelid"` + Bookingid int `json:"bookingid" gorm:"column:bookingid"` + // Bookingdestinationid says which destination this package is going to. + // Null on console-created and pre-existing bookings, which have exactly one + // delivery address and therefore no ambiguity. On a customer-app booking it + // is what lets the miler weigh three packages for Chennai and one for + // Kochi and have each weight settle against the right order — without it, + // a multi-destination pickup has one pile of parcels and no way to say + // which parcel belongs to which tracking number. + Bookingdestinationid *int `json:"bookingdestinationid" gorm:"column:bookingdestinationid;index"` Itemcategory string `json:"itemcategory" gorm:"column:itemcategory"` Itemdescription string `json:"itemdescription" gorm:"column:itemdescription"` Declaredvalue float64 `json:"declaredvalue" gorm:"column:declaredvalue"` diff --git a/models/customer_app.go b/models/customer_app.go new file mode 100644 index 0000000..b4f33a5 --- /dev/null +++ b/models/customer_app.go @@ -0,0 +1,241 @@ +package models + +import "time" + +// Schema for the customer app (doormile_cx) surface. +// +// The one structural idea here is that a customer books a PICKUP, not a +// shipment: one booking fans out to 1..N destinations, and each destination +// becomes its own consignment with its own tracking number when the miler +// completes the pickup. A single-destination booking is the same shape, not a +// special case — pickupbookings keeps its flat delivery* columns mirrored from +// destination 0 so the miler app, the hub console and the routing code keep +// reading the row they already read. + +// ServiceableState is a state the customer may choose a destination in. The +// picker hides any state with no available district, but the row stays so ops +// can open one without an app release. +type ServiceableState struct { + Statecode string `json:"statecode" gorm:"primaryKey;column:statecode;size:8"` + Statename string `json:"statename" gorm:"column:statename;not null"` + // Transittag is display copy shown under the state name ("Ultra-fast + // transit", "Opening soon"). Capped at 22 characters by the design. + Transittag string `json:"transittag" gorm:"column:transittag;size:22"` + Displayorder int `json:"displayorder" gorm:"column:displayorder;default:0"` + Status string `json:"status" gorm:"column:status;default:Active"` // Active, InActive + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` +} + +func (ServiceableState) TableName() string { return "serviceablestates" } + +// ServiceableDistrict is one district inside a state. Unavailable districts are +// returned too: the picker filters them out but the app shows their names in a +// quiet "coming soon" line, so dropping them would lose real copy. +type ServiceableDistrict struct { + Districtcode string `json:"districtcode" gorm:"primaryKey;column:districtcode;size:16"` + Statecode string `json:"statecode" gorm:"column:statecode;size:8;index;not null"` + Districtname string `json:"districtname" gorm:"column:districtname;not null"` + Available bool `json:"available" gorm:"column:available;default:false"` + // Note says why a district is not available — "Opening soon", "Paused this + // week". Only meaningful when Available is false. + Note string `json:"note" gorm:"column:note;size:60"` + // Hubid is the base that serves this district. Nullable: a district can be + // announced before its hub exists. + Hubid *int `json:"hubid" gorm:"column:hubid"` + // Promise is the delivery promise shown on the destination card, + // "Next-day delivery" / "2-day delivery". + Promise string `json:"promise" gorm:"column:promise;size:40"` + // Centre coordinates price and route a destination before any street + // address exists — only state and district are required at booking time, so + // this is frequently the only geography a destination has. + Centrelatitude float64 `json:"centrelatitude" gorm:"column:centrelatitude"` + Centrelongitude float64 `json:"centrelongitude" gorm:"column:centrelongitude"` + Pincodeprefix string `json:"pincodeprefix" gorm:"column:pincodeprefix;size:6"` + Displayorder int `json:"displayorder" gorm:"column:displayorder;default:0"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` +} + +func (ServiceableDistrict) TableName() string { return "serviceabledistricts" } + +// PickupSlotTemplate is a recurring daily pickup window. The customer-facing +// slot id is minted per date from the template code, so a slot always resolves +// back to a real preferredpickupfrom/to on the booking — a slot ops cannot +// staff is worse than no slot. +type PickupSlotTemplate struct { + Slottemplateid int `json:"slottemplateid" gorm:"primaryKey;column:slottemplateid"` + // Code is the stable part of the slot id: "t1" renders as + // "slot_20260905_t1". + Code string `json:"code" gorm:"column:code;size:16;not null"` + Starthour int `json:"starthour" gorm:"column:starthour;not null"` + Startminute int `json:"startminute" gorm:"column:startminute;default:0"` + Endhour int `json:"endhour" gorm:"column:endhour;not null"` + Endminute int `json:"endminute" gorm:"column:endminute;default:0"` + // Capacity is how many pickups this window absorbs in one zone. + Capacity int `json:"capacity" gorm:"column:capacity;default:20"` + // Applocationid scopes a template to one city. Null means every city. + Applocationid *int `json:"applocationid" gorm:"column:applocationid;index"` + // Tag is the single promotional label the design allows on at most one slot + // ("Fastest pickup"). Caption is the softer line under it. + Tag string `json:"tag" gorm:"column:tag;size:40"` + Caption string `json:"caption" gorm:"column:caption;size:60"` + Displayorder int `json:"displayorder" gorm:"column:displayorder;default:0"` + Status string `json:"status" gorm:"column:status;default:Active"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` +} + +func (PickupSlotTemplate) TableName() string { return "pickupslottemplates" } + +// CustomerBookingLimit caps a pickup. Kept in the database rather than in the +// app so ops can vary it by city or tier without an app release. A row with a +// null applocationid is the global default. +type CustomerBookingLimit struct { + Limitid int `json:"limitid" gorm:"primaryKey;column:limitid"` + Applocationid *int `json:"applocationid" gorm:"column:applocationid;index"` + Maxpackages int `json:"maxpackages" gorm:"column:maxpackages;default:20"` + Maxdestinations int `json:"maxdestinations" gorm:"column:maxdestinations;default:5"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` +} + +func (CustomerBookingLimit) TableName() string { return "customerbookinglimits" } + +// BookingDestination is one place inside a pickup booking, and the row that +// becomes a consignment at pickup completion. This is the table that makes +// "one visit, N orders" expressible: before it a booking carried exactly one +// delivery address in its own columns and there was nowhere to put the second. +type BookingDestination struct { + Bookingdestinationid int `json:"bookingdestinationid" gorm:"primaryKey;column:bookingdestinationid"` + Bookingid int `json:"bookingid" gorm:"column:bookingid;index;not null"` + // Seq is the 0-based position within the booking and is the {index} in + // PATCH /customer/bookings/{ref}/destinations/{index}. Stable for the life + // of the booking — destinations are never reordered, because the customer + // addresses them by position. + Seq int `json:"seq" gorm:"column:seq;not null"` + + // Only state and district are required at booking time. Names are stored + // alongside the codes rather than joined at read time: the client renders + // "Chennai, Tamil Nadu" straight from the booking and never looks a code + // up, and a district renamed later must not silently rewrite an address the + // customer already agreed to. + Statecode string `json:"statecode" gorm:"column:statecode;size:8;not null"` + Statename string `json:"statename" gorm:"column:statename;not null"` + Districtcode string `json:"districtcode" gorm:"column:districtcode;size:16;not null"` + Districtname string `json:"districtname" gorm:"column:districtname;not null"` + Packagecount int `json:"packagecount" gorm:"column:packagecount;default:1"` + + // Everything below is optional at booking time and may be completed by the + // customer afterwards or by the miler at the door. Empty means "not added + // yet", which the UI states explicitly rather than hiding. + Street string `json:"street" gorm:"column:street"` + Building string `json:"building" gorm:"column:building"` + Landmark string `json:"landmark" gorm:"column:landmark"` + Recipientname string `json:"recipientname" gorm:"column:recipientname"` + Recipientphone string `json:"recipientphone" gorm:"column:recipientphone"` + Instructions string `json:"instructions" gorm:"column:instructions"` + Pinlatitude *float64 `json:"pinlatitude" gorm:"column:pinlatitude"` + Pinlongitude *float64 `json:"pinlongitude" gorm:"column:pinlongitude"` + Pincode string `json:"pincode" gorm:"column:pincode;size:10"` + + // Codamount is money the miler collects at this door on the customer's + // behalf. Doormile is the carrier, never the seller — this is the + // customer's own collection, and it is per destination because it is + // collected per delivery. + Codamount float64 `json:"codamount" gorm:"column:codamount;default:0"` + + // Set at pickup completion, when this destination becomes an order. + Consignmentid *int `json:"consignmentid" gorm:"column:consignmentid;index"` + Trackingno string `json:"trackingno" gorm:"column:trackingno;size:32;index"` + // Stage is the per-order stage from order_created onward. Empty until the + // order exists; the booking's own stage governs everything before that. + Stage string `json:"stage" gorm:"column:stage;size:24"` + Expecteddeliveryat *time.Time `json:"expecteddeliveryat" gorm:"column:expecteddeliveryat"` + Deliveredat *time.Time `json:"deliveredat" gorm:"column:deliveredat"` + + // Verification is what the miler recorded at the door: the weight the price + // actually settled on, and who recorded it. Photos live in + // bookingparcelphotos, one row per package. + Verifiedweightkg *float64 `json:"verifiedweightkg" gorm:"column:verifiedweightkg"` + Verifiedat *time.Time `json:"verifiedat" gorm:"column:verifiedat"` + Verifiedbyuserid *int `json:"verifiedbyuserid" gorm:"column:verifiedbyuserid"` + + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` + Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"` +} + +func (BookingDestination) TableName() string { return "bookingdestinations" } + +// BookingParcelPhoto is a photograph the miler took of a package at the door. +// It is the evidence half of the receipt — a settled weight without the photo +// is a number the customer has no way to check. +type BookingParcelPhoto struct { + Photoid int `json:"photoid" gorm:"primaryKey;column:photoid"` + Bookingid int `json:"bookingid" gorm:"column:bookingid;index;not null"` + Bookingdestinationid *int `json:"bookingdestinationid" gorm:"column:bookingdestinationid;index"` + // Objectkey is the storage key. The customer is served a short-lived signed + // URL derived from it, never a permanent public link — a parcel photo can + // show the inside of someone's doorway. + Objectkey string `json:"objectkey" gorm:"column:objectkey;not null"` + Capturedbyuserid *int `json:"capturedbyuserid" gorm:"column:capturedbyuserid"` + Capturedat time.Time `json:"capturedat" gorm:"column:capturedat;default:CURRENT_TIMESTAMP"` +} + +func (BookingParcelPhoto) TableName() string { return "bookingparcelphotos" } + +// BookingStageEvent is the append-only audit log every customer timeline is +// built from: one row per stage actually reached, with the real time it was +// reached and who caused it. Nothing here is synthesised or backfilled — a +// timeline with invented timestamps is worse than a short one, because the +// customer cannot tell which entries are real. +type BookingStageEvent struct { + Stageeventid int `json:"stageeventid" gorm:"primaryKey;column:stageeventid"` + Bookingid int `json:"bookingid" gorm:"column:bookingid;index;not null"` + // Bookingdestinationid is null for the booking-level stages (booked through + // order_created) and set for the per-order ones (in_transit onward), which + // may differ between destinations of the same booking. + Bookingdestinationid *int `json:"bookingdestinationid" gorm:"column:bookingdestinationid;index"` + Stage string `json:"stage" gorm:"column:stage;size:24;not null"` + // Actortype/Actorid answer "who did this" — miler, ops user, the customer, + // or the system. Required by the audit rule, and the only way to explain a + // cancellation to a customer who did not make it. + Actortype string `json:"actortype" gorm:"column:actortype;size:16;not null"` + Actorid *int `json:"actorid" gorm:"column:actorid"` + Source string `json:"source" gorm:"column:source;size:64"` + Remarks string `json:"remarks" gorm:"column:remarks"` + Occurredat time.Time `json:"occurredat" gorm:"column:occurredat;index;not null"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` +} + +func (BookingStageEvent) TableName() string { return "bookingstageevents" } + +// CustomerRefreshToken backs the 60-day session. Stored hashed: a leaked +// database row must not itself be a usable credential. Rotation is recorded via +// Replacedbyid so a replayed old token identifies the chain it came from. +type CustomerRefreshToken struct { + Refreshtokenid int `json:"refreshtokenid" gorm:"primaryKey;column:refreshtokenid"` + Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid;index;not null"` + Tokenhash string `json:"-" gorm:"column:tokenhash;size:64;uniqueIndex;not null"` + Expiresat time.Time `json:"expiresat" gorm:"column:expiresat;not null"` + Revokedat *time.Time `json:"revokedat" gorm:"column:revokedat"` + Replacedbyid *int `json:"replacedbyid" gorm:"column:replacedbyid"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` +} + +func (CustomerRefreshToken) TableName() string { return "customerrefreshtokens" } + +// CustomerDevice is a push registration. One row per device token, not per +// customer: a customer with a phone and a tablet must get the notification on +// both, and appcustomers.device_token could only ever hold the last one. +type CustomerDevice struct { + Customerdeviceid int `json:"customerdeviceid" gorm:"primaryKey;column:customerdeviceid"` + Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid;index;not null"` + Token string `json:"token" gorm:"column:token;uniqueIndex;not null"` + Platform string `json:"platform" gorm:"column:platform;size:16"` + Appversion string `json:"appversion" gorm:"column:appversion;size:32"` + Lastseenat time.Time `json:"lastseenat" gorm:"column:lastseenat;default:CURRENT_TIMESTAMP"` + Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` +} + +func (CustomerDevice) TableName() string { return "customerdevices" } diff --git a/models/customer_app_test.go b/models/customer_app_test.go new file mode 100644 index 0000000..7d877ee --- /dev/null +++ b/models/customer_app_test.go @@ -0,0 +1,175 @@ +package models + +import ( + "reflect" + "strings" + "testing" +) + +// Table names and column tags are strings the compiler never checks. A typo in +// either produces a binary that builds cleanly and then fails against the +// database at runtime — which, for the customer app, means the first real +// booking. These are cheap tests for a class of bug nothing else catches. + +// Every customer-app model maps to the table AutoMigrate creates and the seed +// fills. A mismatch here means seed_customer_app.sql populates one table while +// the code reads another. +func TestCustomerAppTableNames(t *testing.T) { + cases := []struct { + model interface{ TableName() string } + want string + }{ + {ServiceableState{}, "serviceablestates"}, + {ServiceableDistrict{}, "serviceabledistricts"}, + {PickupSlotTemplate{}, "pickupslottemplates"}, + {CustomerBookingLimit{}, "customerbookinglimits"}, + {BookingDestination{}, "bookingdestinations"}, + {BookingParcelPhoto{}, "bookingparcelphotos"}, + {BookingStageEvent{}, "bookingstageevents"}, + {CustomerRefreshToken{}, "customerrefreshtokens"}, + {CustomerDevice{}, "customerdevices"}, + } + + for _, tc := range cases { + if got := tc.model.TableName(); got != tc.want { + t.Errorf("%T.TableName() = %q, want %q", + tc.model, got, tc.want) + } + } +} + +// columnOf reads the gorm column name off a struct field, the way the driver +// does. +func columnOf(t *testing.T, model interface{}, field string) string { + t.Helper() + + f, ok := reflect.TypeOf(model).FieldByName(field) + if !ok { + t.Fatalf("%T has no field %s", model, field) + } + for _, part := range strings.Split(f.Tag.Get("gorm"), ";") { + if strings.HasPrefix(part, "column:") { + return strings.TrimPrefix(part, "column:") + } + } + return "" +} + +// The columns the fan-out is built on. bookingdestinations.seq in particular is +// addressed by position in PATCH .../destinations/{index} and is what stop +// ordering sorts by — a renamed column there silently reorders a rider's route. +func TestBookingDestinationColumns(t *testing.T) { + want := map[string]string{ + "Bookingdestinationid": "bookingdestinationid", + "Bookingid": "bookingid", + "Seq": "seq", + "Statecode": "statecode", + "Districtcode": "districtcode", + "Packagecount": "packagecount", + "Recipientname": "recipientname", + "Recipientphone": "recipientphone", + "Codamount": "codamount", + "Consignmentid": "consignmentid", + "Trackingno": "trackingno", + "Stage": "stage", + "Verifiedweightkg": "verifiedweightkg", + } + for field, col := range want { + if got := columnOf(t, BookingDestination{}, field); got != col { + t.Errorf("BookingDestination.%s column = %q, want %q", field, got, col) + } + } +} + +// The columns added to the live pickupbookings table. These land on 512 +// existing rows, so the names have to match what migrate.go creates. +func TestPickupBookingCustomerColumns(t *testing.T) { + want := map[string]string{ + "Slotid": "slotid", + "Customerstage": "customerstage", + "Customerstatus": "customerstatus", + "Estimateminrupees": "estimateminrupees", + "Estimatemaxrupees": "estimatemaxrupees", + "Routekm": "routekm", + "Pickuptitle": "pickuptitle", + "Pickupsub": "pickupsub", + "Cancelreason": "cancelreason", + } + for field, col := range want { + if got := columnOf(t, PickupBooking{}, field); got != col { + t.Errorf("PickupBooking.%s column = %q, want %q", field, got, col) + } + } + + // The link that lets a parcel know which order it belongs to. + if got := columnOf(t, BookingParcel{}, "Bookingdestinationid"); got != "bookingdestinationid" { + t.Errorf("BookingParcel.Bookingdestinationid column = %q, want %q", + got, "bookingdestinationid") + } +} + +// A refresh token is a bearer credential valid for sixty days. It must never be +// serialised outward — a JSON tag of anything but "-" would put it in a +// response body. +func TestRefreshTokenHashIsNeverSerialised(t *testing.T) { + f, ok := reflect.TypeOf(CustomerRefreshToken{}).FieldByName("Tokenhash") + if !ok { + t.Fatal("CustomerRefreshToken has no Tokenhash field") + } + if got := f.Tag.Get("json"); got != "-" { + t.Errorf("Tokenhash json tag = %q, want \"-\" — a stored credential must "+ + "never leave the server", got) + } +} + +// The delivery OTP is the receiver's proof, not the rider's. Returning it in a +// response would hand the rider the code they are supposed to be told at the +// door. +func TestDeliveryOtpIsNeverSerialised(t *testing.T) { + f, ok := reflect.TypeOf(Consignment{}).FieldByName("Deliveryotp") + if !ok { + t.Fatal("Consignment has no Deliveryotp field") + } + if got := f.Tag.Get("json"); got != "-" { + t.Errorf("Deliveryotp json tag = %q, want \"-\"", got) + } +} + +// The customer PIN hash predates the OTP flow and is now unused, but the column +// still exists. It must stay unserialised. +func TestCustomerPinHashIsNeverSerialised(t *testing.T) { + f, ok := reflect.TypeOf(AppCustomer{}).FieldByName("Loginpinhash") + if !ok { + t.Fatal("AppCustomer has no Loginpinhash field") + } + if got := f.Tag.Get("json"); got != "-" { + t.Errorf("Loginpinhash json tag = %q, want \"-\"", got) + } +} + +// Two columns on pickupbookings that are computed, not stored. A missing +// gorm:"-" would make AutoMigrate create real columns for them and the driver +// would try to write there. +func TestComputedBookingFieldsAreNotColumns(t *testing.T) { + for _, field := range []string{"Consignmentstatus", "Trackingno"} { + f, ok := reflect.TypeOf(PickupBooking{}).FieldByName(field) + if !ok { + t.Fatalf("PickupBooking has no field %s", field) + } + if got := f.Tag.Get("gorm"); got != "-" { + t.Errorf("PickupBooking.%s gorm tag = %q, want \"-\" — it is filled in "+ + "by handlers, not stored", field, got) + } + } +} + +// The device token is unique across the whole table, not per customer: a shared +// handset must be REASSIGNED rather than duplicated, or one person's parcel +// updates reach another person's phone. +func TestCustomerDeviceTokenIsGloballyUnique(t *testing.T) { + f, _ := reflect.TypeOf(CustomerDevice{}).FieldByName("Token") + if tag := f.Tag.Get("gorm"); !strings.Contains(tag, "uniqueIndex") { + t.Errorf("CustomerDevice.Token gorm tag = %q, want a uniqueIndex — the "+ + "upsert on conflict depends on it", tag) + } +} diff --git a/routes/routes.go b/routes/routes.go index 89374ce..966419a 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -78,18 +78,46 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { }) // -------------------- - // CUSTOMER APIS + // CUSTOMER APIS — doormile_cx // -------------------- + // The customer books a PICKUP, not a shipment: one visit, 1..N + // destinations, and a tracking number per destination minted only when the + // miler completes the pickup. Everything under /customer/* answers in the + // customer envelope ({success, data, message}; errors carry error.code) — + // auth included, deliberately unlike /miler/verify-pin, whose payload sits + // outside `data` and cost the miler client a release to discover. + // + // This replaces the PIN-based customer surface (register / login / + // verify-pin / reset-pin, and the single-destination booking create, list, + // detail and cancel). Those were retired rather than moved: a booking with + // one delivery address in its own columns is not a shape the product has + // any more. customer := api.Group("/customer") - customer.Post("/register", controllers.RegisterCustomer(cfg)) - customer.Post("/login", authThrottle, controllers.LoginCustomer) - customer.Post("/verify-pin", authThrottle, controllers.VerifyCustomerPin(cfg)) - customer.Post("/reset-pin", authThrottle, controllers.ResetCustomerPin) - customer.Post("/send-email-otp", authThrottle, controllers.SendCustomerEmailOtp(cfg)) - customer.Post("/verify-email-otp", authThrottle, controllers.VerifyCustomerEmailOtp()) + + // Auth — a 4-digit code to a phone or an email address, no password + // anywhere. Throttled on the shared budget with every other credential + // endpoint so an attacker cannot reset it by rotating between them. + customer.Post("/auth/otp/request", authThrottle, controllers.CxRequestOtp(cfg)) + customer.Post("/auth/signup", authThrottle, controllers.CxSignup(cfg)) + // Idempotent: the client retries on flaky networks, and a replayed verify + // must return the original session rather than mint a second one. + customer.Post("/auth/otp/verify", authThrottle, middlewares.Idempotency(), controllers.CxVerifyOtp(cfg)) + customer.Post("/auth/refresh", authThrottle, controllers.CxRefresh(cfg)) + + // Serviceability and configuration are read before sign-in: the booking + // form is explorable without an account, and gating the state picker behind + // auth would make the app's first screen a login wall. + customer.Get("/serviceability/states", controllers.GetCxStates) + customer.Get("/serviceability/states/:stateCode/districts", controllers.GetCxDistricts) + customer.Get("/pickup-slots", controllers.GetCxPickupSlots) + customer.Get("/config/booking-limits", controllers.GetCxBookingLimits) // Authenticated Customer App routes customerAuth := customer.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(9)) + + customerAuth.Get("/auth/me", controllers.CxMe) + customerAuth.Post("/auth/logout", controllers.CxLogout) + customerAuth.Get("/profile", controllers.GetCustomerProfile) customerAuth.Put("/profile", controllers.UpdateCustomerProfile) @@ -98,14 +126,38 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { customerAuth.Put("/locations/:id", controllers.UpdateCustomerLocation) customerAuth.Delete("/locations/:id", controllers.DeleteCustomerLocation) - customerAuth.Put("/device-token", controllers.SaveCustomerDeviceToken) + // Push registration. One row per device token, not one column per customer: + // a phone and a tablet both have to receive the delivery notification. + customerAuth.Post("/devices", controllers.RegisterCxDevice) + customerAuth.Delete("/devices/:token", controllers.UnregisterCxDevice) - customerAuth.Post("/bookings", middlewares.CityGateMiddleware, controllers.CreateCustomerBooking) - customerAuth.Get("/bookings", controllers.GetCustomerBookings) - customerAuth.Get("/bookings/:bookingid", controllers.GetCustomerBookingDetails) - customerAuth.Post("/bookings/:bookingid/cancel", controllers.CancelCustomerBooking) - customerAuth.Get("/bookings/:bookingid/price", controllers.GetCustomerBookingQuote) - customerAuth.Get("/track/:trackingno", controllers.TrackConsignment) + // Places are proxied, never keyed: the legacy rider app shipped a Maps key + // in the binary and it had to be revoked. The customer app is handed + // results, not credentials. + customerAuth.Get("/places/reverse-geocode", controllers.ReverseGeocodeCx(cfg)) + customerAuth.Get("/places/search", controllers.SearchCxPlaces(cfg)) + + // Called on every route and package-count change, so it is cheap and + // cacheable — and a failed estimate never blocks a booking. + customerAuth.Post("/fare/estimate", controllers.EstimateCxFare) + + // Idempotency-Key on create: the client retries over bad networks and a + // duplicate pickup is unacceptable. + customerAuth.Post("/bookings", middlewares.CityGateMiddleware, middlewares.Idempotency(), controllers.CreateCxBooking) + customerAuth.Get("/bookings", controllers.GetCxBookings) + customerAuth.Get("/bookings/:reference", controllers.GetCxBookingDetail) + customerAuth.Post("/bookings/:reference/cancel", controllers.CancelCxBooking) + customerAuth.Patch("/bookings/:reference/destinations/:index", controllers.PatchCxDestination) + + // One order by tracking number, for push deep links (doormile://track/…). + customerAuth.Get("/orders/:trackingId", controllers.GetCxOrder) + + // QA only. Refused outright unless ENV is non-production AND + // CX_ALLOW_STAGE_OVERRIDE=true — two independent switches, because either + // one being wrong in production would let any customer mark their own + // parcel delivered. It exists so every tracking state is reachable for + // design QA and the app's debug stepper can be deleted. + customerAuth.Post("/ops/bookings/:reference/stage", controllers.ForceCxStage) // -------------------- // MILER APIS diff --git a/routes/routes_customer_test.go b/routes/routes_customer_test.go new file mode 100644 index 0000000..b90882a --- /dev/null +++ b/routes/routes_customer_test.go @@ -0,0 +1,405 @@ +package routes_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" +) + +// Routing contract for the customer surface, plus a regression guard proving +// the miler, admin and hub surfaces were not touched. +// +// Same boundary as routes_logistics_test.go: everything up to the handler is +// tested here with no database. A wrong path, a route registered under the +// wrong group, a missing auth gate, or a param route shadowing a static one are +// all real bugs that compile perfectly and that unit tests cannot see. A 200 is +// never claimed — that needs Postgres and Redis. + +// cxAuthedRoutes is every authenticated route in the customer contract. If a +// path here stops resolving, the app gets a router 404 with no envelope at all +// and the client's error state cannot explain it. +var cxAuthedRoutes = []struct { + method string + path string + body string +}{ + {http.MethodGet, "/api/v1/customer/auth/me", ""}, + {http.MethodPost, "/api/v1/customer/auth/logout", `{}`}, + + {http.MethodGet, "/api/v1/customer/profile", ""}, + {http.MethodPut, "/api/v1/customer/profile", `{"name":"Joe Oommen"}`}, + + {http.MethodGet, "/api/v1/customer/locations", ""}, + {http.MethodPost, "/api/v1/customer/locations", `{"address":"a","pincode":"641012","latitude":11.0,"longitude":76.9}`}, + {http.MethodPut, "/api/v1/customer/locations/1", `{"address":"a"}`}, + {http.MethodDelete, "/api/v1/customer/locations/1", ""}, + + {http.MethodPost, "/api/v1/customer/devices", `{"token":"abc","platform":"android"}`}, + {http.MethodDelete, "/api/v1/customer/devices/abc", ""}, + + {http.MethodGet, "/api/v1/customer/places/reverse-geocode?lat=11.0&lng=76.9", ""}, + {http.MethodGet, "/api/v1/customer/places/search?q=brookefields", ""}, + + {http.MethodPost, "/api/v1/customer/fare/estimate", `{"destinations":[{"stateCode":"TN","districtCode":"TN-MAA","packageCount":1}]}`}, + + {http.MethodPost, "/api/v1/customer/bookings", `{"slotId":"slot_20991231_t1","destinations":[]}`}, + {http.MethodGet, "/api/v1/customer/bookings", ""}, + {http.MethodGet, "/api/v1/customer/bookings/DM-482913", ""}, + {http.MethodPost, "/api/v1/customer/bookings/DM-482913/cancel", `{"reason":"Package not ready"}`}, + {http.MethodPatch, "/api/v1/customer/bookings/DM-482913/destinations/0", `{"street":"12th Main"}`}, + + {http.MethodGet, "/api/v1/customer/orders/DMX10482913", ""}, + + {http.MethodPost, "/api/v1/customer/ops/bookings/DM-482913/stage", `{"stage":"delivered"}`}, +} + +// cxPublicRoutes are reachable without a token on purpose: the booking form is +// explorable before sign-in, and gating the state picker behind auth would make +// the app's first screen a login wall. +var cxPublicRoutes = []struct { + method string + path string + body string +}{ + {http.MethodPost, "/api/v1/customer/auth/otp/request", `{"identifier":""}`}, + {http.MethodPost, "/api/v1/customer/auth/signup", `{"name":"J"}`}, + {http.MethodPost, "/api/v1/customer/auth/otp/verify", `{"identifier":"nope","code":""}`}, + {http.MethodPost, "/api/v1/customer/auth/refresh", `{"refreshToken":""}`}, +} + +// Every authenticated customer route exists and is gated. A missing token must +// produce 401, never 404 (route absent) and never 500 (gate skipped and the +// handler reached a nil database). +func TestCustomerRoutesRequireATokenAndExist(t *testing.T) { + app := newApp() + + for _, r := range cxAuthedRoutes { + t.Run(r.method+" "+r.path, func(t *testing.T) { + status, body := do(t, app, r.method, r.path, "", r.body) + + if status == fiber.StatusNotFound { + t.Fatalf("route is not registered — the client would get a router 404 with no envelope (body: %s)", body) + } + if status >= 500 { + t.Fatalf("status = %d: the auth gate did not stop this before the handler (body: %s)", status, body) + } + if status != fiber.StatusUnauthorized { + t.Errorf("status = %d without a token, want 401 (body: %s)", status, body) + } + }) + } +} + +// A customer route must refuse a token from any other surface. Role 9 is the +// customer; 5 is a miler, 1 an admin, 6 hub staff. A miler token opening a +// customer's booking would be a cross-surface data leak. +func TestCustomerRoutesRefuseOtherRoles(t *testing.T) { + app := newApp() + + for _, roleID := range []int{1, 3, 4, 5, 6} { + bearer := token(t, 99, roleID) + + for _, r := range cxAuthedRoutes { + status, body := do(t, app, r.method, r.path, bearer, r.body) + + if status != fiber.StatusForbidden { + t.Errorf("role %d on %s %s: status = %d, want 403 (body: %s)", + roleID, r.method, r.path, status, body) + } + } + } +} + +// The pre-auth routes are reachable without a token. They still must not 404 +// (route missing) and must not 500 — a malformed identifier is the caller's +// mistake and has to be answered as one. +func TestCustomerPublicRoutesAreReachableWithoutAToken(t *testing.T) { + app := newApp() + + for _, r := range cxPublicRoutes { + t.Run(r.method+" "+r.path, func(t *testing.T) { + status, body := do(t, app, r.method, r.path, "", r.body) + + if status == fiber.StatusNotFound { + t.Fatalf("route is not registered (body: %s)", body) + } + if status >= 500 { + t.Fatalf("status = %d on a malformed request, want 4xx (body: %s)", status, body) + } + if status < 400 { + t.Errorf("status = %d on a deliberately invalid body, want 4xx (body: %s)", status, body) + } + // The refusal has to come from the HANDLER, in the customer + // envelope, not from the auth middleware. These four routes are + // reached before sign-in, so a bare "authorization header is + // required" here would mean one had been registered after the + // group's Use and silently put behind a login wall. + if !strings.Contains(body, `"error"`) { + t.Errorf("refusal did not come from the handler — this route may have been "+ + "registered behind the auth middleware (body: %s)", body) + } + }) + } +} + +// The retired PIN surface must be gone. Leaving it registered would keep a +// credential endpoint alive that could reset any account from a phone number, +// and would let the old app keep writing single-destination bookings that the +// new tracking screen cannot render. +func TestRetiredCustomerRoutesAreRemoved(t *testing.T) { + app := newApp() + + retired := []struct { + method string + path string + }{ + {http.MethodPost, "/api/v1/customer/register"}, + {http.MethodPost, "/api/v1/customer/login"}, + {http.MethodPost, "/api/v1/customer/verify-pin"}, + {http.MethodPost, "/api/v1/customer/reset-pin"}, + {http.MethodPost, "/api/v1/customer/send-email-otp"}, + {http.MethodPost, "/api/v1/customer/verify-email-otp"}, + {http.MethodGet, "/api/v1/customer/track/DM-TRK-ABCD-123"}, + {http.MethodGet, "/api/v1/customer/bookings/42/price"}, + {http.MethodPut, "/api/v1/customer/device-token"}, + } + + for _, r := range retired { + status, body := do(t, app, r.method, r.path, "", `{}`) + + // The assertion is "no handler serves this any more", not "404". + // + // Fiber mounts customerAuth via customer.Use(...) on the /customer + // PREFIX, so any path under it that matches no route falls through to + // the auth middleware and answers 401 rather than 404. That is + // pre-existing behaviour of every group in this router (/miler, + // /admin and /hub all do it) and is not something this work changed — + // but it is what the retired PIN endpoints now return, and the app + // developer needs to know that a stale build calling + // POST /customer/login sees 401, not 404. + if status < 400 { + t.Errorf("%s %s: status = %d — a retired route is still being served (body: %s)", + r.method, r.path, status, body) + } + if status >= 500 { + t.Errorf("%s %s: status = %d — a retired route should refuse cleanly, not fault (body: %s)", + r.method, r.path, status, body) + } + // The old PIN handlers answered 200/201 with a token. Nothing here may + // still mint one. + if strings.Contains(body, `"token"`) || strings.Contains(body, `"accessToken"`) { + t.Errorf("%s %s still returns a credential: %s", r.method, r.path, body) + } + } +} + +// ── Regression guard: the other surfaces are untouched ─────────────────────── + +// milerSurface is every miler route, with the role it requires. This exists to +// answer one question in CI rather than by inspection: did the customer work +// change the rider's API? A path disappearing, moving group, or losing its role +// gate fails here. +var milerSurface = []struct { + method string + path string +}{ + {http.MethodGet, "/api/v1/miler/profile"}, + {http.MethodPut, "/api/v1/miler/profile"}, + {http.MethodPut, "/api/v1/miler/device-token"}, + {http.MethodPut, "/api/v1/miler/location"}, + {http.MethodPut, "/api/v1/miler/availability"}, + {http.MethodGet, "/api/v1/miler/assignments"}, + {http.MethodGet, "/api/v1/miler/assignments/1"}, + {http.MethodPost, "/api/v1/miler/assignments/1/accept"}, + {http.MethodPost, "/api/v1/miler/assignments/1/reject"}, + {http.MethodPost, "/api/v1/miler/bookings/1/reached"}, + {http.MethodPatch, "/api/v1/miler/bookings/1/addresses"}, + {http.MethodPost, "/api/v1/miler/bookings/1/parcel"}, + {http.MethodPost, "/api/v1/miler/bookings/1/payment"}, + {http.MethodPost, "/api/v1/miler/bookings/1/pickup-complete"}, + {http.MethodPost, "/api/v1/miler/bookings/1/vehicle-required"}, + {http.MethodPost, "/api/v1/miler/bookings/1/cancel"}, + {http.MethodPost, "/api/v1/miler/bookings/1/skip"}, + {http.MethodGet, "/api/v1/miler/bookings"}, + {http.MethodPost, "/api/v1/miler/duty/start"}, + {http.MethodPut, "/api/v1/miler/duty/end"}, + {http.MethodGet, "/api/v1/miler/duty/current"}, + {http.MethodGet, "/api/v1/miler/consignments/1"}, + {http.MethodPost, "/api/v1/miler/consignments/1/start-delivery"}, + {http.MethodPost, "/api/v1/miler/consignments/1/deliver"}, + {http.MethodPost, "/api/v1/miler/consignments/1/skip"}, + {http.MethodPost, "/api/v1/miler/consignments/1/inward-at-hub"}, + {http.MethodGet, "/api/v1/miler/bases"}, + {http.MethodGet, "/api/v1/miler/earnings"}, + {http.MethodGet, "/api/v1/miler/notifications"}, + {http.MethodPost, "/api/v1/miler/support"}, + {http.MethodGet, "/api/v1/miler/support"}, + {http.MethodPost, "/api/v1/miler/uploads/sign"}, +} + +// consoleSurface is a representative slice of the admin and hub consoles — +// including every route the customer work touched code behind (assignment, +// express booking creation, hub queues). +var consoleSurface = []struct { + method string + path string + role int +}{ + {http.MethodGet, "/api/v1/admin/dashboard", 1}, + {http.MethodGet, "/api/v1/admin/bookings", 1}, + {http.MethodGet, "/api/v1/admin/bookings/1", 1}, + {http.MethodPost, "/api/v1/admin/bookings/1/assign-miler", 1}, + {http.MethodPost, "/api/v1/admin/bookings/1/cancel", 1}, + {http.MethodPost, "/api/v1/admin/expressbooking", 1}, + {http.MethodPost, "/api/v1/admin/expressbooking/bulk", 1}, + {http.MethodGet, "/api/v1/admin/consignments", 1}, + {http.MethodGet, "/api/v1/admin/customers", 1}, + {http.MethodGet, "/api/v1/hub/dashboard", 6}, + {http.MethodGet, "/api/v1/hub/bookings/unassigned", 6}, + {http.MethodPost, "/api/v1/hub/bookings/1/assign-miler", 6}, + {http.MethodPost, "/api/v1/hub/bookings/1/auto-assign", 6}, + {http.MethodPost, "/api/v1/hub/bookings/batch-assign", 6}, + {http.MethodGet, "/api/v1/hub/inbound/expected", 6}, +} + +// Every miler route still exists and is still gated to role 5. This is the +// regression guard for "did the customer work break the rider app's routing". +func TestMilerSurfaceIsUnchanged(t *testing.T) { + app := newApp() + customerBearer := token(t, 77, 9) + + for _, r := range milerSurface { + t.Run(r.method+" "+r.path, func(t *testing.T) { + if status, body := do(t, app, r.method, r.path, "", `{}`); status != fiber.StatusUnauthorized { + t.Errorf("no token: status = %d, want 401 (body: %s)", status, body) + } + // And a customer token must not reach a rider endpoint. + if status, body := do(t, app, r.method, r.path, customerBearer, `{}`); status != fiber.StatusForbidden { + t.Errorf("customer token: status = %d, want 403 (body: %s)", status, body) + } + }) + } +} + +func TestConsoleSurfaceIsUnchanged(t *testing.T) { + app := newApp() + customerBearer := token(t, 77, 9) + + for _, r := range consoleSurface { + t.Run(r.method+" "+r.path, func(t *testing.T) { + if status, body := do(t, app, r.method, r.path, "", `{}`); status != fiber.StatusUnauthorized { + t.Errorf("no token: status = %d, want 401 (body: %s)", status, body) + } + if status, body := do(t, app, r.method, r.path, customerBearer, `{}`); status != fiber.StatusForbidden { + t.Errorf("customer token: status = %d, want 403 (body: %s)", status, body) + } + }) + } +} + +// The customer envelope must not have leaked onto the other surfaces. A miler +// or console client reads `code` at the top level, not `error.code`, and a +// silently reshaped error body is the kind of thing that costs a release to +// discover. +func TestOtherSurfacesKeepTheirOwnErrorEnvelope(t *testing.T) { + app := newApp() + + for _, path := range []string{ + "/api/v1/miler/bookings", + "/api/v1/admin/bookings", + "/api/v1/hub/dashboard", + } { + _, body := do(t, app, http.MethodGet, path, "", "") + + if strings.Contains(body, `"error"`) { + t.Errorf("%s: refusal body carries the customer surface's nested error object: %s", path, body) + } + if !strings.Contains(body, `"success"`) || !strings.Contains(body, `"message"`) { + t.Errorf("%s: refusal body lost its original shape: %s", path, body) + } + } +} + +// The four catalogue reads must be reachable WITHOUT a token. +// +// The booking form is explorable before sign-in — the state picker is the app's +// first screen. These four are registered before `customer.Use(...)`, which is +// the only thing keeping them public: move any of them below that line and the +// app's opening screen silently becomes a login wall, with nothing else to +// catch it. `cxPublicRoutes` above covers the auth endpoints; this covers the +// catalogue, which had no such assertion until a wrong URL in the field +// surfaced the gap. +// +// With no database configured these reach the handler and fault on a nil +// db.DB — which is the proof. A 401 or 403 would mean the request never got +// that far. +func TestCatalogueRoutesAreNotBehindAuth(t *testing.T) { + app := newApp() + + for _, path := range []string{ + "/api/v1/customer/serviceability/states", + "/api/v1/customer/serviceability/states/TN/districts", + "/api/v1/customer/pickup-slots?lat=11.0168&lng=76.9558", + "/api/v1/customer/config/booking-limits", + } { + t.Run(path, func(t *testing.T) { + status, body := do(t, app, http.MethodGet, path, "", "") + + if status == fiber.StatusUnauthorized { + t.Fatalf("status = 401: this route is behind the auth middleware. "+ + "The booking form must be explorable before sign-in — check it is "+ + "registered BEFORE customer.Use(...) in routes.go (body: %s)", body) + } + if status == fiber.StatusForbidden { + t.Fatalf("status = 403: this route is behind the role gate (body: %s)", body) + } + if status == fiber.StatusNotFound { + t.Fatalf("status = 404: route not registered (body: %s)", body) + } + }) + } + + // A wrong-role token must not change that answer either — these routes do + // not consult the caller at all. + adminBearer := token(t, 1, 1) + for _, path := range []string{ + "/api/v1/customer/serviceability/states", + "/api/v1/customer/config/booking-limits", + } { + if status, body := do(t, app, http.MethodGet, path, adminBearer, ""); status == fiber.StatusForbidden { + t.Errorf("%s refused an admin token with 403 — a catalogue read is public "+ + "and should ignore the caller entirely (body: %s)", path, body) + } + } +} + +// A MISTYPED path under /customer/* answers 401 or 403, never 404. +// +// Fiber mounts customerAuth via customer.Use(...) on the /customer PREFIX, so +// any path matching no route falls through to the auth middleware. Every group +// in this router behaves this way (/miler, /admin, /hub included) and it is not +// something the customer work introduced — but it is genuinely confusing in the +// field: a typo like /serviceability/state (singular) reports an auth problem +// rather than a missing route, which sends people looking for a permissions bug +// that is not there. +// +// Asserted so the behaviour is at least documented and deliberate. +func TestMistypedCustomerPathReportsAuthNotNotFound(t *testing.T) { + app := newApp() + const typo = "/api/v1/customer/serviceability/state" // note: singular + + status, body := do(t, app, http.MethodGet, typo, "", "") + if status != fiber.StatusUnauthorized { + t.Errorf("no token on a mistyped path: status = %d, want 401 (body: %s)", status, body) + } + + status, body = do(t, app, http.MethodGet, typo, token(t, 1, 1), "") + if status != fiber.StatusForbidden { + t.Errorf("wrong-role token on a mistyped path: status = %d, want 403 (body: %s)", status, body) + } + if !strings.Contains(body, "insufficient permissions") { + t.Errorf("unexpected refusal body: %s", body) + } +} diff --git a/scratch/cx_readonly_probe.go b/scratch/cx_readonly_probe.go new file mode 100644 index 0000000..387e10b --- /dev/null +++ b/scratch/cx_readonly_probe.go @@ -0,0 +1,174 @@ +//go:build ignore + +// Read-only probe of the live `logistics` database for the customer-app work. +// +// STRICTLY READ-ONLY. It runs SELECTs against information_schema and a few +// COUNTs, and nothing else — no DDL, no INSERT, no UPDATE. It exists to answer +// one question before anybody boots the service against this database: +// +// is migrations.Migrate() actually additive here, or would it collide with +// something that already exists? +// +// main.go calls Migrate() on startup, so "just run the server and see" is a +// schema change to a database the Miler app and both consoles are live against. +// This is the safe half of that check. +// +// go run scratch/cx_readonly_probe.go +package main + +import ( + "database/sql" + "fmt" + "log" + "os" + "strings" + + _ "github.com/lib/pq" +) + +// The tables migrations.Migrate() would create for the customer app. +var newTables = []string{ + "serviceablestates", + "serviceabledistricts", + "pickupslottemplates", + "customerbookinglimits", + "bookingdestinations", + "bookingparcelphotos", + "bookingstageevents", + "customerrefreshtokens", + "customerdevices", +} + +// Columns it would add to existing tables. These are the ones that touch rows +// the live system already reads. +var newColumns = map[string][]string{ + "pickupbookings": { + "slotid", "customerstage", "customerstatus", + "estimateminrupees", "estimatemaxrupees", "routekm", + "pickuptitle", "pickupsub", "cancelreason", + }, + "bookingparcels": {"bookingdestinationid"}, +} + +var newSequences = []string{"cx_booking_reference_seq", "cx_tracking_seq"} + +func main() { + // Credentials come from the environment only. The other probes in this + // folder hardcode the production password; that is one more copy of a live + // credential in the repository than there needs to be, and this file does + // not add another. + // + // set -a && . ./.env && set +a && go run scratch/cx_readonly_probe.go + host := env("DB_HOST", "") + port := env("DB_PORT", "5433") + user := env("DB_USER", "") + pass := env("DB_PASSWORD", "") + name := env("DB_NAME", "logistics") + + if host == "" || user == "" || pass == "" { + log.Fatal("set DB_HOST, DB_USER and DB_PASSWORD first — " + + "e.g. `set -a && . ./.env && set +a`") + } + + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", + host, user, pass, name, port) + + db, err := sql.Open("postgres", dsn) + if err != nil { + log.Fatalf("open: %v", err) + } + defer db.Close() + + if err := db.Ping(); err != nil { + log.Fatalf("ping: %v", err) + } + fmt.Printf("connected read-only to %s@%s:%s/%s\n\n", user, host, port, name) + + var total int + must(db.QueryRow(`SELECT count(*) FROM information_schema.tables + WHERE table_schema = 'public'`).Scan(&total)) + fmt.Printf("existing public tables: %d\n\n", total) + + fmt.Println("── tables the migration would CREATE ──") + existing := 0 + for _, t := range newTables { + var n int + must(db.QueryRow(`SELECT count(*) FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = $1`, t).Scan(&n)) + if n > 0 { + existing++ + var rows int + if err := db.QueryRow(fmt.Sprintf(`SELECT count(*) FROM %q`, t)).Scan(&rows); err != nil { + rows = -1 + } + fmt.Printf(" %-24s ALREADY EXISTS (%d rows)\n", t, rows) + } else { + fmt.Printf(" %-24s absent — would be created\n", t) + } + } + + fmt.Println("\n── columns the migration would ADD to live tables ──") + for table, cols := range newColumns { + var tableRows int + if err := db.QueryRow(fmt.Sprintf(`SELECT count(*) FROM %q`, table)).Scan(&tableRows); err != nil { + fmt.Printf(" %s: could not count rows: %v\n", table, err) + continue + } + fmt.Printf(" %s (%d rows):\n", table, tableRows) + for _, col := range cols { + var n int + must(db.QueryRow(`SELECT count(*) FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2`, + table, col).Scan(&n)) + state := "absent — would be added" + if n > 0 { + state = "ALREADY EXISTS" + } + fmt.Printf(" %-24s %s\n", col, state) + } + } + + fmt.Println("\n── sequences ──") + for _, s := range newSequences { + var n int + must(db.QueryRow(`SELECT count(*) FROM information_schema.sequences + WHERE sequence_schema = 'public' AND sequence_name = $1`, s).Scan(&n)) + state := "absent — would be created" + if n > 0 { + state = "ALREADY EXISTS" + } + fmt.Printf(" %-30s %s\n", s, state) + } + + // Anything that would make the migration NOT additive: a name collision on + // a table that is not ours, or a column of an incompatible type. + fmt.Println("\n── collision check ──") + if existing == 0 { + fmt.Println(" none — every customer-app table is new to this database") + } else { + fmt.Printf(" %d of %d target tables already exist; inspect them before migrating\n", + existing, len(newTables)) + } + + // How much live data the additive columns would land on. + var bookings, consignments int + _ = db.QueryRow(`SELECT count(*) FROM pickupbookings`).Scan(&bookings) + _ = db.QueryRow(`SELECT count(*) FROM consignments`).Scan(&consignments) + fmt.Printf("\nlive rows that would gain nullable columns: pickupbookings=%d consignments=%d\n", + bookings, consignments) + + fmt.Println("\nNOTHING WAS WRITTEN. This probe issues SELECTs only.") +} + +func env(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func must(err error) { + if err != nil { + log.Fatalf("query: %v", err) + } +} diff --git a/seed_customer_app.sql b/seed_customer_app.sql new file mode 100644 index 0000000..c112f54 --- /dev/null +++ b/seed_customer_app.sql @@ -0,0 +1,170 @@ +-- ============================================================ +-- Doormile — customer app (doormile_cx) seed data +-- Idempotent: safe to re-run (ON CONFLICT DO UPDATE throughout) +-- +-- Seeded to mirror the client's in-app mock exactly, because those cases back +-- the app's 18 widget tests and its design QA: +-- * Tamil Nadu / Kerala / Karnataka open +-- * Puducherry serviceable with NO open district (the "opening soon" state) +-- * Madurai / Kozhikode / Mangaluru unavailable, each with a reason +-- * At least one fully-booked pickup slot (t5, capacity 0) +-- Change any of those and a green test suite stops meaning anything. +-- +-- Run AFTER the Go service has started once, so AutoMigrate has created the +-- tables this file fills. +-- ============================================================ + +-- ── Serviceable states ─────────────────────────────────────────────────────── +-- districtCount is computed at read time from the districts below, so a state +-- listed here with nothing open still returns and carries its transit tag. The +-- app hides a state showing 0 rather than the server omitting it. + +INSERT INTO serviceablestates (statecode, statename, transittag, displayorder, status, createdat, updatedat) +VALUES + ('TN', 'Tamil Nadu', 'Ultra-fast transit', 1, 'Active', NOW(), NOW()), + ('KL', 'Kerala', 'Next-day transit', 2, 'Active', NOW(), NOW()), + ('KA', 'Karnataka', 'Next-day transit', 3, 'Active', NOW(), NOW()), + ('TG', 'Telangana', 'Next-day transit', 4, 'Active', NOW(), NOW()), + ('PY', 'Puducherry', 'Opening soon', 5, 'Active', NOW(), NOW()) +ON CONFLICT (statecode) DO UPDATE +SET statename = EXCLUDED.statename, + transittag = EXCLUDED.transittag, + displayorder = EXCLUDED.displayorder, + status = EXCLUDED.status, + updatedat = NOW(); + +-- ── Serviceable districts ──────────────────────────────────────────────────── +-- Unavailable districts are seeded too, and returned by the API. The picker +-- filters them out but the app shows their names in a quiet "coming soon" line, +-- so deleting them would remove real copy from the screen. `note` is why — +-- never left blank on an unavailable row, or the app has to invent a reason. +-- +-- centrelatitude/longitude matter more than they look: only state and district +-- are required at booking time, so for most destinations these coordinates are +-- the ONLY geography the parcel has until the miler corrects it at the door. +-- They price the estimate and they keep the stop out of the 0,0 bucket that +-- route sequencing skips. + +INSERT INTO serviceabledistricts + (districtcode, statecode, districtname, available, note, promise, + centrelatitude, centrelongitude, pincodeprefix, displayorder, createdat, updatedat) +VALUES + -- Tamil Nadu + ('TN-CBE', 'TN', 'Coimbatore', TRUE, '', 'Next-day delivery', 11.016845, 76.955832, '641', 1, NOW(), NOW()), + ('TN-MAA', 'TN', 'Chennai', TRUE, '', 'Next-day delivery', 13.082680, 80.270718, '600', 2, NOW(), NOW()), + ('TN-SLM', 'TN', 'Salem', TRUE, '', '2-day delivery', 11.664325, 78.146011, '636', 3, NOW(), NOW()), + ('TN-TRY', 'TN', 'Tiruchirappalli', TRUE, '', '2-day delivery', 10.790483, 78.704674, '620', 4, NOW(), NOW()), + ('TN-ERD', 'TN', 'Erode', TRUE, '', '2-day delivery', 11.341000, 77.717300, '638', 5, NOW(), NOW()), + ('TN-KNK', 'TN', 'Kanyakumari', TRUE, '', '2-day delivery', 8.178200, 77.435400, '629', 6, NOW(), NOW()), + ('TN-MDU', 'TN', 'Madurai', FALSE, 'Opening soon', '', 9.925201, 78.119775, '625', 7, NOW(), NOW()), + + -- Kerala + ('KL-EKM', 'KL', 'Ernakulam', TRUE, '', 'Next-day delivery', 9.981636, 76.299881, '682', 1, NOW(), NOW()), + ('KL-TVM', 'KL', 'Thiruvananthapuram', TRUE, '', '2-day delivery', 8.524139, 76.936638, '695', 2, NOW(), NOW()), + ('KL-TSR', 'KL', 'Thrissur', TRUE, '', '2-day delivery', 10.527640, 76.214402, '680', 3, NOW(), NOW()), + ('KL-KKD', 'KL', 'Kozhikode', FALSE, 'Opening soon', '', 11.258753, 75.780411, '673', 4, NOW(), NOW()), + + -- Karnataka + ('KA-BLR', 'KA', 'Bengaluru Urban', TRUE, '', 'Next-day delivery', 12.971599, 77.594566, '560', 1, NOW(), NOW()), + ('KA-MYS', 'KA', 'Mysuru', TRUE, '', '2-day delivery', 12.295810, 76.639381, '570', 2, NOW(), NOW()), + ('KA-MNG', 'KA', 'Dakshina Kannada', FALSE, 'Paused this week', '', 12.914142, 74.856000, '575', 3, NOW(), NOW()), + + -- Telangana + ('TG-HYD', 'TG', 'Hyderabad', TRUE, '', 'Next-day delivery', 17.385044, 78.486671, '500', 1, NOW(), NOW()), + + -- Puducherry: serviceable state, no open district. This is the "Opening + -- soon" state the app has a designed screen for, and the only place that + -- screen can be exercised. + ('PY-PDY', 'PY', 'Puducherry', FALSE, 'Opening soon', '', 11.913860, 79.812600, '605', 1, NOW(), NOW()) +ON CONFLICT (districtcode) DO UPDATE +SET statecode = EXCLUDED.statecode, + districtname = EXCLUDED.districtname, + available = EXCLUDED.available, + note = EXCLUDED.note, + promise = EXCLUDED.promise, + centrelatitude = EXCLUDED.centrelatitude, + centrelongitude = EXCLUDED.centrelongitude, + pincodeprefix = EXCLUDED.pincodeprefix, + displayorder = EXCLUDED.displayorder, + updatedat = NOW(); + +-- Attach each district to the nearest active hub, so the destination card can +-- name a serving base. Done by proximity rather than hard-coded ids because hub +-- ids differ between environments and a wrong id is worse than a missing name. +UPDATE serviceabledistricts d +SET hubid = nearest.hubid +FROM LATERAL ( + SELECT h.hubid + FROM hubs h + WHERE h.status = 'Active' + AND h.deletedat IS NULL + AND h.latitude <> 0 + ORDER BY (h.latitude - d.centrelatitude) ^ 2 + + (h.longitude - d.centrelongitude) ^ 2 + LIMIT 1 + ) AS nearest +WHERE d.hubid IS NULL; + +-- ── Pickup slot templates ──────────────────────────────────────────────────── +-- Roughly six windows a day, which is what the design lays out. The customer +-- never sees these rows: the API expands them onto today and tomorrow and mints +-- a dated slot id, so a slot the customer picks always resolves back to a real +-- preferredpickupfrom/to that the assignment engine can staff. +-- +-- t5 is seeded at capacity 0 on purpose — it is the ONLY way to reach the +-- "Fully booked" state in design QA without actually filling a window. + +INSERT INTO pickupslottemplates + (code, starthour, startminute, endhour, endminute, capacity, applocationid, + tag, caption, displayorder, status, createdat, updatedat) +VALUES + ('t1', 8, 0, 10, 0, 25, NULL, 'Fastest pickup', 'Arriving in approx. 45 mins', 1, 'Active', NOW(), NOW()), + ('t2', 10, 0, 12, 0, 25, NULL, '', '', 2, 'Active', NOW(), NOW()), + ('t3', 12, 0, 14, 0, 25, NULL, '', '', 3, 'Active', NOW(), NOW()), + ('t4', 14, 0, 16, 0, 25, NULL, '', '', 4, 'Active', NOW(), NOW()), + ('t5', 16, 0, 18, 0, 0, NULL, '', '', 5, 'Active', NOW(), NOW()), + ('t6', 18, 0, 20, 0, 20, NULL, '', '', 6, 'Active', NOW(), NOW()) +ON CONFLICT DO NOTHING; + +-- ── Booking limits ─────────────────────────────────────────────────────────── +-- The global default row. Ops varies these per city by inserting a row with an +-- applocationid; the client keeps 20/5 if the call fails, and the server never +-- answers 0 for either — a zero cap would reject every booking on the platform. + +-- maxdestinations is seeded at 1 ON PURPOSE, not at the 5 the design allows. +-- +-- The fan-out works server-side, but the deployed rider app keys its stop list +-- on `orderid`, which is booking-level — so all three stops of a +-- three-destination pickup collapse into one in its local store, and two +-- parcels would be left with no stop and no way to close them. Accepting such a +-- booking would create work nobody can complete. +-- +-- Because the client reads this value from GET /customer/config/booking-limits +-- and adapts, capping it here keeps every pickup single-destination with no +-- feature flag and no app release. Raise it to 5 with a single UPDATE once a +-- rider build that keys on `consignmentid` is live: +-- +-- UPDATE customerbookinglimits SET maxdestinations = 5 WHERE applocationid IS NULL; +-- +-- Same discipline as MILER_HUB_HANDOVER_ENABLED: never turn on a server +-- behaviour the deployed rider app cannot finish. +INSERT INTO customerbookinglimits (applocationid, maxpackages, maxdestinations, createdat, updatedat) +SELECT NULL, 20, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM customerbookinglimits WHERE applocationid IS NULL); + +-- ── Test account ───────────────────────────────────────────────────────────── +-- Automated tests and design QA cannot receive a real text, and the previous +-- end-to-end attempt on this platform stalled for exactly that reason: customer +-- login needed an OTP on a real handset and could not be scripted. +-- +-- Pair this row with CX_STAGING_OTP=1234 in the staging environment. That env +-- var is refused outright when ENV=production (internal/sms), because a fixed +-- code is a skeleton key for every account on the platform. + +INSERT INTO appcustomers (firstname, lastname, phone, email, status, configid, createdat, updatedat) +SELECT 'Doormile', 'QA', '+919999900001', 'qa@doormile.com', 'Active', 1001, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM appcustomers WHERE phone = '+919999900001'); + +INSERT INTO appcustomers (firstname, lastname, phone, email, status, configid, createdat, updatedat) +SELECT 'Design', 'QA', '+919999900002', 'design.qa@doormile.com', 'Active', 1001, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM appcustomers WHERE phone = '+919999900002'); diff --git a/utils/epoch.go b/utils/epoch.go new file mode 100644 index 0000000..faafcd5 --- /dev/null +++ b/utils/epoch.go @@ -0,0 +1,103 @@ +package utils + +import ( + "fmt" + "time" +) + +// Timestamps on the customer surface. +// +// The requirement is epoch milliseconds, UTC, integer — a real instant, not a +// wall clock. That is not what this database hands back. The DSN sets +// TimeZone=Asia/Kolkata and the timestamp columns hold IST wall-clock digits +// (see DBNow), so a stored value read into a time.Time carries the right +// digits with the wrong (or no) zone. Calling UnixMilli on it directly is off +// by 5h30m — the same class of defect as sending a naive local string with a Z +// on it, which has already produced "yesterday's work shown as today" on the +// miler app. So every timestamp leaving /customer/* goes through here. +// +// Reinterpreting the wall clock in IST is correct for both shapes this +// database produces: a value tagged UTC that actually holds IST digits, and a +// value correctly tagged +05:30. Both name the same instant afterwards. + +// istLocation is Asia/Kolkata, with an exact fixed-offset fallback for +// containers shipped without tzdata. IST observes no DST, so +05:30 is not an +// approximation. +var istLocation = func() *time.Location { + if loc, err := time.LoadLocation("Asia/Kolkata"); err == nil { + return loc + } + return time.FixedZone("IST", 5*3600+30*60) +}() + +// IST reinterprets a database timestamp's wall clock as Indian Standard Time, +// yielding the instant it actually names. +func IST(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), istLocation) +} + +// EpochMillis converts a database timestamp to UTC epoch milliseconds. +func EpochMillis(t time.Time) int64 { + return IST(t).UnixMilli() +} + +// EpochMillisPtr is EpochMillis over a nullable column. A nil timestamp stays +// null in JSON rather than becoming the epoch, which the client would render +// as 1 Jan 1970. +func EpochMillisPtr(t *time.Time) *int64 { + if t == nil || t.IsZero() { + return nil + } + ms := EpochMillis(*t) + return &ms +} + +// ISTNow is the current moment in IST, for formatting and for comparing +// against a value already put through IST(). +func ISTNow() time.Time { + return time.Now().In(istLocation) +} + +// ISTLocation exposes the zone for callers building their own times. +func ISTLocation() *time.Location { return istLocation } + +// FormatISTDate renders a date the way the customer app shows it: "Thu, 12 Sep". +// Display strings are formatted server-side, in IST, so the client never has to +// know the operating timezone. +func FormatISTDate(t time.Time) string { + return IST(t).Format("Mon, 2 Jan") +} + +// FormatISTDay renders a date relative to today where that reads better — +// "Today", "Tomorrow", otherwise "Mon, 8 Sep". Used for pickup slot days. +func FormatISTDay(t time.Time) string { + d := IST(t) + today := ISTNow() + y1, m1, d1 := d.Date() + y2, m2, d2 := today.Date() + switch { + case y1 == y2 && m1 == m2 && d1 == d2: + return "Today" + case y1 == y2 && m1 == m2 && d1 == d2+1: + return "Tomorrow" + default: + tomorrow := today.AddDate(0, 0, 1) + y3, m3, d3 := tomorrow.Date() + if y1 == y3 && m1 == m3 && d1 == d3 { + return "Tomorrow" + } + return d.Format("Mon, 2 Jan") + } +} + +// FormatISTWindow renders a pickup window as the design writes it: +// "2:00 – 4:00 PM" — en dash, spaced, and the meridiem stated once when both +// ends share it. +func FormatISTWindow(from, to time.Time) string { + f, t := IST(from), IST(to) + fMer, tMer := f.Format("PM"), t.Format("PM") + if fMer == tMer { + return fmt.Sprintf("%s – %s", f.Format("3:04"), t.Format("3:04 PM")) + } + return fmt.Sprintf("%s – %s", f.Format("3:04 PM"), t.Format("3:04 PM")) +} diff --git a/utils/epoch_test.go b/utils/epoch_test.go new file mode 100644 index 0000000..9c033b5 --- /dev/null +++ b/utils/epoch_test.go @@ -0,0 +1,116 @@ +package utils + +import ( + "testing" + "time" +) + +// The customer contract asks for epoch milliseconds, UTC, integer. This +// database stores IST wall-clock digits (see DBNow), so the conversion is the +// one place a 5h30m error can enter every timestamp the app renders — which is +// exactly the defect class §3.3 of the contract warns about after it produced +// "yesterday's work shown as today" on the miler app. + +func TestEpochMillisTreatsStoredWallClockAsIST(t *testing.T) { + // A row written as 2026-09-05 14:30:00 by this database means 2:30pm in + // Chennai, whatever zone the time.Time happens to carry. + stored := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC) + + got := EpochMillis(stored) + want := time.Date(2026, 9, 5, 9, 0, 0, 0, time.UTC).UnixMilli() // 14:30 IST == 09:00 UTC + + if got != want { + t.Errorf("EpochMillis(2026-09-05 14:30 stored) = %d, want %d (a %d ms error)", + got, want, got-want) + } +} + +func TestEpochMillisAgreesForBothTaggings(t *testing.T) { + // The driver can hand back the same instant either tagged UTC with IST + // digits, or correctly tagged +05:30. Both must name one moment, or a + // column type change silently shifts every timestamp on the tracking screen. + ist := ISTLocation() + taggedUTC := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC) + taggedIST := time.Date(2026, 9, 5, 14, 30, 0, 0, ist) + + if EpochMillis(taggedUTC) != EpochMillis(taggedIST) { + t.Errorf("EpochMillis disagrees on tagging: utc-tagged=%d ist-tagged=%d", + EpochMillis(taggedUTC), EpochMillis(taggedIST)) + } +} + +func TestEpochMillisPtrKeepsNullNull(t *testing.T) { + // A nil timestamp must stay null in JSON. Coercing it to 0 renders as + // 1 Jan 1970 on the timeline, which reads as a real event. + if got := EpochMillisPtr(nil); got != nil { + t.Errorf("EpochMillisPtr(nil) = %v, want nil", got) + } + + var zero time.Time + if got := EpochMillisPtr(&zero); got != nil { + t.Errorf("EpochMillisPtr(zero time) = %v, want nil", got) + } + + stored := time.Date(2026, 9, 5, 14, 30, 0, 0, time.UTC) + got := EpochMillisPtr(&stored) + if got == nil || *got != EpochMillis(stored) { + t.Errorf("EpochMillisPtr(%v) = %v, want %d", stored, got, EpochMillis(stored)) + } +} + +func TestFormatISTWindowMatchesTheDesign(t *testing.T) { + // "2:00 – 4:00 PM" — en dash, spaced, meridiem stated once when both ends + // share it. The client renders this string verbatim. + cases := []struct { + name string + fromH, fromM int + toH, toM int + want string + }{ + {"afternoon window", 14, 0, 16, 0, "2:00 – 4:00 PM"}, + {"morning window", 8, 0, 10, 0, "8:00 – 10:00 AM"}, + {"straddles noon", 10, 0, 14, 0, "10:00 AM – 2:00 PM"}, + {"half hour bounds", 18, 30, 20, 30, "6:30 – 8:30 PM"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + from := time.Date(2026, 9, 5, tc.fromH, tc.fromM, 0, 0, time.UTC) + to := time.Date(2026, 9, 5, tc.toH, tc.toM, 0, 0, time.UTC) + if got := FormatISTWindow(from, to); got != tc.want { + t.Errorf("FormatISTWindow = %q, want %q", got, tc.want) + } + }) + } +} + +func TestFormatISTDayUsesRelativeWordsOnlyWhenTrue(t *testing.T) { + now := ISTNow() + + if got := FormatISTDay(now); got != "Today" { + t.Errorf("FormatISTDay(now) = %q, want \"Today\"", got) + } + if got := FormatISTDay(now.AddDate(0, 0, 1)); got != "Tomorrow" { + t.Errorf("FormatISTDay(tomorrow) = %q, want \"Tomorrow\"", got) + } + + // Three days out has no relative word — it falls back to the dated form, + // which must not read as "Today". + got := FormatISTDay(now.AddDate(0, 0, 3)) + if got == "Today" || got == "Tomorrow" { + t.Errorf("FormatISTDay(+3 days) = %q, want a dated label", got) + } +} + +// FormatISTDay crossing a month boundary is the case a naive day+1 comparison +// gets wrong: 30 September plus one day is 1 October, not 31 September. +func TestFormatISTDayAcrossMonthEnd(t *testing.T) { + now := ISTNow() + tomorrow := now.AddDate(0, 0, 1) + if now.Month() == tomorrow.Month() { + t.Skip("not a month boundary today; the AddDate path is exercised by the general case") + } + if got := FormatISTDay(tomorrow); got != "Tomorrow" { + t.Errorf("FormatISTDay across a month end = %q, want \"Tomorrow\"", got) + } +} diff --git a/utils/helper.go b/utils/helper.go index 5ce166d..f37299f 100644 --- a/utils/helper.go +++ b/utils/helper.go @@ -80,7 +80,18 @@ type Claims struct { } func GenerateToken(userID int, email string, roleID int, tenantID int, configID int, secret string) (string, error) { - expirationTime := time.Now().Add(24 * time.Hour) + return GenerateTokenWithTTL(userID, email, roleID, tenantID, configID, secret, 24*time.Hour) +} + +// GenerateTokenWithTTL is GenerateToken with an explicit lifetime. +// +// The customer app pairs a short access token with a long-lived refresh token, +// so a stolen access token expires in an hour rather than a day, while the +// customer still stays signed in for months. The miler and console surfaces +// keep the 24-hour default: they have no refresh endpoint, and shortening their +// token would sign a rider out mid-shift. +func GenerateTokenWithTTL(userID int, email string, roleID int, tenantID int, configID int, secret string, ttl time.Duration) (string, error) { + expirationTime := time.Now().Add(ttl) claims := &Claims{ UserID: userID, Email: email, diff --git a/utils/response_cx.go b/utils/response_cx.go new file mode 100644 index 0000000..f90407e --- /dev/null +++ b/utils/response_cx.go @@ -0,0 +1,97 @@ +package utils + +import "github.com/gofiber/fiber/v2" + +// Customer-app (doormile_cx) response envelope. +// +// Deliberately a separate helper set from OK/List/Fail rather than a reuse of +// them. The customer contract fixes three things the miler/console contract +// does not: `message` is always present on success (empty string, never +// omitted), the machine-readable code is nested under `error.code` rather than +// sitting at the top level, and `total`/`nextCursor` ride the list envelope. +// Serving one endpoint in one shape and its neighbour in another is exactly +// what cost the miler client a release on /miler/verify-pin — so the customer +// surface gets its own helpers and every /customer/* response goes through +// them, auth included. + +// CxErr* are the error codes in the customer contract. The client branches on +// these; `message` is customer-safe English shown verbatim in the UI. +const ( + CxErrInvalid = "invalid" + CxErrInvalidName = "invalid_name" + CxErrInvalidOtp = "invalid_otp" + CxErrUnauthorized = "unauthorized" + CxErrForbidden = "forbidden" + CxErrNotFound = "not_found" + CxErrConflict = "conflict" + CxErrUnserviceable = "unserviceable" + CxErrRateLimited = "rate_limited" + CxErrServer = "server_error" +) + +// CxOK is the success envelope: {success, data, message}. +func CxOK(c *fiber.Ctx, data interface{}) error { + return c.JSON(fiber.Map{"success": true, "data": data, "message": ""}) +} + +// CxCreated is CxOK with a 201. +func CxCreated(c *fiber.Ctx, data interface{}) error { + return c.Status(fiber.StatusCreated). + JSON(fiber.Map{"success": true, "data": data, "message": ""}) +} + +// CxList is the list envelope. data is always an array — never null, because +// the client types it as a list and a null throws in the parser. nextCursor is +// null when the page is the last one. +func CxList(c *fiber.Ctx, data interface{}, total int, nextCursor *string) error { + body := fiber.Map{ + "success": true, + "data": data, + "total": total, + "nextCursor": nil, + "message": "", + } + if nextCursor != nil && *nextCursor != "" { + body["nextCursor"] = *nextCursor + } + return c.JSON(body) +} + +// CxFail is the error envelope: {success:false, message, error:{code}}. The +// app funnels every failure into one retryable error state and renders +// `message` verbatim, so callers must pass customer-safe English here — never +// an enum key, a driver error or a wrapped stack. +func CxFail(c *fiber.Ctx, status int, code, msg string) error { + return c.Status(status).JSON(fiber.Map{ + "success": false, + "message": msg, + "error": fiber.Map{"code": code}, + }) +} + +// Shorthands for the statuses the contract pins to a specific code. +func CxBadRequest(c *fiber.Ctx, msg string) error { + return CxFail(c, fiber.StatusBadRequest, CxErrInvalid, msg) +} + +func CxUnauthorized(c *fiber.Ctx, msg string) error { + return CxFail(c, fiber.StatusUnauthorized, CxErrUnauthorized, msg) +} + +func CxForbidden(c *fiber.Ctx, msg string) error { + return CxFail(c, fiber.StatusForbidden, CxErrForbidden, msg) +} + +func CxNotFound(c *fiber.Ctx, msg string) error { + return CxFail(c, fiber.StatusNotFound, CxErrNotFound, msg) +} + +func CxConflict(c *fiber.Ctx, msg string) error { + return CxFail(c, fiber.StatusConflict, CxErrConflict, msg) +} + +// CxInternal never leaks the underlying error to the customer. Log the real +// one; the app shows this. +func CxInternal(c *fiber.Ctx) error { + return CxFail(c, fiber.StatusInternalServerError, CxErrServer, "Something went wrong") +} From f531b424563cac15ffafcc35ba8c63cd851f69eb Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Mon, 7 Sep 2026 16:52:44 +0530 Subject: [PATCH 04/11] updates on the md files --- docs/CHANGELOG.md | 80 ++++++++++++++++++++++++++++++++++++++++++ docs/DEV_ONBOARDING.md | 4 +++ docs/miler-app-api.md | 5 +-- 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 docs/CHANGELOG.md diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..cbe9996 --- /dev/null +++ b/docs/CHANGELOG.md @@ -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. diff --git a/docs/DEV_ONBOARDING.md b/docs/DEV_ONBOARDING.md index 537edfd..83ac70b 100644 --- a/docs/DEV_ONBOARDING.md +++ b/docs/DEV_ONBOARDING.md @@ -13,6 +13,10 @@ 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 diff --git a/docs/miler-app-api.md b/docs/miler-app-api.md index 845e0b3..391fa9d 100644 --- a/docs/miler-app-api.md +++ b/docs/miler-app-api.md @@ -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 From 86ae2ab41ec2abd7d91dfc10634dc2650d5aaab7 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Tue, 8 Sep 2026 13:23:42 +0530 Subject: [PATCH 05/11] updates on the customer app api --- docs/customer-app-api-crisp.md | 291 +++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 docs/customer-app-api-crisp.md diff --git a/docs/customer-app-api-crisp.md b/docs/customer-app-api-crisp.md new file mode 100644 index 0000000..86335a6 --- /dev/null +++ b/docs/customer-app-api-crisp.md @@ -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`
**Tracking Number:** `DMX10482913` | +| **Headers** | `Authorization: Bearer `
`X-Client: doormile-cx/+`
`X-Platform: android | ios`
`Idempotency-Key: ` | • Required on all routes except pre-auth & catalogue.
• Client telemetry logged on every request.
• 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 +``` From 17bd316e4d471547e5f4bcbafface281ebf0566a Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Thu, 10 Sep 2026 15:55:40 +0530 Subject: [PATCH 06/11] updates on the admincontroler page --- controllers/adminController.go | 74 ++++++++++++++++++- controllers/adminDestinations_test.go | 102 ++++++++++++++++++++++++++ controllers/cxBookingController.go | 9 +++ models/booking.go | 12 +++ 4 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 controllers/adminDestinations_test.go diff --git a/controllers/adminController.go b/controllers/adminController.go index 70022d9..609ba1f 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -2062,6 +2062,41 @@ 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))) @@ -2113,6 +2148,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{ @@ -2595,7 +2651,23 @@ 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") } diff --git a/controllers/adminDestinations_test.go b/controllers/adminDestinations_test.go new file mode 100644 index 0000000..44f86c0 --- /dev/null +++ b/controllers/adminDestinations_test.go @@ -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) + } +} diff --git a/controllers/cxBookingController.go b/controllers/cxBookingController.go index dae8706..10571fc 100644 --- a/controllers/cxBookingController.go +++ b/controllers/cxBookingController.go @@ -82,6 +82,13 @@ type cxCreateBookingRequest 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. @@ -270,6 +277,8 @@ func CreateCxBooking(c *fiber.Ctx) error { Estimatemaxrupees: estimateMax, Routekm: quote.RouteKM, + Notes: req.Remarks, + Createdat: now, Updatedat: now, } diff --git a/models/booking.go b/models/booking.go index f506811..b49edc0 100644 --- a/models/booking.go +++ b/models/booking.go @@ -80,6 +80,18 @@ type PickupBooking struct { // 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"` From e8f4c0a5939de1b37e32930f8b0f1dd9738c659b Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Fri, 11 Sep 2026 11:48:06 +0530 Subject: [PATCH 07/11] update son the admincontroller acoording datas and update the md file as well --- controllers/adminController.go | 18 ++++++++++++++++++ docs/express-console-api.md | 24 ++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/controllers/adminController.go b/controllers/adminController.go index 609ba1f..b47fbb4 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -2116,8 +2116,26 @@ 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. var bookings []models.PickupBooking if err := query.Preload("Parcels").Preload("ServiceOptions"). + Order("bookingid DESC"). Offset(offset).Limit(pagesize).Find(&bookings).Error; err != nil { return utils.Internal(c, "failed to fetch bookings") } diff --git a/docs/express-console-api.md b/docs/express-console-api.md index 10e4e69..152540c 100644 --- a/docs/express-console-api.md +++ b/docs/express-console-api.md @@ -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. From 89321c9e06a1bbb83fd1063528866dcf32879d81 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Tue, 15 Sep 2026 11:50:12 +0530 Subject: [PATCH 08/11] updates on the otp updates on the customer app --- .env.example | 30 ++- CLAUDE.md | 138 ++++++++++- cmd/migrate_qdrant/main.go | 6 +- config/config.go | 110 +++++++-- config/secrets_test.go | 142 +++++++++++ controllers/cxAuthController.go | 53 +++- controllers/cxOtpFieldAlias_test.go | 88 +++++++ db/connect.go | 12 + db/nats_guard_test.go | 50 ++++ docs/CHANGELOG.md | 67 +++++ docs/customer-app-api-crisp.md | 89 ++++--- docs/customer-app-handover-2026-09-15.md | 297 +++++++++++++++++++++++ internal/assignment/ai_layer.go | 11 +- main.go | 9 + scratch/debug_booking.go | 13 +- scratch/fix_booking_status_constraint.go | 13 +- scratch/list_tables.go | 13 +- 17 files changed, 1072 insertions(+), 69 deletions(-) create mode 100644 config/secrets_test.go create mode 100644 controllers/cxOtpFieldAlias_test.go create mode 100644 db/nats_guard_test.go create mode 100644 docs/customer-app-handover-2026-09-15.md diff --git a/.env.example b/.env.example index 5fdee7d..2da1844 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # App Config APP_PORT=8081 ENV=development -JWT_SECRET_KEY=DoormileSuperSecretJWTKey2026! +JWT_SECRET_KEY=change-me-locally INTERNAL_API_KEY=doormile-internal-2024 # Reverse proxy — comma-separated IPs/CIDRs allowed to set X-Forwarded-For. @@ -16,13 +16,13 @@ DB_HOST=31.97.228.132 DB_PORT=5433 DB_NAME=logistics DB_USER=admin -DB_PASSWORD=Package@321# +DB_PASSWORD= # Redis Configuration REDIS_HOST=31.97.228.132 REDIS_PORT=6379 REDIS_USER=admin -REDIS_PASSWORD=Package@321# +REDIS_PASSWORD= # SMTP Configuration (email OTP verification) SMTP_HOST=smtp.gmail.com @@ -38,4 +38,26 @@ $env:PATH += ";C:\Program Files\Docker\Docker\resources\bin" >> docker push doormile/doormile-backend:latest >> - +# ── Required / changed 2026-09-11 ─────────────────────────────────────────── +# JWT_SECRET_KEY no longer has a default. It used to fall back to a literal in +# config/config.go, which meant anyone holding this repository could mint a +# valid token for any user id and any role against a deployment that had not +# overridden it. +# ENV=production + unset -> the service REFUSES TO START (cfg.Validate). +# anything else + unset -> an ephemeral per-process key is generated and a +# warning logged; tokens will not survive a restart. +# Set it for a stable local session, and make sure it is set in production +# before deploying (the JWT_SECRET_KEY line above). +# +# NATS_URL and the AI/optimiser hosts also lost their defaults, which pointed at +# the real production cluster — an unconfigured local run silently joined the +# live stream and competed with the production workers. Unset now means +# "disabled": no NATS connection, no route sequencing, legacy assignment +# scoring. Set them explicitly where you actually want them. +# NATS_URL=nats://localhost:4222 +# NATS_USER= +# NATS_PASSWORD= +# AI_LAYER_BASE_URL= +# ROUTE_OPTIMIZER_URL= +# +# DB_PASSWORD has no default either — set it for your own database. diff --git a/CLAUDE.md b/CLAUDE.md index e4bb875..68fdeb0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -237,8 +237,11 @@ websocket routes. **[verified this session]** confirm current values rather than assuming): admin login at `suriya@doormile.com`; hub staff accounts pattern `hub.[city]@doormile.in` plus partner variants; miler test phone numbers + PINs. -- Auth: Firebase OTP for customer login — this cannot be scripted/bypassed - from the command line, which is why the last live E2E test stalled (§9). +- Auth: **not Firebase.** Customer login is a 4-digit OTP to phone or email, + issued by `issueCxOtp` and stored in Redis (§8.5). `CX_STAGING_OTP` makes it + a fixed code outside production, which is what lets it be scripted — the + earlier "cannot be bypassed from the command line" note (§9) predates that + and predates the `/customer/*` rebuild. --- @@ -597,6 +600,137 @@ key, after the legacy rider app's key had to be revoked), `MILER_CALL_PROXY` --- +## 8.6 Customer sign-in, config hardening & the ordering fix (2026-09-11) + +**[verified this session]** — six defects were reported against customer +bookings. Five were real; one was not. Everything below was verified against +the code, and the schema question against the **production database**. + +### The one that locked every customer out (DM-01) + +`CxVerifyOtp` read `json:"code"`. The customer app sent `otp` — because +`docs/customer-app-api-crisp.md` documented `otp`, while +`docs/openapi-customer.yaml` correctly said `code`. **The two docs disagreed +and the app was built from the wrong one.** `req.Code` was therefore always +empty, the empty-code branch always fired, and every sign-in failed with +`400 "Enter the code we sent you"` — a correct code failed exactly like a +wrong one. No amount of SMS-gateway credit would have fixed it. + +The handler now accepts `otp` as a **deprecated alias**; `code` wins when both +are present. This deliberately fixes builds already in customers' hands, which +an app-side fix alone cannot. `controllers/cxOtpFieldAlias_test.go` pins both +names, the precedence, and that a blank/missing code is still rejected. Remove +the alias once the install base has moved on. + +### A failed OTP send no longer punishes the customer (DM-05) + +`issueCxOtp` writes the code, the resend cooldown and the rate-limit slot +*before* attempting delivery — it has to, the code must exist to be sent. But +on failure it kept all three: the customer was told something went wrong, could +not resend until the cooldown expired, had spent one of their five hourly +codes, and a valid code they never received sat live in Redis for its full TTL. +All three are now rolled back on a delivery error (`DEL` the code and cooldown, +`DECR` the rate counter), so a retry is immediate and nothing usable is left. + +### Production credentials are no longer defaults (DM-06) + +`config.Load()` defaulted `JWT_SECRET_KEY` to a literal, and `NATS_URL` / +`NATS_USER` / `NATS_PASSWORD` / `AI_LAYER_BASE_URL` / `ROUTE_OPTIMIZER_URL` / +`DB_PASSWORD` to the real production values. Two consequences, both live: +a clone of this repository could mint a valid token for any user id and any +role against any deployment that had not overridden the secret; and `go run .` +on a laptop silently joined the production NATS cluster and competed with the +real workers for the same durable consumer. **This was hit accidentally on +2026-09-11** — a local instance pulled `api.v1.bookings.update` for real +booking ids and caused redelivery churn on production for ~40 seconds. + +All now default to empty, and the empty case is handled rather than assumed: +`InitNATS` skips connecting, `routing.BaseURL == ""` already disabled +sequencing, and the AI layer returns an error so the caller's existing +`AI_LAYER_FALLBACK` path takes over with legacy scoring. A second hardcoded +production URL in `internal/assignment/ai_layer.go` (not in the original +report) was removed too. + +`JWT_SECRET_KEY` is special-cased because an empty signing key is worse than a +shared one: **`cfg.Validate()`, called from `main`, refuses to start** when it +is unset in production. Outside production an ephemeral per-process key is +generated with a warning, so local development needs no configuration while +tokens stop surviving a restart. `GEOCODER_URL` deliberately keeps its default +— Nominatim is a public service, not a Doormile host. + +### `GET /admin/bookings` is ordered (DM-04) + +Added `Order("bookingid DESC")`. Without it the row order was unspecified — +Postgres heap order, oldest first — which put the newest booking on the LAST +page, outside the console's bounded drain window, and made `OFFSET` paging +unstable enough to duplicate and skip rows. The primary key is unique, so the +sort needs no tiebreaker. + +The console half lives in the admin console repo (`krow_talent_app` — the +directory name is stale; it is the Doormile Express Console): it requests +`pagesize=1000`, receives 100, and stops after 12 pages, so it sees 1200 rows +regardless. With the list now newest-first those 1200 are the most recent ones +rather than the oldest, which turns a silent disappearance into a bounded view. + +### DM-03 (timestamp drift) is NOT a production bug — do not "fix" it + +Reported as `DBNow()` relabelling IST wall-clock as UTC against +`timestamp with time zone` columns, causing a +5:30 drift that hid evening +bookings from the console. **Verified against production and it is false +there:** + +``` +pickupbookings.createdat timestamp without time zone +pickupbookings.preferredpickupfrom timestamp without time zone +appcustomers.createdat timestamp without time zone +``` + +Which is exactly what `DBNow()`'s own comment assumes. A round-trip confirmed +it: a customer created at a known `12:10:34 IST` stored as `12:10:34.094323`. +Zero drift. **Changing `DBNow()` would introduce the bug, not fix it.** + +The real finding is the reporter's own fallback: GORM's Postgres driver maps +`time.Time` to `timestamptz`, so a schema built fresh from `AutoMigrate` does +NOT match production, and every new dev environment WILL show the +5:30 drift +that production does not. That is why they saw it. Pin the column types +explicitly in the models before this bites someone again. + +### Docs corrected + +`docs/customer-app-api-crisp.md` had **three** request shapes that did not +match their parsers, all failing silently through `BodyParser` — no error, just +a zero value: + +| Endpoint | Documented | Actually parsed | +|---|---|---| +| `auth/otp/verify` | `otp` | `code` (now both) | +| `fare/estimate` | `pickup.latitude`/`longitude`, `packages[].weightKg` | `pickup.lat`/`lng`, `packageCount` | +| `bookings` | flat destination fields, `pickup.latitude` | nested `details{}`, `pickup.lat`/`lng` | + +The booking **response** block was wrong the same way (`latitude`/`longitude` +where `renderCxBooking` emits `lat`/`lng`). `docs/express-console-api.md` also +claimed pagination "default 500, cap 1000" when the code is default 20, cap 100 +— which is what made the console size its page budget for twelve times the rows +it actually receives. + +**When a doc and a parser disagree here, the parser has won every time.** Three +separate client teams have now built against wrong Doormile docs in one week. + +### Still open from this report + +- **DM-02: email OTP returns 500 on production.** `SMTP_HOST`, `SMTP_USER` and + `SMTP_PASSWORD` all default to `""` and are not set. Either configure SMTP or + hide the app's Email tab — offering a path that always fails is worse than + not offering it. +- The **committed secrets** (`.env` and a live GCP service-account private key) + are still tracked in git and pushed. Removing the defaults above does not + help until those keys are **rotated**. +- `GET /api/v1/ready` returns **503 while its body says `"status":"ready"`** + (`routes/routes.go`) — a monitor reading the body sees the opposite of the + status code. + +--- + ## 9. Current blockers & open work (whole-project level) **[carried forward]** diff --git a/cmd/migrate_qdrant/main.go b/cmd/migrate_qdrant/main.go index 67ccc8c..11cb0a8 100644 --- a/cmd/migrate_qdrant/main.go +++ b/cmd/migrate_qdrant/main.go @@ -35,7 +35,11 @@ func main() { "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Kolkata", getenv("DB_HOST", "127.0.0.1"), getenv("DB_USER", "admin"), - getenv("DB_PASSWORD", "Package@321#"), + // No default: this used to carry the live database password, so the + // credential shipped with every clone of the repository. Unset means + // unset — the connection will fail with a clear error instead of + // silently reaching production. + getenv("DB_PASSWORD", ""), getenv("DB_NAME", "logistics"), getenv("DB_PORT", "5433"), ) diff --git a/config/config.go b/config/config.go index d5b6120..f224837 100644 --- a/config/config.go +++ b/config/config.go @@ -1,7 +1,13 @@ package config import ( + "crypto/rand" + "encoding/hex" + "fmt" "os" + "strings" + + "doormile/utils" ) type Config struct { @@ -52,25 +58,93 @@ type Config struct { } func Load() *Config { - return &Config{ - Env: getEnv("ENV", "development"), - Port: getEnv("APP_PORT", "8081"), - DBName: getEnv("DB_NAME", "logistics"), - DBUser: getEnv("DB_USER", "admin"), - DBPassword: getEnv("DB_PASSWORD", "Package@321#"), - DBPort: getEnv("DB_PORT", "5433"), - DBHost: getEnv("DB_HOST", "127.0.0.1"), - RedisHost: getEnv("REDIS_HOST", "127.0.0.1"), - RedisPort: getEnv("REDIS_PORT", "6379"), - RedisUser: getEnv("REDIS_USER", ""), - RedisPassword: getEnv("REDIS_PASSWORD", ""), - JWTSecret: getEnv("JWT_SECRET_KEY", "DoormileSuperSecretJWTKey2026!"), - NatsURL: getEnv("NATS_URL", "nats://66.116.226.161:4223"), - NatsUser: getEnv("NATS_USER", "doormile"), - NatsPassword: getEnv("NATS_PASSWORD", "Package@321#"), - AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"), + cfg := load() + cfg.hardenSecrets() + return cfg +} - RouteOptimizerURL: getEnv("ROUTE_OPTIMIZER_URL", "https://routes.workolik.com"), +// IsProduction reports whether this process is running as production. Used by +// the guards that must behave differently there — a fixed OTP, a missing JWT +// secret — rather than scattering string comparisons. +func (c *Config) IsProduction() bool { + return strings.EqualFold(strings.TrimSpace(c.Env), "production") +} + +// hardenSecrets refuses to let the service run on a guessable signing key. +// +// JWT_SECRET_KEY used to default to a literal in this file. Anyone holding the +// repository could mint a token for any user id and any role, against any +// deployment that had not overridden it — which is the whole authorisation +// model, given away by a git clone. +// +// In production an unset secret is fatal: booting with a known key is worse +// than not booting, because nothing external shows that anything is wrong. +// Anywhere else it becomes a random per-process key, so local development +// works without configuration while tokens stop surviving a restart and can +// never be valid anywhere but this process. +func (c *Config) hardenSecrets() { + if strings.TrimSpace(c.JWTSecret) != "" { + return + } + // In production an absent secret is left absent, so Validate can refuse the + // boot with a clear message. Generating one here would be worse than the + // old default: every restart would invalidate every live session, and + // nothing would say why. + if c.IsProduction() { + return + } + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + // Leave it empty; Validate turns this into a refusal to start. + return + } + c.JWTSecret = hex.EncodeToString(b) + utils.Warn("JWT_SECRET_KEY is not set — generated an ephemeral key for this process only. " + + "Tokens will not survive a restart. Set JWT_SECRET_KEY for a stable local session.") +} + +// Validate reports configuration that must prevent the service from starting. +// Called by main; kept separate from Load so that loading stays free of side +// effects and the package remains testable. +func (c *Config) Validate() error { + if strings.TrimSpace(c.JWTSecret) == "" { + return fmt.Errorf("JWT_SECRET_KEY is not set (ENV=%s): refusing to start, because "+ + "booting on a default or empty signing key lets anyone holding this repository "+ + "mint a valid token for any account", c.Env) + } + return nil +} + +func load() *Config { + return &Config{ + Env: getEnv("ENV", "development"), + Port: getEnv("APP_PORT", "8081"), + DBName: getEnv("DB_NAME", "logistics"), + DBUser: getEnv("DB_USER", "admin"), + DBPassword: getEnv("DB_PASSWORD", ""), + DBPort: getEnv("DB_PORT", "5433"), + DBHost: getEnv("DB_HOST", "127.0.0.1"), + RedisHost: getEnv("REDIS_HOST", "127.0.0.1"), + RedisPort: getEnv("REDIS_PORT", "6379"), + RedisUser: getEnv("REDIS_USER", ""), + RedisPassword: getEnv("REDIS_PASSWORD", ""), + // No default. See hardenSecrets below — an unset secret is either a + // refusal to boot or an ephemeral per-process key, never a shared one + // baked into the source. + JWTSecret: getEnv("JWT_SECRET_KEY", ""), + + // These defaulted to the real production hosts and credentials, which + // meant `go run .` on a laptop silently joined the live NATS stream and + // competed with the production workers for the same durable consumer. + // Empty now: InitNATS skips connecting, routing.BaseURL == "" disables + // sequencing, and the AI layer falls back to legacy scoring. Fail + // closed, so reaching production is something you opt into. + NatsURL: getEnv("NATS_URL", ""), + NatsUser: getEnv("NATS_USER", ""), + NatsPassword: getEnv("NATS_PASSWORD", ""), + AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", ""), + + RouteOptimizerURL: getEnv("ROUTE_OPTIMIZER_URL", ""), GeocoderURL: getEnv("GEOCODER_URL", "https://nominatim.openstreetmap.org"), GeocoderEmail: getEnv("GEOCODER_EMAIL", ""), TrustedProxies: getEnv("TRUSTED_PROXIES", ""), diff --git a/config/secrets_test.go b/config/secrets_test.go new file mode 100644 index 0000000..a2b356c --- /dev/null +++ b/config/secrets_test.go @@ -0,0 +1,142 @@ +package config + +import ( + "strings" + "testing" +) + +// DM-06: config.go used to default JWT_SECRET_KEY, the NATS URL and its +// credentials, and the AI/optimiser hosts to the REAL production values. Two +// consequences: anyone holding the repository could mint a valid token for any +// account against a deployment that had not overridden the secret, and any +// local run silently joined the live NATS stream. + +// The literals that must never come back. Written out so a revert is a test +// failure rather than something noticed in review. +func TestProductionValuesAreNotDefaults(t *testing.T) { + for _, key := range []string{ + "JWT_SECRET_KEY", "NATS_URL", "NATS_USER", "NATS_PASSWORD", + "AI_LAYER_BASE_URL", "ROUTE_OPTIMIZER_URL", "DB_PASSWORD", + } { + setEnv(t, key, "") + } + setEnv(t, "ENV", "development") + + cfg := Load() + + banned := map[string]string{ + "NatsURL": cfg.NatsURL, + "NatsUser": cfg.NatsUser, + "NatsPassword": cfg.NatsPassword, + "AILayerBaseURL": cfg.AILayerBaseURL, + "RouteOptimizerURL": cfg.RouteOptimizerURL, + "DBPassword": cfg.DBPassword, + } + for field, got := range banned { + if got != "" { + t.Errorf("%s defaulted to %q — production values must not be defaults", field, got) + } + } + + // The old hardcoded secret must not be what we sign with. + if cfg.JWTSecret == "DoormileSuperSecretJWTKey2026!" { + t.Error("JWTSecret fell back to the literal that used to be in config.go") + } +} + +// With no secret configured outside production the service still runs, but on +// a key that exists only for this process. +func TestUnsetSecretOutsideProductionIsEphemeralNotShared(t *testing.T) { + setEnv(t, "JWT_SECRET_KEY", "") + setEnv(t, "ENV", "development") + + first := Load().JWTSecret + second := Load().JWTSecret + + if first == "" || second == "" { + t.Fatal("an unset secret produced an empty signing key; tokens would be forgeable") + } + if first == second { + t.Error("two loads produced the same generated key — it is not ephemeral") + } + if len(first) < 32 { + t.Errorf("generated key is %d chars, too short to be a signing key", len(first)) + } +} + +// In production an absent secret is NOT quietly replaced — it is left absent so +// Validate can refuse the boot with a message that says why. Silently +// generating one would invalidate every live session on each restart. +func TestProductionRefusesToStartWithoutASecret(t *testing.T) { + setEnv(t, "JWT_SECRET_KEY", "") + setEnv(t, "ENV", "production") + + cfg := Load() + if cfg.JWTSecret != "" { + t.Errorf("production generated a secret (%q); it must stay empty so Validate fails", cfg.JWTSecret) + } + err := cfg.Validate() + if err == nil { + t.Fatal("Validate accepted an empty JWT secret in production") + } + if !strings.Contains(err.Error(), "JWT_SECRET_KEY") { + t.Errorf("Validate error does not name the variable: %v", err) + } +} + +// Outside production the ephemeral key is enough to pass validation, so local +// development needs no configuration at all. +func TestValidatePassesOutsideProductionWithNoSecret(t *testing.T) { + setEnv(t, "JWT_SECRET_KEY", "") + setEnv(t, "ENV", "development") + + if err := Load().Validate(); err != nil { + t.Errorf("development should boot without a configured secret: %v", err) + } +} + +// A configured secret always validates, production or not. +func TestValidatePassesWithAConfiguredSecret(t *testing.T) { + setEnv(t, "JWT_SECRET_KEY", "a-real-configured-secret") + setEnv(t, "ENV", "production") + + if err := Load().Validate(); err != nil { + t.Errorf("a configured secret must validate: %v", err) + } +} + +// An explicitly configured secret is always used verbatim. +func TestConfiguredSecretIsUsedVerbatim(t *testing.T) { + setEnv(t, "JWT_SECRET_KEY", "a-real-configured-secret") + setEnv(t, "ENV", "production") + + if got := Load().JWTSecret; got != "a-real-configured-secret" { + t.Errorf("JWTSecret = %q, want the configured value", got) + } +} + +func TestIsProductionIsCaseAndSpaceInsensitive(t *testing.T) { + setEnv(t, "JWT_SECRET_KEY", "x") + for _, env := range []string{"production", "Production", "PRODUCTION", " production "} { + setEnv(t, "ENV", env) + if !Load().IsProduction() { + t.Errorf("ENV=%q was not treated as production", env) + } + } + for _, env := range []string{"development", "staging", "test", ""} { + setEnv(t, "ENV", env) + if Load().IsProduction() { + t.Errorf("ENV=%q was treated as production", env) + } + } +} + +// The geocoder is a PUBLIC service, not a Doormile host, so it keeps its +// default — removing it would break place search for no security gain. +func TestGeocoderKeepsItsPublicDefault(t *testing.T) { + setEnv(t, "JWT_SECRET_KEY", "x") + setEnv(t, "GEOCODER_URL", "") + if got := Load().GeocoderURL; !strings.Contains(got, "nominatim") { + t.Errorf("GeocoderURL = %q, want the public Nominatim default", got) + } +} diff --git a/controllers/cxAuthController.go b/controllers/cxAuthController.go index 9fa56cd..c8f948d 100644 --- a/controllers/cxAuthController.go +++ b/controllers/cxAuthController.go @@ -201,14 +201,38 @@ func issueCxOtp(cfg *config.Config, identifier, kind string) (resendAfter int, e db.Rdb.Del(ctx, cxOtpTriesKey(identifier)) db.Rdb.Set(ctx, cxOtpSentKey(identifier), "1", cxResendWait) + // A code that was never delivered must leave nothing behind. + // + // The stored code, the resend cooldown and the rate-limit slot are all + // written BEFORE delivery is attempted, because they have to be — the code + // has to exist before it can be sent. But when sending fails, keeping them + // punishes the customer for the gateway's failure: they are told something + // went wrong, cannot resend until the cooldown expires, and have spent one + // of their five hourly codes — while a valid code they never received sits + // live in Redis for its full TTL. + // + // So unwind all three on failure. The customer can retry immediately, and + // nothing usable is left in Redis. + rollback := func() { + rctx, rcancel := context.WithTimeout(context.Background(), 3*time.Second) + defer rcancel() + db.Rdb.Del(rctx, cxOtpKey(identifier)) + db.Rdb.Del(rctx, cxOtpSentKey(identifier)) + // Give the slot back rather than deleting the window: DECR keeps the + // hourly window honest for codes that DID go out. + db.Rdb.Decr(rctx, cxOtpRateKey(identifier)) + } + if kind == "email" { if merr := mail.SendOTPEmail(cfg, identifier, code); merr != nil { - utils.Warn("cx auth: failed to send OTP email", "error", merr) + utils.Warn("cx auth: failed to send OTP email — rolling back the stored code", "error", merr) + rollback() return 0, merr } } else { if serr := sms.SendOTP(identifier, code); serr != nil { - utils.Warn("cx auth: failed to send OTP sms", "error", serr) + utils.Warn("cx auth: failed to send OTP sms — rolling back the stored code", "error", serr) + rollback() return 0, serr } } @@ -368,18 +392,37 @@ func CxVerifyOtp(cfg *config.Config) fiber.Handler { var req struct { Identifier string `json:"identifier"` Code string `json:"code"` - Name string `json:"name"` + // Otp is a DEPRECATED alias for Code, and the only reason sign-in + // works for anyone on an already-installed build. + // + // The customer app was written against a spec that named this + // field "otp". The server only ever read "code", so req.Code was + // always empty, the empty-code branch below always fired, and + // EVERY sign-in failed with a 400 — a correct code failed exactly + // like a wrong one. Fixing the app alone would have left every + // customer locked out until they updated; accepting both keys + // fixes them all without a release. + // + // "code" stays the documented field. Remove this once the install + // base has moved on. + Otp string `json:"otp"` + Name string `json:"name"` } if err := c.BodyParser(&req); err != nil { return utils.CxBadRequest(c, "We could not read that request") } + code := strings.TrimSpace(req.Code) + if code == "" { + code = strings.TrimSpace(req.Otp) + } + identifier, kind, ok := normalizeIdentifier(req.Identifier) - if !ok || strings.TrimSpace(req.Code) == "" { + if !ok || code == "" { return utils.CxBadRequest(c, "Enter the code we sent you") } - if !consumeCxOtp(identifier, strings.TrimSpace(req.Code)) { + if !consumeCxOtp(identifier, code) { return utils.CxFail(c, fiber.StatusUnauthorized, utils.CxErrInvalidOtp, "That code did not match") } diff --git a/controllers/cxOtpFieldAlias_test.go b/controllers/cxOtpFieldAlias_test.go new file mode 100644 index 0000000..385e899 --- /dev/null +++ b/controllers/cxOtpFieldAlias_test.go @@ -0,0 +1,88 @@ +package controllers + +import ( + "encoding/json" + "strings" + "testing" +) + +// DM-01: the customer app posts the verification code as "otp"; the server was +// written to read "code". req.Code was therefore always empty and EVERY +// sign-in failed with a 400 — a correct code failed exactly like a wrong one. +// +// These pin the alias. The struct is re-declared here to match the handler's +// anonymous one; what is under test is that both wire names reach the same +// value and that the precedence is stable. +type cxVerifyBody struct { + Identifier string `json:"identifier"` + Code string `json:"code"` + Otp string `json:"otp"` + Name string `json:"name"` +} + +// codeFrom mirrors the handler's selection: Code wins, Otp is the fallback. +func codeFrom(b cxVerifyBody) string { + code := strings.TrimSpace(b.Code) + if code == "" { + code = strings.TrimSpace(b.Otp) + } + return code +} + +func parseVerify(t *testing.T, raw string) cxVerifyBody { + t.Helper() + var b cxVerifyBody + if err := json.Unmarshal([]byte(raw), &b); err != nil { + t.Fatalf("unmarshal %s: %v", raw, err) + } + return b +} + +// The shape the app actually sends. This is the regression that locked every +// customer out of production. +func TestVerifyAcceptsTheAppsOtpField(t *testing.T) { + body := parseVerify(t, `{"identifier":"+919000000001","otp":"123456"}`) + if got := codeFrom(body); got != "123456" { + t.Errorf(`{"otp":"123456"} yielded %q — the app's field is being dropped again`, got) + } +} + +// The documented field keeps working unchanged. +func TestVerifyStillAcceptsCode(t *testing.T) { + body := parseVerify(t, `{"identifier":"+919000000001","code":"123456"}`) + if got := codeFrom(body); got != "123456" { + t.Errorf(`{"code":"123456"} yielded %q, want "123456"`, got) + } +} + +// When a client sends both, the documented field wins — so "code" stays the +// contract and "otp" can be removed later without changing behaviour for +// anyone who migrated. +func TestCodeWinsOverOtpWhenBothArePresent(t *testing.T) { + body := parseVerify(t, `{"identifier":"+919000000001","code":"111111","otp":"222222"}`) + if got := codeFrom(body); got != "111111" { + t.Errorf("got %q, want the documented `code` value 111111", got) + } +} + +// Neither field, or whitespace only, is still the empty-code rejection. The +// alias must not turn a missing code into an accepted one. +func TestMissingOrBlankCodeIsStillRejected(t *testing.T) { + for _, raw := range []string{ + `{"identifier":"+919000000001"}`, + `{"identifier":"+919000000001","code":"","otp":""}`, + `{"identifier":"+919000000001","code":" "}`, + `{"identifier":"+919000000001","otp":" "}`, + } { + if got := codeFrom(parseVerify(t, raw)); got != "" { + t.Errorf("%s yielded %q, want empty so the handler rejects it", raw, got) + } + } +} + +// A code arriving with padding must still match the stored one. +func TestPaddedOtpIsTrimmed(t *testing.T) { + if got := codeFrom(parseVerify(t, `{"identifier":"x","otp":" 123456 "}`)); got != "123456" { + t.Errorf("got %q, want the trimmed 123456", got) + } +} diff --git a/db/connect.go b/db/connect.go index a7092e8..bf8b337 100644 --- a/db/connect.go +++ b/db/connect.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "os" + "strings" "time" "doormile/config" @@ -142,6 +143,17 @@ func InitRedis(cfg *config.Config) { } func InitNATS(cfg *config.Config) { + // No URL means NATS is deliberately not configured, so do not connect. + // This used to default to the production cluster, which meant any local + // run joined the live stream and competed with the real workers for the + // same durable pull consumer — messages got redelivered rather than lost, + // but it was production churn caused by someone running the repo. + if strings.TrimSpace(cfg.NatsURL) == "" { + utils.Info("NATS_URL is not set — running without NATS. " + + "Publishes are dropped and no consumer is started.") + return + } + var err error Nc, err = nats.Connect( cfg.NatsURL, diff --git a/db/nats_guard_test.go b/db/nats_guard_test.go new file mode 100644 index 0000000..47ff3d6 --- /dev/null +++ b/db/nats_guard_test.go @@ -0,0 +1,50 @@ +package db + +import ( + "testing" + + "doormile/config" +) + +// DM-06: NATS_URL used to default to the production cluster, so a backend run +// locally with no NATS configuration silently joined the live stream and +// competed with the production workers for the same durable pull consumer. +// That happened for real on 2026-09-11. +// +// The default is now empty, and empty must mean "do not connect" rather than +// "connect to whatever nats.Connect does with an empty string" — which would +// be localhost:4222, i.e. still a connection attempt. +func TestInitNATSSkipsWhenNoURLIsConfigured(t *testing.T) { + previousNc, previousJs := Nc, Js + t.Cleanup(func() { Nc, Js = previousNc, previousJs }) + Nc, Js = nil, nil + + for _, url := range []string{"", " "} { + Nc, Js = nil, nil + InitNATS(&config.Config{NatsURL: url}) + if Nc != nil { + t.Errorf("NatsURL=%q opened a connection; empty must mean no NATS", url) + Nc.Close() + Nc = nil + } + if Js != nil { + t.Errorf("NatsURL=%q initialised JetStream; empty must mean no NATS", url) + } + } +} + +// The config side of the same guarantee: no NATS setting may carry a real +// default, or the guard above is bypassed before it is ever reached. +func TestNATSConfigHasNoProductionDefaults(t *testing.T) { + for _, key := range []string{"NATS_URL", "NATS_USER", "NATS_PASSWORD"} { + t.Setenv(key, "") + } + t.Setenv("JWT_SECRET_KEY", "test") + t.Setenv("ENV", "development") + + cfg := config.Load() + if cfg.NatsURL != "" || cfg.NatsUser != "" || cfg.NatsPassword != "" { + t.Errorf("NATS settings defaulted to %q / %q / %q — all must be empty", + cfg.NatsURL, cfg.NatsUser, cfg.NatsPassword) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cbe9996..0aad410 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,73 @@ This document provides a comprehensive log of the major features, architectural --- +## 0. Customer sign-in fix, config hardening & admin ordering (2026-09-11) + +Six defects were reported against customer bookings. Five were real, one was +not. Full detail in `CLAUDE.md` §8.6. + +**Fixed** + +- **`POST /customer/auth/otp/verify` now accepts `otp` as well as `code`.** + The handler only ever read `code`; the app sent `otp`, because this repo's + own quick-reference documented `otp` while `openapi-customer.yaml` said + `code`. Every sign-in failed with a `400` — a correct code failed exactly + like a wrong one. `code` remains the contract and wins when both are sent; + `otp` is a deprecated alias kept so builds already installed keep working. +- **A failed OTP send no longer leaves a live code behind.** `issueCxOtp` now + rolls back the stored code, the resend cooldown and the rate-limit slot when + SMS or email delivery fails, instead of charging the customer for the + gateway's failure. +- **`GET /admin/bookings` is ordered `bookingid DESC`.** It had no `ORDER BY`, + so row order was unspecified — in practice oldest first, putting the newest + booking on the last page and outside any client that reads a bounded number + of pages. `OFFSET` paging over an unordered result was also unstable. +- **Production hosts and credentials removed from `config.Load()` defaults.** + `JWT_SECRET_KEY`, `NATS_URL`/`NATS_USER`/`NATS_PASSWORD`, + `AI_LAYER_BASE_URL`, `ROUTE_OPTIMIZER_URL` and `DB_PASSWORD` all defaulted to + real values, so a clone of this repo could mint a valid token for any account + and any local run joined the live NATS stream. A second hardcoded production + URL in `internal/assignment/ai_layer.go` was removed too. + +**Behaviour change to be aware of when deploying** + +`cfg.Validate()` now runs at startup and **the service refuses to boot when +`JWT_SECRET_KEY` is unset and `ENV=production`**. Outside production an +ephemeral per-process key is generated with a warning, so local development +needs no configuration — but tokens no longer survive a restart unless you set +the variable. Make sure `JWT_SECRET_KEY` is present in the production +environment before the next deploy. + +Unset `NATS_URL` now means "no NATS" rather than "production NATS": publishes +are dropped and no consumer starts. Set it explicitly wherever NATS is wanted. + +**Investigated and rejected** + +A reported +5:30 timestamp drift (`DBNow()` vs `timestamp with time zone` +columns) does **not** exist on production — the columns there are `timestamp +without time zone`, which is what `DBNow()` assumes, confirmed by a round-trip +with zero drift. Changing `DBNow()` would introduce the bug. The real risk is +that GORM's `AutoMigrate` produces `timestamptz`, so a freshly built schema +does not match production and every new dev environment shows a drift that +production does not. + +**Docs corrected** + +`customer-app-api-crisp.md` carried three request shapes that did not match +their parsers (`auth/otp/verify`, `fare/estimate`, `bookings`) plus a wrong +booking response shape; `express-console-api.md` documented pagination as +"default 500, cap 1000" when the code enforces default 20, cap 100. Every one +of these failed silently through `BodyParser` or a page budget, never as an +error. + +**Still open** + +Email OTP returns 500 on production (`SMTP_*` unset); `.env` and a live GCP +service-account key remain committed and need rotating; `GET /api/v1/ready` +returns 503 with a body that says `"status":"ready"`. + +--- + ## 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. diff --git a/docs/customer-app-api-crisp.md b/docs/customer-app-api-crisp.md index 86335a6..25713e2 100644 --- a/docs/customer-app-api-crisp.md +++ b/docs/customer-app-api-crisp.md @@ -84,11 +84,22 @@ ## 3. Core Request & Response Payloads ### 1) OTP Verification (`POST /customer/auth/otp/verify`) + +> **The field is `code`, not `otp`.** This section said `otp` until 11 Sep 2026 +> and the customer app was built against it, while the server only ever read +> `code`. Every sign-in therefore failed with a `400 "Enter the code we sent +> you"` — a correct code failed exactly like a wrong one. `openapi-customer.yaml` +> had it right all along; the two disagreed and this one was wrong. +> +> The server now also accepts `otp` as a **deprecated alias**, so builds already +> in customers' hands keep working. Send `code`. If both are present, `code` +> wins. + ```json // Request { "identifier": "+919876543210", - "otp": "1234" + "code": "1234" } // Response (200 OK) @@ -109,26 +120,25 @@ ``` ### 2) Fare Estimate (`POST /customer/fare/estimate`) + +> **Corrected 11 Sep 2026** against `cxEstimateRequest` +> (`controllers/cxFareController.go`). The old shape used +> `pickup.latitude`/`longitude` (parsed as `lat`/`lng`), gave `pickup` a +> `stateCode`/`districtCode` it does not have, and described destinations as +> carrying a `packages` array of weights. The estimate is priced on +> `packageCount`; per-package weight is not known until the miler weighs it at +> the door. + ```json // Request { "pickup": { - "latitude": 13.0827, - "longitude": 80.2707, - "stateCode": "TN", - "districtCode": "CHN" + "lat": 13.0827, + "lng": 80.2707 }, "destinations": [ - { - "stateCode": "TN", - "districtCode": "CHN", - "packages": [{ "weightKg": 2.5 }] - }, - { - "stateCode": "KA", - "districtCode": "BLR", - "packages": [{ "weightKg": 1.0 }] - } + { "stateCode": "TN", "districtCode": "CHN", "packageCount": 2 }, + { "stateCode": "KA", "districtCode": "BLR", "packageCount": 1 } ] } @@ -149,6 +159,20 @@ ``` ### 3) Booking Creation (`POST /customer/bookings`) + +> **Corrected 11 Sep 2026.** The shape below previously did not match the +> parser (`cxCreateBookingRequest`, `controllers/cxBookingController.go`), and +> every mismatch failed **silently** through `BodyParser` — no error, just a +> zero value: +> +> | Was documented | Actually parsed | Effect of following the old doc | +> |---|---|---| +> | `pickup.latitude` / `longitude` | `pickup.lat` / `lng` | pickup coordinates `0` | +> | `pickup.contactName` / `contactPhone` | *not read at all* | dropped | +> | destination fields flat | nested under `details` | every recipient/address field dropped | +> | destination `latitude` / `longitude` | `details.pin.lat` / `lng` | drop coordinates `0` | +> | `estimate` absent from the doc | **is** read | the quote shown to the customer was not recorded | + ```json // Request { @@ -156,26 +180,27 @@ "pickup": { "title": "Home", "sub": "Flat 4B, Green Towers, Anna Nagar", - "latitude": 13.0827, - "longitude": 80.2707, - "contactName": "Alex Kumar", - "contactPhone": "+919876543210" + "lat": 13.0827, + "lng": 80.2707 }, "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, + "districtCode": "CHN", "packageCount": 1, - "codAmount": 450 + "details": { + "street": "MG Road", + "building": "12/A", + "landmark": "Near Metro", + "recipientName": "Priya S", + "recipientPhone": "+919840123456", + "instructions": "Ring the bell", + "pin": { "lat": 13.0850, "lng": 80.2100 }, + "codAmount": 450 + } } ], + "estimate": { "min": 240, "max": 310 }, "remarks": "Handle with care" } @@ -192,16 +217,18 @@ "pickup": { "title": "Home", "sub": "Flat 4B, Green Towers, Anna Nagar", - "latitude": 13.0827, - "longitude": 80.2707 + "lat": 13.0827, + "lng": 80.2707 }, "destinations": [ { "index": 0, + "stateCode": "TN", "stateName": "Tamil Nadu", + "districtCode": "CHN", "districtName": "Chennai", "packageCount": 1, - "codAmount": 450, + "details": { "recipientName": "Priya S", "codAmount": 450 }, "trackingId": null, "stage": null } diff --git a/docs/customer-app-handover-2026-09-15.md b/docs/customer-app-handover-2026-09-15.md new file mode 100644 index 0000000..ee3293f --- /dev/null +++ b/docs/customer-app-handover-2026-09-15.md @@ -0,0 +1,297 @@ +# Doormile backend → customer app · what changed + +**For:** the `doormile_cx` app team +**From:** Doormile backend +**Date:** 15 Sep 2026 +**Verified against:** production Postgres + Redis, and the code in this repo + +--- + +## Read this first + +Two things decide what you can do today: + +1. **Send the verification code as `code`, not `otp`.** That works against + production right now. The server-side `otp` alias described below is written + but **not deployed yet** — do not rely on it until we confirm it has shipped. +2. **Booking creation is currently blocked on our side**, for a reason that has + nothing to do with your app. See [Still blocked](#still-blocked-on-our-side). + Sign-in will work before booking does. + +Everything else here is context for why your existing integration was failing. + +--- + +## 1. Sign-in was broken by a field name, and it was our documentation's fault + +Your app posted the code as `otp`. The server only ever read `code`. So the +parsed value was always empty, the "no code supplied" branch always fired, and +**every** sign-in returned: + +``` +400 {"error":{"code":"invalid"},"message":"Enter the code we sent you"} +``` + +A correct code failed exactly the same way as a wrong one. No amount of SMS +gateway credit would have changed it. + +**This was our fault, not yours.** Two of our documents disagreed: + +| Document | Said | Correct? | +|---|---|---| +| `customer-app-api-crisp.md` | `otp` | ❌ wrong — you built against this | +| `openapi-customer.yaml` | `code` | ✅ right | + +`customer-app-api-crisp.md` has been corrected. + +### What to send + +```jsonc +POST /api/v1/customer/auth/otp/verify +{ + "identifier": "+919876543210", + "code": "1234" // ← `code`, always +} +``` + +**Response 200:** + +```jsonc +{ + "success": true, + "data": { + "accessToken": "eyJhbGciOi...", + "refreshToken": "d8f1e2a3...", // 64 hex chars + "expiresIn": 3600, // seconds + "customer": { "id": "cust_294", "name": "...", "phone": "+91...", "email": "" } + } +} +``` + +### About the `otp` alias + +We are adding server-side acceptance of `otp` as a **deprecated alias**, so +builds already on customers' phones start working without an app release. When +both keys are present, `code` wins. + +**It is not deployed yet.** Treat it as a safety net for old installs, not as a +reason to keep sending `otp`. Please migrate to `code`. + +--- + +## 2. Three request shapes in our docs did not match the server + +Every one of these failed **silently** — our parser ignores unknown keys, so a +wrong field name produced a zero value, not an error. No 400, no log, just a +booking with coordinates of `0` or a missing recipient. + +If you built any of these from `customer-app-api-crisp.md` before 11 Sep, they +need changing. + +### 2.1 `POST /customer/auth/otp/verify` + +| Was documented | Server actually reads | +|---|---| +| `otp` | `code` | + +### 2.2 `POST /customer/fare/estimate` + +| Was documented | Server actually reads | +|---|---| +| `pickup.latitude` / `pickup.longitude` | `pickup.lat` / `pickup.lng` | +| `pickup.stateCode` / `districtCode` | *not read — pickup has only lat/lng* | +| `destinations[].packages[].weightKg` | `destinations[].packageCount` | + +Per-package weight is not an input. The estimate is priced on package **count**; +real weight is not known until the miler weighs it at the door. + +**Correct request:** + +```jsonc +{ + "pickup": { "lat": 13.0827, "lng": 80.2707 }, + "destinations": [ + { "stateCode": "TN", "districtCode": "CHN", "packageCount": 2 }, + { "stateCode": "KA", "districtCode": "BLR", "packageCount": 1 } + ] +} +``` + +### 2.3 `POST /customer/bookings` — the one with the most wrong fields + +| Was documented | Server actually reads | If you send the old shape | +|---|---|---| +| `pickup.latitude` / `longitude` | `pickup.lat` / `lng` | pickup coordinates become **0** | +| `pickup.contactName` / `contactPhone` | *not read at all* | silently dropped | +| destination fields **flat** | nested under `details` | **every** recipient/address field dropped | +| destination `latitude` / `longitude` | `details.pin.lat` / `lng` | drop coordinates become **0** | +| `estimate` not documented | **is** read and stored | the quote shown to the customer is not recorded | + +**Correct request:** + +```jsonc +{ + "slotId": "slot_20260916_t2", + "pickup": { + "title": "Home", + "sub": "Flat 4B, Green Towers, Anna Nagar", + "lat": 13.0827, + "lng": 80.2707 + }, + "destinations": [ + { + "stateCode": "TN", + "districtCode": "CHN", + "packageCount": 2, + "details": { + "street": "MG Road", + "building": "12/A", + "landmark": "Near Metro", + "recipientName": "Priya S", + "recipientPhone": "+919840123456", + "instructions": "Ring the bell", + "pin": { "lat": 13.0850, "lng": 80.2100 }, + "codAmount": 450 + } + } + ], + "estimate": { "min": 240, "max": 310 }, + "remarks": "Handle with care" +} +``` + +**The booking *response* was also documented wrong** — it returns +`pickup.lat` / `lng`, not `latitude` / `longitude`. If you parse the response +for coordinates, check that too. + +--- + +## 3. `remarks` now actually saves + +The top-level `remarks` field you were already sending was being dropped — the +server's request struct had no field for it, so `BodyParser` discarded it and +the booking's note was empty for every customer-app booking. The admin console +displays and searches that column, so operators saw nothing. + +Fixed and **merged to main**. Keep sending it exactly as you are. + +--- + +## 4. Auth flow, confirmed working end to end + +We created a test customer through the live API and verified the whole +sequence. There is no separate "request OTP" step after signup — signup sends +the code itself. + +``` +POST /api/v1/customer/auth/signup { name, phone, email? } → 200 {sent, resendAfterSeconds} +POST /api/v1/customer/auth/otp/verify { identifier, code } → 200 {accessToken, refreshToken, ...} +``` + +For an existing account: + +``` +POST /api/v1/customer/auth/otp/request { identifier } → 200 +POST /api/v1/customer/auth/otp/verify { identifier, code } → 200 +``` + +Two behaviours worth coding for, both confirmed by testing: + +- **The code is single-use.** After a successful verify it is deleted. Logging + in again requires a fresh `otp/request` first — re-sending the same code + returns `401 invalid_otp`. +- **There is a 30-second resend cooldown.** Calling `otp/request` inside that + window does not issue a new code. Respect `resendAfterSeconds` from the + response rather than retrying blindly. + +### Refresh + +``` +POST /api/v1/customer/auth/refresh { refreshToken } +``` + +Refresh tokens **rotate** — the presented one is revoked and replaced. Replaying +an already-used refresh token **revokes every session for that customer**, so +never keep an old one around as a fallback. Store only the newest. + +Access tokens last 1 hour (`expiresIn: 3600`). + +--- + +## 5. Do not offer the Email tab yet + +``` +POST /api/v1/customer/auth/otp/request {"identifier":"someone@example.com"} +→ 500 {"error":{"code":"server_error"},"message":"Something went wrong"} +``` + +SMTP is not configured on production (`SMTP_HOST`, `SMTP_USER`, +`SMTP_PASSWORD` are all unset). Email sign-in fails every time. + +**Please hide or disable the Email option** until we confirm SMTP is live. +Offering a path that always fails is worse than not offering it. + +--- + +## Still blocked on our side + +**You will not be able to create a booking yet, no matter what you send.** + +Both serviceability tables are empty on production: + +``` +serviceablestates 0 rows +serviceabledistricts 0 rows +``` + +`CreateCxBooking` validates every destination against that catalogue, so with +zero rows every booking is rejected with: + +``` +400 {"message":"Every destination needs a serviceable state and district"} +``` + +And `GET /customer/serviceability/states` returns `200` with an **empty list**, +so your state picker has nothing to show in the first place. + +This is ours to fix — the seed data exists (`seed_customer_app.sql`, 5 states +including Tamil Nadu / Kerala / Karnataka / Telangana / Puducherry, 22 +districts) and simply has not been applied to production. We will confirm when +it has. + +**Until then:** sign-in and the catalogue endpoints are what you can integrate +against. Booking creation will return a 400 that is not your bug. + +--- + +## Summary — what you need to change + +| # | Change | Priority | +|---|---|---| +| 1 | Send the verification code as **`code`**, not `otp` | **Required** — nothing works without it | +| 2 | Fare estimate: `pickup.lat`/`lng`, `packageCount` (no `packages[].weightKg`) | Required | +| 3 | Booking: `pickup.lat`/`lng`, destination details nested under `details`, coords at `details.pin` | Required | +| 4 | Parse the booking response's `pickup.lat`/`lng` (not `latitude`/`longitude`) | Required | +| 5 | Send `estimate: {min, max}` on booking create | Recommended — it is the dispute record | +| 6 | Hide the Email sign-in tab | Recommended | +| 7 | Handle single-use codes + the 30s resend cooldown | Recommended | +| 8 | Store only the newest refresh token, never replay an old one | Recommended | + +--- + +## Status of the backend changes referenced here + +| Change | State | +|---|---| +| `remarks` saved on booking create | **Merged to main** | +| Doc corrections (`customer-app-api-crisp.md`) | **In review** | +| `otp` accepted as alias for `code` | **In review — not deployed** | +| Failed OTP send no longer burns the cooldown / rate limit | **In review** | +| Serviceability seed applied to production | **Not done** | +| SMTP configured for email OTP | **Not done** | + +"In review" means written and tested but not yet on `api.doormile.com`. Build +against `code` and the corrected shapes — those are correct regardless of +deployment order. We will confirm when the alias and the seed are live. + +Questions → the backend team. diff --git a/internal/assignment/ai_layer.go b/internal/assignment/ai_layer.go index 2b18200..3ca6aff 100644 --- a/internal/assignment/ai_layer.go +++ b/internal/assignment/ai_layer.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "strconv" + "strings" "time" "doormile/constants" @@ -263,9 +264,15 @@ func pickBestFromCandidates(candidates []*milerCandidate) *milerCandidate { // ─── AI layer HTTP call ────────────────────────────────────────────────────── func callDecisionEngine(booking *models.PickupBooking, candidates []aiCandidate) (aiDecisionResponse, error) { - baseURL := os.Getenv("AI_LAYER_BASE_URL") + // No hardcoded fallback. This used to default to the production AI layer, + // so a developer running the backend locally sent real booking and rider + // data to it without ever configuring anything. Unset now means "no AI + // layer": the caller already falls back to legacy scoring when this + // returns an error (see AI_LAYER_FALLBACK above), so degrading is the + // designed path rather than a new one. + baseURL := strings.TrimSpace(os.Getenv("AI_LAYER_BASE_URL")) if baseURL == "" { - baseURL = "https://routemate.workolik.com" + return aiDecisionResponse{}, fmt.Errorf("AI_LAYER_BASE_URL is not set") } now := time.Now() diff --git a/main.go b/main.go index 4ddc62e..31376b0 100644 --- a/main.go +++ b/main.go @@ -84,6 +84,15 @@ func main() { _ = godotenv.Load() cfg := config.Load() + // Refuse to start on configuration that would be unsafe rather than merely + // wrong. JWT_SECRET_KEY used to default to a literal in config.go, which + // meant a clone of this repository was enough to mint a valid token for any + // account on any deployment that had not overridden it. + if err := cfg.Validate(); err != nil { + utils.Error("invalid configuration — refusing to start", "error", err) + os.Exit(1) + } + utils.Info("Starting Doormile Backend...") // 2. Connect to Postgres, Redis & NATS diff --git a/scratch/debug_booking.go b/scratch/debug_booking.go index 7d41386..0f93678 100644 --- a/scratch/debug_booking.go +++ b/scratch/debug_booking.go @@ -6,12 +6,21 @@ import ( "database/sql" "fmt" "log" + "os" _ "github.com/lib/pq" ) func main() { - dsn := "host=31.97.228.132 user=admin password=Package@321# dbname=logistics port=5433 sslmode=disable" + // Built from the environment, not hardcoded. This line used to carry the + // production host and password in plaintext, in a file tracked by git — so + // the live database credential shipped with every clone. Export DB_HOST, + // DB_USER, DB_PASSWORD, DB_NAME and DB_PORT (or source .env) before running. + dsn := fmt.Sprintf( + "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", + os.Getenv("DB_HOST"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), + os.Getenv("DB_NAME"), os.Getenv("DB_PORT"), + ) db, err := sql.Open("postgres", dsn) if err != nil { log.Fatalf("Open failed: %v", err) @@ -81,4 +90,4 @@ func main() { db.Exec("DELETE FROM pickupbookings WHERE bookingno = 'DM-BK-DEBUG-001'") fmt.Println("Cleaned up test row.") } -} \ No newline at end of file +} diff --git a/scratch/fix_booking_status_constraint.go b/scratch/fix_booking_status_constraint.go index a3f70f2..9692e29 100644 --- a/scratch/fix_booking_status_constraint.go +++ b/scratch/fix_booking_status_constraint.go @@ -6,12 +6,21 @@ import ( "database/sql" "fmt" "log" + "os" _ "github.com/lib/pq" ) func main() { - dsn := "host=31.97.228.132 user=admin password=Package@321# dbname=logistics port=5433 sslmode=disable" + // Built from the environment, not hardcoded. This line used to carry the + // production host and password in plaintext, in a file tracked by git — so + // the live database credential shipped with every clone. Export DB_HOST, + // DB_USER, DB_PASSWORD, DB_NAME and DB_PORT (or source .env) before running. + dsn := fmt.Sprintf( + "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", + os.Getenv("DB_HOST"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), + os.Getenv("DB_NAME"), os.Getenv("DB_PORT"), + ) db, err := sql.Open("postgres", dsn) if err != nil { log.Fatalf("Open failed: %v", err) @@ -57,4 +66,4 @@ func main() { WHERE rel.relname = 'pickupbookings' AND con.contype = 'c' `).Scan(&def) fmt.Println("Current constraint:", def) -} \ No newline at end of file +} diff --git a/scratch/list_tables.go b/scratch/list_tables.go index 45f4181..e5c1de7 100644 --- a/scratch/list_tables.go +++ b/scratch/list_tables.go @@ -6,13 +6,22 @@ import ( "database/sql" "fmt" "log" + "os" _ "github.com/lib/pq" ) func main() { // DSN matches the one in connect.go and .env - dsn := "host=31.97.228.132 user=admin password=Package@321# dbname=logistics port=5433 sslmode=disable" + // Built from the environment, not hardcoded. This line used to carry the + // production host and password in plaintext, in a file tracked by git — so + // the live database credential shipped with every clone. Export DB_HOST, + // DB_USER, DB_PASSWORD, DB_NAME and DB_PORT (or source .env) before running. + dsn := fmt.Sprintf( + "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", + os.Getenv("DB_HOST"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), + os.Getenv("DB_NAME"), os.Getenv("DB_PORT"), + ) db, err := sql.Open("postgres", dsn) if err != nil { log.Fatalf("Failed to open DB: %v", err) @@ -42,4 +51,4 @@ func main() { } fmt.Printf("Table: %s\n", name) } -} \ No newline at end of file +} From 5c73d769ca77279c7d6af56301bb1e291f2d51c7 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Tue, 15 Sep 2026 11:58:53 +0530 Subject: [PATCH 09/11] Revert "updates on the otp updates on the customer app" This reverts commit 89321c9e06a1bbb83fd1063528866dcf32879d81. --- .env.example | 30 +-- CLAUDE.md | 138 +---------- cmd/migrate_qdrant/main.go | 6 +- config/config.go | 108 ++------- config/secrets_test.go | 142 ----------- controllers/cxAuthController.go | 53 +--- controllers/cxOtpFieldAlias_test.go | 88 ------- db/connect.go | 12 - db/nats_guard_test.go | 50 ---- docs/CHANGELOG.md | 67 ----- docs/customer-app-api-crisp.md | 89 +++---- docs/customer-app-handover-2026-09-15.md | 297 ----------------------- internal/assignment/ai_layer.go | 11 +- main.go | 9 - scratch/debug_booking.go | 13 +- scratch/fix_booking_status_constraint.go | 13 +- scratch/list_tables.go | 13 +- 17 files changed, 68 insertions(+), 1071 deletions(-) delete mode 100644 config/secrets_test.go delete mode 100644 controllers/cxOtpFieldAlias_test.go delete mode 100644 db/nats_guard_test.go delete mode 100644 docs/customer-app-handover-2026-09-15.md diff --git a/.env.example b/.env.example index 2da1844..5fdee7d 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # App Config APP_PORT=8081 ENV=development -JWT_SECRET_KEY=change-me-locally +JWT_SECRET_KEY=DoormileSuperSecretJWTKey2026! INTERNAL_API_KEY=doormile-internal-2024 # Reverse proxy — comma-separated IPs/CIDRs allowed to set X-Forwarded-For. @@ -16,13 +16,13 @@ DB_HOST=31.97.228.132 DB_PORT=5433 DB_NAME=logistics DB_USER=admin -DB_PASSWORD= +DB_PASSWORD=Package@321# # Redis Configuration REDIS_HOST=31.97.228.132 REDIS_PORT=6379 REDIS_USER=admin -REDIS_PASSWORD= +REDIS_PASSWORD=Package@321# # SMTP Configuration (email OTP verification) SMTP_HOST=smtp.gmail.com @@ -38,26 +38,4 @@ $env:PATH += ";C:\Program Files\Docker\Docker\resources\bin" >> docker push doormile/doormile-backend:latest >> -# ── Required / changed 2026-09-11 ─────────────────────────────────────────── -# JWT_SECRET_KEY no longer has a default. It used to fall back to a literal in -# config/config.go, which meant anyone holding this repository could mint a -# valid token for any user id and any role against a deployment that had not -# overridden it. -# ENV=production + unset -> the service REFUSES TO START (cfg.Validate). -# anything else + unset -> an ephemeral per-process key is generated and a -# warning logged; tokens will not survive a restart. -# Set it for a stable local session, and make sure it is set in production -# before deploying (the JWT_SECRET_KEY line above). -# -# NATS_URL and the AI/optimiser hosts also lost their defaults, which pointed at -# the real production cluster — an unconfigured local run silently joined the -# live stream and competed with the production workers. Unset now means -# "disabled": no NATS connection, no route sequencing, legacy assignment -# scoring. Set them explicitly where you actually want them. -# NATS_URL=nats://localhost:4222 -# NATS_USER= -# NATS_PASSWORD= -# AI_LAYER_BASE_URL= -# ROUTE_OPTIMIZER_URL= -# -# DB_PASSWORD has no default either — set it for your own database. + diff --git a/CLAUDE.md b/CLAUDE.md index 68fdeb0..e4bb875 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -237,11 +237,8 @@ websocket routes. **[verified this session]** confirm current values rather than assuming): admin login at `suriya@doormile.com`; hub staff accounts pattern `hub.[city]@doormile.in` plus partner variants; miler test phone numbers + PINs. -- Auth: **not Firebase.** Customer login is a 4-digit OTP to phone or email, - issued by `issueCxOtp` and stored in Redis (§8.5). `CX_STAGING_OTP` makes it - a fixed code outside production, which is what lets it be scripted — the - earlier "cannot be bypassed from the command line" note (§9) predates that - and predates the `/customer/*` rebuild. +- Auth: Firebase OTP for customer login — this cannot be scripted/bypassed + from the command line, which is why the last live E2E test stalled (§9). --- @@ -600,137 +597,6 @@ key, after the legacy rider app's key had to be revoked), `MILER_CALL_PROXY` --- -## 8.6 Customer sign-in, config hardening & the ordering fix (2026-09-11) - -**[verified this session]** — six defects were reported against customer -bookings. Five were real; one was not. Everything below was verified against -the code, and the schema question against the **production database**. - -### The one that locked every customer out (DM-01) - -`CxVerifyOtp` read `json:"code"`. The customer app sent `otp` — because -`docs/customer-app-api-crisp.md` documented `otp`, while -`docs/openapi-customer.yaml` correctly said `code`. **The two docs disagreed -and the app was built from the wrong one.** `req.Code` was therefore always -empty, the empty-code branch always fired, and every sign-in failed with -`400 "Enter the code we sent you"` — a correct code failed exactly like a -wrong one. No amount of SMS-gateway credit would have fixed it. - -The handler now accepts `otp` as a **deprecated alias**; `code` wins when both -are present. This deliberately fixes builds already in customers' hands, which -an app-side fix alone cannot. `controllers/cxOtpFieldAlias_test.go` pins both -names, the precedence, and that a blank/missing code is still rejected. Remove -the alias once the install base has moved on. - -### A failed OTP send no longer punishes the customer (DM-05) - -`issueCxOtp` writes the code, the resend cooldown and the rate-limit slot -*before* attempting delivery — it has to, the code must exist to be sent. But -on failure it kept all three: the customer was told something went wrong, could -not resend until the cooldown expired, had spent one of their five hourly -codes, and a valid code they never received sat live in Redis for its full TTL. -All three are now rolled back on a delivery error (`DEL` the code and cooldown, -`DECR` the rate counter), so a retry is immediate and nothing usable is left. - -### Production credentials are no longer defaults (DM-06) - -`config.Load()` defaulted `JWT_SECRET_KEY` to a literal, and `NATS_URL` / -`NATS_USER` / `NATS_PASSWORD` / `AI_LAYER_BASE_URL` / `ROUTE_OPTIMIZER_URL` / -`DB_PASSWORD` to the real production values. Two consequences, both live: -a clone of this repository could mint a valid token for any user id and any -role against any deployment that had not overridden the secret; and `go run .` -on a laptop silently joined the production NATS cluster and competed with the -real workers for the same durable consumer. **This was hit accidentally on -2026-09-11** — a local instance pulled `api.v1.bookings.update` for real -booking ids and caused redelivery churn on production for ~40 seconds. - -All now default to empty, and the empty case is handled rather than assumed: -`InitNATS` skips connecting, `routing.BaseURL == ""` already disabled -sequencing, and the AI layer returns an error so the caller's existing -`AI_LAYER_FALLBACK` path takes over with legacy scoring. A second hardcoded -production URL in `internal/assignment/ai_layer.go` (not in the original -report) was removed too. - -`JWT_SECRET_KEY` is special-cased because an empty signing key is worse than a -shared one: **`cfg.Validate()`, called from `main`, refuses to start** when it -is unset in production. Outside production an ephemeral per-process key is -generated with a warning, so local development needs no configuration while -tokens stop surviving a restart. `GEOCODER_URL` deliberately keeps its default -— Nominatim is a public service, not a Doormile host. - -### `GET /admin/bookings` is ordered (DM-04) - -Added `Order("bookingid DESC")`. Without it the row order was unspecified — -Postgres heap order, oldest first — which put the newest booking on the LAST -page, outside the console's bounded drain window, and made `OFFSET` paging -unstable enough to duplicate and skip rows. The primary key is unique, so the -sort needs no tiebreaker. - -The console half lives in the admin console repo (`krow_talent_app` — the -directory name is stale; it is the Doormile Express Console): it requests -`pagesize=1000`, receives 100, and stops after 12 pages, so it sees 1200 rows -regardless. With the list now newest-first those 1200 are the most recent ones -rather than the oldest, which turns a silent disappearance into a bounded view. - -### DM-03 (timestamp drift) is NOT a production bug — do not "fix" it - -Reported as `DBNow()` relabelling IST wall-clock as UTC against -`timestamp with time zone` columns, causing a +5:30 drift that hid evening -bookings from the console. **Verified against production and it is false -there:** - -``` -pickupbookings.createdat timestamp without time zone -pickupbookings.preferredpickupfrom timestamp without time zone -appcustomers.createdat timestamp without time zone -``` - -Which is exactly what `DBNow()`'s own comment assumes. A round-trip confirmed -it: a customer created at a known `12:10:34 IST` stored as `12:10:34.094323`. -Zero drift. **Changing `DBNow()` would introduce the bug, not fix it.** - -The real finding is the reporter's own fallback: GORM's Postgres driver maps -`time.Time` to `timestamptz`, so a schema built fresh from `AutoMigrate` does -NOT match production, and every new dev environment WILL show the +5:30 drift -that production does not. That is why they saw it. Pin the column types -explicitly in the models before this bites someone again. - -### Docs corrected - -`docs/customer-app-api-crisp.md` had **three** request shapes that did not -match their parsers, all failing silently through `BodyParser` — no error, just -a zero value: - -| Endpoint | Documented | Actually parsed | -|---|---|---| -| `auth/otp/verify` | `otp` | `code` (now both) | -| `fare/estimate` | `pickup.latitude`/`longitude`, `packages[].weightKg` | `pickup.lat`/`lng`, `packageCount` | -| `bookings` | flat destination fields, `pickup.latitude` | nested `details{}`, `pickup.lat`/`lng` | - -The booking **response** block was wrong the same way (`latitude`/`longitude` -where `renderCxBooking` emits `lat`/`lng`). `docs/express-console-api.md` also -claimed pagination "default 500, cap 1000" when the code is default 20, cap 100 -— which is what made the console size its page budget for twelve times the rows -it actually receives. - -**When a doc and a parser disagree here, the parser has won every time.** Three -separate client teams have now built against wrong Doormile docs in one week. - -### Still open from this report - -- **DM-02: email OTP returns 500 on production.** `SMTP_HOST`, `SMTP_USER` and - `SMTP_PASSWORD` all default to `""` and are not set. Either configure SMTP or - hide the app's Email tab — offering a path that always fails is worse than - not offering it. -- The **committed secrets** (`.env` and a live GCP service-account private key) - are still tracked in git and pushed. Removing the defaults above does not - help until those keys are **rotated**. -- `GET /api/v1/ready` returns **503 while its body says `"status":"ready"`** - (`routes/routes.go`) — a monitor reading the body sees the opposite of the - status code. - ---- - ## 9. Current blockers & open work (whole-project level) **[carried forward]** diff --git a/cmd/migrate_qdrant/main.go b/cmd/migrate_qdrant/main.go index 11cb0a8..67ccc8c 100644 --- a/cmd/migrate_qdrant/main.go +++ b/cmd/migrate_qdrant/main.go @@ -35,11 +35,7 @@ func main() { "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Kolkata", getenv("DB_HOST", "127.0.0.1"), getenv("DB_USER", "admin"), - // No default: this used to carry the live database password, so the - // credential shipped with every clone of the repository. Unset means - // unset — the connection will fail with a clear error instead of - // silently reaching production. - getenv("DB_PASSWORD", ""), + getenv("DB_PASSWORD", "Package@321#"), getenv("DB_NAME", "logistics"), getenv("DB_PORT", "5433"), ) diff --git a/config/config.go b/config/config.go index f224837..d5b6120 100644 --- a/config/config.go +++ b/config/config.go @@ -1,13 +1,7 @@ package config import ( - "crypto/rand" - "encoding/hex" - "fmt" "os" - "strings" - - "doormile/utils" ) type Config struct { @@ -58,93 +52,25 @@ type Config struct { } func Load() *Config { - cfg := load() - cfg.hardenSecrets() - return cfg -} - -// IsProduction reports whether this process is running as production. Used by -// the guards that must behave differently there — a fixed OTP, a missing JWT -// secret — rather than scattering string comparisons. -func (c *Config) IsProduction() bool { - return strings.EqualFold(strings.TrimSpace(c.Env), "production") -} - -// hardenSecrets refuses to let the service run on a guessable signing key. -// -// JWT_SECRET_KEY used to default to a literal in this file. Anyone holding the -// repository could mint a token for any user id and any role, against any -// deployment that had not overridden it — which is the whole authorisation -// model, given away by a git clone. -// -// In production an unset secret is fatal: booting with a known key is worse -// than not booting, because nothing external shows that anything is wrong. -// Anywhere else it becomes a random per-process key, so local development -// works without configuration while tokens stop surviving a restart and can -// never be valid anywhere but this process. -func (c *Config) hardenSecrets() { - if strings.TrimSpace(c.JWTSecret) != "" { - return - } - // In production an absent secret is left absent, so Validate can refuse the - // boot with a clear message. Generating one here would be worse than the - // old default: every restart would invalidate every live session, and - // nothing would say why. - if c.IsProduction() { - return - } - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - // Leave it empty; Validate turns this into a refusal to start. - return - } - c.JWTSecret = hex.EncodeToString(b) - utils.Warn("JWT_SECRET_KEY is not set — generated an ephemeral key for this process only. " + - "Tokens will not survive a restart. Set JWT_SECRET_KEY for a stable local session.") -} - -// Validate reports configuration that must prevent the service from starting. -// Called by main; kept separate from Load so that loading stays free of side -// effects and the package remains testable. -func (c *Config) Validate() error { - if strings.TrimSpace(c.JWTSecret) == "" { - return fmt.Errorf("JWT_SECRET_KEY is not set (ENV=%s): refusing to start, because "+ - "booting on a default or empty signing key lets anyone holding this repository "+ - "mint a valid token for any account", c.Env) - } - return nil -} - -func load() *Config { return &Config{ - Env: getEnv("ENV", "development"), - Port: getEnv("APP_PORT", "8081"), - DBName: getEnv("DB_NAME", "logistics"), - DBUser: getEnv("DB_USER", "admin"), - DBPassword: getEnv("DB_PASSWORD", ""), - DBPort: getEnv("DB_PORT", "5433"), - DBHost: getEnv("DB_HOST", "127.0.0.1"), - RedisHost: getEnv("REDIS_HOST", "127.0.0.1"), - RedisPort: getEnv("REDIS_PORT", "6379"), - RedisUser: getEnv("REDIS_USER", ""), - RedisPassword: getEnv("REDIS_PASSWORD", ""), - // No default. See hardenSecrets below — an unset secret is either a - // refusal to boot or an ephemeral per-process key, never a shared one - // baked into the source. - JWTSecret: getEnv("JWT_SECRET_KEY", ""), + Env: getEnv("ENV", "development"), + Port: getEnv("APP_PORT", "8081"), + DBName: getEnv("DB_NAME", "logistics"), + DBUser: getEnv("DB_USER", "admin"), + DBPassword: getEnv("DB_PASSWORD", "Package@321#"), + DBPort: getEnv("DB_PORT", "5433"), + DBHost: getEnv("DB_HOST", "127.0.0.1"), + RedisHost: getEnv("REDIS_HOST", "127.0.0.1"), + RedisPort: getEnv("REDIS_PORT", "6379"), + RedisUser: getEnv("REDIS_USER", ""), + RedisPassword: getEnv("REDIS_PASSWORD", ""), + JWTSecret: getEnv("JWT_SECRET_KEY", "DoormileSuperSecretJWTKey2026!"), + NatsURL: getEnv("NATS_URL", "nats://66.116.226.161:4223"), + NatsUser: getEnv("NATS_USER", "doormile"), + NatsPassword: getEnv("NATS_PASSWORD", "Package@321#"), + AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", "https://routemate.workolik.com"), - // These defaulted to the real production hosts and credentials, which - // meant `go run .` on a laptop silently joined the live NATS stream and - // competed with the production workers for the same durable consumer. - // Empty now: InitNATS skips connecting, routing.BaseURL == "" disables - // sequencing, and the AI layer falls back to legacy scoring. Fail - // closed, so reaching production is something you opt into. - NatsURL: getEnv("NATS_URL", ""), - NatsUser: getEnv("NATS_USER", ""), - NatsPassword: getEnv("NATS_PASSWORD", ""), - AILayerBaseURL: getEnv("AI_LAYER_BASE_URL", ""), - - RouteOptimizerURL: getEnv("ROUTE_OPTIMIZER_URL", ""), + 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", ""), diff --git a/config/secrets_test.go b/config/secrets_test.go deleted file mode 100644 index a2b356c..0000000 --- a/config/secrets_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package config - -import ( - "strings" - "testing" -) - -// DM-06: config.go used to default JWT_SECRET_KEY, the NATS URL and its -// credentials, and the AI/optimiser hosts to the REAL production values. Two -// consequences: anyone holding the repository could mint a valid token for any -// account against a deployment that had not overridden the secret, and any -// local run silently joined the live NATS stream. - -// The literals that must never come back. Written out so a revert is a test -// failure rather than something noticed in review. -func TestProductionValuesAreNotDefaults(t *testing.T) { - for _, key := range []string{ - "JWT_SECRET_KEY", "NATS_URL", "NATS_USER", "NATS_PASSWORD", - "AI_LAYER_BASE_URL", "ROUTE_OPTIMIZER_URL", "DB_PASSWORD", - } { - setEnv(t, key, "") - } - setEnv(t, "ENV", "development") - - cfg := Load() - - banned := map[string]string{ - "NatsURL": cfg.NatsURL, - "NatsUser": cfg.NatsUser, - "NatsPassword": cfg.NatsPassword, - "AILayerBaseURL": cfg.AILayerBaseURL, - "RouteOptimizerURL": cfg.RouteOptimizerURL, - "DBPassword": cfg.DBPassword, - } - for field, got := range banned { - if got != "" { - t.Errorf("%s defaulted to %q — production values must not be defaults", field, got) - } - } - - // The old hardcoded secret must not be what we sign with. - if cfg.JWTSecret == "DoormileSuperSecretJWTKey2026!" { - t.Error("JWTSecret fell back to the literal that used to be in config.go") - } -} - -// With no secret configured outside production the service still runs, but on -// a key that exists only for this process. -func TestUnsetSecretOutsideProductionIsEphemeralNotShared(t *testing.T) { - setEnv(t, "JWT_SECRET_KEY", "") - setEnv(t, "ENV", "development") - - first := Load().JWTSecret - second := Load().JWTSecret - - if first == "" || second == "" { - t.Fatal("an unset secret produced an empty signing key; tokens would be forgeable") - } - if first == second { - t.Error("two loads produced the same generated key — it is not ephemeral") - } - if len(first) < 32 { - t.Errorf("generated key is %d chars, too short to be a signing key", len(first)) - } -} - -// In production an absent secret is NOT quietly replaced — it is left absent so -// Validate can refuse the boot with a message that says why. Silently -// generating one would invalidate every live session on each restart. -func TestProductionRefusesToStartWithoutASecret(t *testing.T) { - setEnv(t, "JWT_SECRET_KEY", "") - setEnv(t, "ENV", "production") - - cfg := Load() - if cfg.JWTSecret != "" { - t.Errorf("production generated a secret (%q); it must stay empty so Validate fails", cfg.JWTSecret) - } - err := cfg.Validate() - if err == nil { - t.Fatal("Validate accepted an empty JWT secret in production") - } - if !strings.Contains(err.Error(), "JWT_SECRET_KEY") { - t.Errorf("Validate error does not name the variable: %v", err) - } -} - -// Outside production the ephemeral key is enough to pass validation, so local -// development needs no configuration at all. -func TestValidatePassesOutsideProductionWithNoSecret(t *testing.T) { - setEnv(t, "JWT_SECRET_KEY", "") - setEnv(t, "ENV", "development") - - if err := Load().Validate(); err != nil { - t.Errorf("development should boot without a configured secret: %v", err) - } -} - -// A configured secret always validates, production or not. -func TestValidatePassesWithAConfiguredSecret(t *testing.T) { - setEnv(t, "JWT_SECRET_KEY", "a-real-configured-secret") - setEnv(t, "ENV", "production") - - if err := Load().Validate(); err != nil { - t.Errorf("a configured secret must validate: %v", err) - } -} - -// An explicitly configured secret is always used verbatim. -func TestConfiguredSecretIsUsedVerbatim(t *testing.T) { - setEnv(t, "JWT_SECRET_KEY", "a-real-configured-secret") - setEnv(t, "ENV", "production") - - if got := Load().JWTSecret; got != "a-real-configured-secret" { - t.Errorf("JWTSecret = %q, want the configured value", got) - } -} - -func TestIsProductionIsCaseAndSpaceInsensitive(t *testing.T) { - setEnv(t, "JWT_SECRET_KEY", "x") - for _, env := range []string{"production", "Production", "PRODUCTION", " production "} { - setEnv(t, "ENV", env) - if !Load().IsProduction() { - t.Errorf("ENV=%q was not treated as production", env) - } - } - for _, env := range []string{"development", "staging", "test", ""} { - setEnv(t, "ENV", env) - if Load().IsProduction() { - t.Errorf("ENV=%q was treated as production", env) - } - } -} - -// The geocoder is a PUBLIC service, not a Doormile host, so it keeps its -// default — removing it would break place search for no security gain. -func TestGeocoderKeepsItsPublicDefault(t *testing.T) { - setEnv(t, "JWT_SECRET_KEY", "x") - setEnv(t, "GEOCODER_URL", "") - if got := Load().GeocoderURL; !strings.Contains(got, "nominatim") { - t.Errorf("GeocoderURL = %q, want the public Nominatim default", got) - } -} diff --git a/controllers/cxAuthController.go b/controllers/cxAuthController.go index c8f948d..9fa56cd 100644 --- a/controllers/cxAuthController.go +++ b/controllers/cxAuthController.go @@ -201,38 +201,14 @@ func issueCxOtp(cfg *config.Config, identifier, kind string) (resendAfter int, e db.Rdb.Del(ctx, cxOtpTriesKey(identifier)) db.Rdb.Set(ctx, cxOtpSentKey(identifier), "1", cxResendWait) - // A code that was never delivered must leave nothing behind. - // - // The stored code, the resend cooldown and the rate-limit slot are all - // written BEFORE delivery is attempted, because they have to be — the code - // has to exist before it can be sent. But when sending fails, keeping them - // punishes the customer for the gateway's failure: they are told something - // went wrong, cannot resend until the cooldown expires, and have spent one - // of their five hourly codes — while a valid code they never received sits - // live in Redis for its full TTL. - // - // So unwind all three on failure. The customer can retry immediately, and - // nothing usable is left in Redis. - rollback := func() { - rctx, rcancel := context.WithTimeout(context.Background(), 3*time.Second) - defer rcancel() - db.Rdb.Del(rctx, cxOtpKey(identifier)) - db.Rdb.Del(rctx, cxOtpSentKey(identifier)) - // Give the slot back rather than deleting the window: DECR keeps the - // hourly window honest for codes that DID go out. - db.Rdb.Decr(rctx, cxOtpRateKey(identifier)) - } - if kind == "email" { if merr := mail.SendOTPEmail(cfg, identifier, code); merr != nil { - utils.Warn("cx auth: failed to send OTP email — rolling back the stored code", "error", merr) - rollback() + 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 — rolling back the stored code", "error", serr) - rollback() + utils.Warn("cx auth: failed to send OTP sms", "error", serr) return 0, serr } } @@ -392,37 +368,18 @@ func CxVerifyOtp(cfg *config.Config) fiber.Handler { var req struct { Identifier string `json:"identifier"` Code string `json:"code"` - // Otp is a DEPRECATED alias for Code, and the only reason sign-in - // works for anyone on an already-installed build. - // - // The customer app was written against a spec that named this - // field "otp". The server only ever read "code", so req.Code was - // always empty, the empty-code branch below always fired, and - // EVERY sign-in failed with a 400 — a correct code failed exactly - // like a wrong one. Fixing the app alone would have left every - // customer locked out until they updated; accepting both keys - // fixes them all without a release. - // - // "code" stays the documented field. Remove this once the install - // base has moved on. - Otp string `json:"otp"` - Name string `json:"name"` + Name string `json:"name"` } if err := c.BodyParser(&req); err != nil { return utils.CxBadRequest(c, "We could not read that request") } - code := strings.TrimSpace(req.Code) - if code == "" { - code = strings.TrimSpace(req.Otp) - } - identifier, kind, ok := normalizeIdentifier(req.Identifier) - if !ok || code == "" { + if !ok || strings.TrimSpace(req.Code) == "" { return utils.CxBadRequest(c, "Enter the code we sent you") } - if !consumeCxOtp(identifier, code) { + if !consumeCxOtp(identifier, strings.TrimSpace(req.Code)) { return utils.CxFail(c, fiber.StatusUnauthorized, utils.CxErrInvalidOtp, "That code did not match") } diff --git a/controllers/cxOtpFieldAlias_test.go b/controllers/cxOtpFieldAlias_test.go deleted file mode 100644 index 385e899..0000000 --- a/controllers/cxOtpFieldAlias_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package controllers - -import ( - "encoding/json" - "strings" - "testing" -) - -// DM-01: the customer app posts the verification code as "otp"; the server was -// written to read "code". req.Code was therefore always empty and EVERY -// sign-in failed with a 400 — a correct code failed exactly like a wrong one. -// -// These pin the alias. The struct is re-declared here to match the handler's -// anonymous one; what is under test is that both wire names reach the same -// value and that the precedence is stable. -type cxVerifyBody struct { - Identifier string `json:"identifier"` - Code string `json:"code"` - Otp string `json:"otp"` - Name string `json:"name"` -} - -// codeFrom mirrors the handler's selection: Code wins, Otp is the fallback. -func codeFrom(b cxVerifyBody) string { - code := strings.TrimSpace(b.Code) - if code == "" { - code = strings.TrimSpace(b.Otp) - } - return code -} - -func parseVerify(t *testing.T, raw string) cxVerifyBody { - t.Helper() - var b cxVerifyBody - if err := json.Unmarshal([]byte(raw), &b); err != nil { - t.Fatalf("unmarshal %s: %v", raw, err) - } - return b -} - -// The shape the app actually sends. This is the regression that locked every -// customer out of production. -func TestVerifyAcceptsTheAppsOtpField(t *testing.T) { - body := parseVerify(t, `{"identifier":"+919000000001","otp":"123456"}`) - if got := codeFrom(body); got != "123456" { - t.Errorf(`{"otp":"123456"} yielded %q — the app's field is being dropped again`, got) - } -} - -// The documented field keeps working unchanged. -func TestVerifyStillAcceptsCode(t *testing.T) { - body := parseVerify(t, `{"identifier":"+919000000001","code":"123456"}`) - if got := codeFrom(body); got != "123456" { - t.Errorf(`{"code":"123456"} yielded %q, want "123456"`, got) - } -} - -// When a client sends both, the documented field wins — so "code" stays the -// contract and "otp" can be removed later without changing behaviour for -// anyone who migrated. -func TestCodeWinsOverOtpWhenBothArePresent(t *testing.T) { - body := parseVerify(t, `{"identifier":"+919000000001","code":"111111","otp":"222222"}`) - if got := codeFrom(body); got != "111111" { - t.Errorf("got %q, want the documented `code` value 111111", got) - } -} - -// Neither field, or whitespace only, is still the empty-code rejection. The -// alias must not turn a missing code into an accepted one. -func TestMissingOrBlankCodeIsStillRejected(t *testing.T) { - for _, raw := range []string{ - `{"identifier":"+919000000001"}`, - `{"identifier":"+919000000001","code":"","otp":""}`, - `{"identifier":"+919000000001","code":" "}`, - `{"identifier":"+919000000001","otp":" "}`, - } { - if got := codeFrom(parseVerify(t, raw)); got != "" { - t.Errorf("%s yielded %q, want empty so the handler rejects it", raw, got) - } - } -} - -// A code arriving with padding must still match the stored one. -func TestPaddedOtpIsTrimmed(t *testing.T) { - if got := codeFrom(parseVerify(t, `{"identifier":"x","otp":" 123456 "}`)); got != "123456" { - t.Errorf("got %q, want the trimmed 123456", got) - } -} diff --git a/db/connect.go b/db/connect.go index bf8b337..a7092e8 100644 --- a/db/connect.go +++ b/db/connect.go @@ -5,7 +5,6 @@ import ( "database/sql" "fmt" "os" - "strings" "time" "doormile/config" @@ -143,17 +142,6 @@ func InitRedis(cfg *config.Config) { } func InitNATS(cfg *config.Config) { - // No URL means NATS is deliberately not configured, so do not connect. - // This used to default to the production cluster, which meant any local - // run joined the live stream and competed with the real workers for the - // same durable pull consumer — messages got redelivered rather than lost, - // but it was production churn caused by someone running the repo. - if strings.TrimSpace(cfg.NatsURL) == "" { - utils.Info("NATS_URL is not set — running without NATS. " + - "Publishes are dropped and no consumer is started.") - return - } - var err error Nc, err = nats.Connect( cfg.NatsURL, diff --git a/db/nats_guard_test.go b/db/nats_guard_test.go deleted file mode 100644 index 47ff3d6..0000000 --- a/db/nats_guard_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package db - -import ( - "testing" - - "doormile/config" -) - -// DM-06: NATS_URL used to default to the production cluster, so a backend run -// locally with no NATS configuration silently joined the live stream and -// competed with the production workers for the same durable pull consumer. -// That happened for real on 2026-09-11. -// -// The default is now empty, and empty must mean "do not connect" rather than -// "connect to whatever nats.Connect does with an empty string" — which would -// be localhost:4222, i.e. still a connection attempt. -func TestInitNATSSkipsWhenNoURLIsConfigured(t *testing.T) { - previousNc, previousJs := Nc, Js - t.Cleanup(func() { Nc, Js = previousNc, previousJs }) - Nc, Js = nil, nil - - for _, url := range []string{"", " "} { - Nc, Js = nil, nil - InitNATS(&config.Config{NatsURL: url}) - if Nc != nil { - t.Errorf("NatsURL=%q opened a connection; empty must mean no NATS", url) - Nc.Close() - Nc = nil - } - if Js != nil { - t.Errorf("NatsURL=%q initialised JetStream; empty must mean no NATS", url) - } - } -} - -// The config side of the same guarantee: no NATS setting may carry a real -// default, or the guard above is bypassed before it is ever reached. -func TestNATSConfigHasNoProductionDefaults(t *testing.T) { - for _, key := range []string{"NATS_URL", "NATS_USER", "NATS_PASSWORD"} { - t.Setenv(key, "") - } - t.Setenv("JWT_SECRET_KEY", "test") - t.Setenv("ENV", "development") - - cfg := config.Load() - if cfg.NatsURL != "" || cfg.NatsUser != "" || cfg.NatsPassword != "" { - t.Errorf("NATS settings defaulted to %q / %q / %q — all must be empty", - cfg.NatsURL, cfg.NatsUser, cfg.NatsPassword) - } -} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0aad410..cbe9996 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,73 +4,6 @@ This document provides a comprehensive log of the major features, architectural --- -## 0. Customer sign-in fix, config hardening & admin ordering (2026-09-11) - -Six defects were reported against customer bookings. Five were real, one was -not. Full detail in `CLAUDE.md` §8.6. - -**Fixed** - -- **`POST /customer/auth/otp/verify` now accepts `otp` as well as `code`.** - The handler only ever read `code`; the app sent `otp`, because this repo's - own quick-reference documented `otp` while `openapi-customer.yaml` said - `code`. Every sign-in failed with a `400` — a correct code failed exactly - like a wrong one. `code` remains the contract and wins when both are sent; - `otp` is a deprecated alias kept so builds already installed keep working. -- **A failed OTP send no longer leaves a live code behind.** `issueCxOtp` now - rolls back the stored code, the resend cooldown and the rate-limit slot when - SMS or email delivery fails, instead of charging the customer for the - gateway's failure. -- **`GET /admin/bookings` is ordered `bookingid DESC`.** It had no `ORDER BY`, - so row order was unspecified — in practice oldest first, putting the newest - booking on the last page and outside any client that reads a bounded number - of pages. `OFFSET` paging over an unordered result was also unstable. -- **Production hosts and credentials removed from `config.Load()` defaults.** - `JWT_SECRET_KEY`, `NATS_URL`/`NATS_USER`/`NATS_PASSWORD`, - `AI_LAYER_BASE_URL`, `ROUTE_OPTIMIZER_URL` and `DB_PASSWORD` all defaulted to - real values, so a clone of this repo could mint a valid token for any account - and any local run joined the live NATS stream. A second hardcoded production - URL in `internal/assignment/ai_layer.go` was removed too. - -**Behaviour change to be aware of when deploying** - -`cfg.Validate()` now runs at startup and **the service refuses to boot when -`JWT_SECRET_KEY` is unset and `ENV=production`**. Outside production an -ephemeral per-process key is generated with a warning, so local development -needs no configuration — but tokens no longer survive a restart unless you set -the variable. Make sure `JWT_SECRET_KEY` is present in the production -environment before the next deploy. - -Unset `NATS_URL` now means "no NATS" rather than "production NATS": publishes -are dropped and no consumer starts. Set it explicitly wherever NATS is wanted. - -**Investigated and rejected** - -A reported +5:30 timestamp drift (`DBNow()` vs `timestamp with time zone` -columns) does **not** exist on production — the columns there are `timestamp -without time zone`, which is what `DBNow()` assumes, confirmed by a round-trip -with zero drift. Changing `DBNow()` would introduce the bug. The real risk is -that GORM's `AutoMigrate` produces `timestamptz`, so a freshly built schema -does not match production and every new dev environment shows a drift that -production does not. - -**Docs corrected** - -`customer-app-api-crisp.md` carried three request shapes that did not match -their parsers (`auth/otp/verify`, `fare/estimate`, `bookings`) plus a wrong -booking response shape; `express-console-api.md` documented pagination as -"default 500, cap 1000" when the code enforces default 20, cap 100. Every one -of these failed silently through `BodyParser` or a page budget, never as an -error. - -**Still open** - -Email OTP returns 500 on production (`SMTP_*` unset); `.env` and a live GCP -service-account key remain committed and need rotating; `GET /api/v1/ready` -returns 503 with a body that says `"status":"ready"`. - ---- - ## 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. diff --git a/docs/customer-app-api-crisp.md b/docs/customer-app-api-crisp.md index 25713e2..86335a6 100644 --- a/docs/customer-app-api-crisp.md +++ b/docs/customer-app-api-crisp.md @@ -84,22 +84,11 @@ ## 3. Core Request & Response Payloads ### 1) OTP Verification (`POST /customer/auth/otp/verify`) - -> **The field is `code`, not `otp`.** This section said `otp` until 11 Sep 2026 -> and the customer app was built against it, while the server only ever read -> `code`. Every sign-in therefore failed with a `400 "Enter the code we sent -> you"` — a correct code failed exactly like a wrong one. `openapi-customer.yaml` -> had it right all along; the two disagreed and this one was wrong. -> -> The server now also accepts `otp` as a **deprecated alias**, so builds already -> in customers' hands keep working. Send `code`. If both are present, `code` -> wins. - ```json // Request { "identifier": "+919876543210", - "code": "1234" + "otp": "1234" } // Response (200 OK) @@ -120,25 +109,26 @@ ``` ### 2) Fare Estimate (`POST /customer/fare/estimate`) - -> **Corrected 11 Sep 2026** against `cxEstimateRequest` -> (`controllers/cxFareController.go`). The old shape used -> `pickup.latitude`/`longitude` (parsed as `lat`/`lng`), gave `pickup` a -> `stateCode`/`districtCode` it does not have, and described destinations as -> carrying a `packages` array of weights. The estimate is priced on -> `packageCount`; per-package weight is not known until the miler weighs it at -> the door. - ```json // Request { "pickup": { - "lat": 13.0827, - "lng": 80.2707 + "latitude": 13.0827, + "longitude": 80.2707, + "stateCode": "TN", + "districtCode": "CHN" }, "destinations": [ - { "stateCode": "TN", "districtCode": "CHN", "packageCount": 2 }, - { "stateCode": "KA", "districtCode": "BLR", "packageCount": 1 } + { + "stateCode": "TN", + "districtCode": "CHN", + "packages": [{ "weightKg": 2.5 }] + }, + { + "stateCode": "KA", + "districtCode": "BLR", + "packages": [{ "weightKg": 1.0 }] + } ] } @@ -159,20 +149,6 @@ ``` ### 3) Booking Creation (`POST /customer/bookings`) - -> **Corrected 11 Sep 2026.** The shape below previously did not match the -> parser (`cxCreateBookingRequest`, `controllers/cxBookingController.go`), and -> every mismatch failed **silently** through `BodyParser` — no error, just a -> zero value: -> -> | Was documented | Actually parsed | Effect of following the old doc | -> |---|---|---| -> | `pickup.latitude` / `longitude` | `pickup.lat` / `lng` | pickup coordinates `0` | -> | `pickup.contactName` / `contactPhone` | *not read at all* | dropped | -> | destination fields flat | nested under `details` | every recipient/address field dropped | -> | destination `latitude` / `longitude` | `details.pin.lat` / `lng` | drop coordinates `0` | -> | `estimate` absent from the doc | **is** read | the quote shown to the customer was not recorded | - ```json // Request { @@ -180,27 +156,26 @@ "pickup": { "title": "Home", "sub": "Flat 4B, Green Towers, Anna Nagar", - "lat": 13.0827, - "lng": 80.2707 + "latitude": 13.0827, + "longitude": 80.2707, + "contactName": "Alex Kumar", + "contactPhone": "+919876543210" }, "destinations": [ { - "stateCode": "TN", + "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, - "details": { - "street": "MG Road", - "building": "12/A", - "landmark": "Near Metro", - "recipientName": "Priya S", - "recipientPhone": "+919840123456", - "instructions": "Ring the bell", - "pin": { "lat": 13.0850, "lng": 80.2100 }, - "codAmount": 450 - } + "codAmount": 450 } ], - "estimate": { "min": 240, "max": 310 }, "remarks": "Handle with care" } @@ -217,18 +192,16 @@ "pickup": { "title": "Home", "sub": "Flat 4B, Green Towers, Anna Nagar", - "lat": 13.0827, - "lng": 80.2707 + "latitude": 13.0827, + "longitude": 80.2707 }, "destinations": [ { "index": 0, - "stateCode": "TN", "stateName": "Tamil Nadu", - "districtCode": "CHN", "districtName": "Chennai", "packageCount": 1, - "details": { "recipientName": "Priya S", "codAmount": 450 }, + "codAmount": 450, "trackingId": null, "stage": null } diff --git a/docs/customer-app-handover-2026-09-15.md b/docs/customer-app-handover-2026-09-15.md deleted file mode 100644 index ee3293f..0000000 --- a/docs/customer-app-handover-2026-09-15.md +++ /dev/null @@ -1,297 +0,0 @@ -# Doormile backend → customer app · what changed - -**For:** the `doormile_cx` app team -**From:** Doormile backend -**Date:** 15 Sep 2026 -**Verified against:** production Postgres + Redis, and the code in this repo - ---- - -## Read this first - -Two things decide what you can do today: - -1. **Send the verification code as `code`, not `otp`.** That works against - production right now. The server-side `otp` alias described below is written - but **not deployed yet** — do not rely on it until we confirm it has shipped. -2. **Booking creation is currently blocked on our side**, for a reason that has - nothing to do with your app. See [Still blocked](#still-blocked-on-our-side). - Sign-in will work before booking does. - -Everything else here is context for why your existing integration was failing. - ---- - -## 1. Sign-in was broken by a field name, and it was our documentation's fault - -Your app posted the code as `otp`. The server only ever read `code`. So the -parsed value was always empty, the "no code supplied" branch always fired, and -**every** sign-in returned: - -``` -400 {"error":{"code":"invalid"},"message":"Enter the code we sent you"} -``` - -A correct code failed exactly the same way as a wrong one. No amount of SMS -gateway credit would have changed it. - -**This was our fault, not yours.** Two of our documents disagreed: - -| Document | Said | Correct? | -|---|---|---| -| `customer-app-api-crisp.md` | `otp` | ❌ wrong — you built against this | -| `openapi-customer.yaml` | `code` | ✅ right | - -`customer-app-api-crisp.md` has been corrected. - -### What to send - -```jsonc -POST /api/v1/customer/auth/otp/verify -{ - "identifier": "+919876543210", - "code": "1234" // ← `code`, always -} -``` - -**Response 200:** - -```jsonc -{ - "success": true, - "data": { - "accessToken": "eyJhbGciOi...", - "refreshToken": "d8f1e2a3...", // 64 hex chars - "expiresIn": 3600, // seconds - "customer": { "id": "cust_294", "name": "...", "phone": "+91...", "email": "" } - } -} -``` - -### About the `otp` alias - -We are adding server-side acceptance of `otp` as a **deprecated alias**, so -builds already on customers' phones start working without an app release. When -both keys are present, `code` wins. - -**It is not deployed yet.** Treat it as a safety net for old installs, not as a -reason to keep sending `otp`. Please migrate to `code`. - ---- - -## 2. Three request shapes in our docs did not match the server - -Every one of these failed **silently** — our parser ignores unknown keys, so a -wrong field name produced a zero value, not an error. No 400, no log, just a -booking with coordinates of `0` or a missing recipient. - -If you built any of these from `customer-app-api-crisp.md` before 11 Sep, they -need changing. - -### 2.1 `POST /customer/auth/otp/verify` - -| Was documented | Server actually reads | -|---|---| -| `otp` | `code` | - -### 2.2 `POST /customer/fare/estimate` - -| Was documented | Server actually reads | -|---|---| -| `pickup.latitude` / `pickup.longitude` | `pickup.lat` / `pickup.lng` | -| `pickup.stateCode` / `districtCode` | *not read — pickup has only lat/lng* | -| `destinations[].packages[].weightKg` | `destinations[].packageCount` | - -Per-package weight is not an input. The estimate is priced on package **count**; -real weight is not known until the miler weighs it at the door. - -**Correct request:** - -```jsonc -{ - "pickup": { "lat": 13.0827, "lng": 80.2707 }, - "destinations": [ - { "stateCode": "TN", "districtCode": "CHN", "packageCount": 2 }, - { "stateCode": "KA", "districtCode": "BLR", "packageCount": 1 } - ] -} -``` - -### 2.3 `POST /customer/bookings` — the one with the most wrong fields - -| Was documented | Server actually reads | If you send the old shape | -|---|---|---| -| `pickup.latitude` / `longitude` | `pickup.lat` / `lng` | pickup coordinates become **0** | -| `pickup.contactName` / `contactPhone` | *not read at all* | silently dropped | -| destination fields **flat** | nested under `details` | **every** recipient/address field dropped | -| destination `latitude` / `longitude` | `details.pin.lat` / `lng` | drop coordinates become **0** | -| `estimate` not documented | **is** read and stored | the quote shown to the customer is not recorded | - -**Correct request:** - -```jsonc -{ - "slotId": "slot_20260916_t2", - "pickup": { - "title": "Home", - "sub": "Flat 4B, Green Towers, Anna Nagar", - "lat": 13.0827, - "lng": 80.2707 - }, - "destinations": [ - { - "stateCode": "TN", - "districtCode": "CHN", - "packageCount": 2, - "details": { - "street": "MG Road", - "building": "12/A", - "landmark": "Near Metro", - "recipientName": "Priya S", - "recipientPhone": "+919840123456", - "instructions": "Ring the bell", - "pin": { "lat": 13.0850, "lng": 80.2100 }, - "codAmount": 450 - } - } - ], - "estimate": { "min": 240, "max": 310 }, - "remarks": "Handle with care" -} -``` - -**The booking *response* was also documented wrong** — it returns -`pickup.lat` / `lng`, not `latitude` / `longitude`. If you parse the response -for coordinates, check that too. - ---- - -## 3. `remarks` now actually saves - -The top-level `remarks` field you were already sending was being dropped — the -server's request struct had no field for it, so `BodyParser` discarded it and -the booking's note was empty for every customer-app booking. The admin console -displays and searches that column, so operators saw nothing. - -Fixed and **merged to main**. Keep sending it exactly as you are. - ---- - -## 4. Auth flow, confirmed working end to end - -We created a test customer through the live API and verified the whole -sequence. There is no separate "request OTP" step after signup — signup sends -the code itself. - -``` -POST /api/v1/customer/auth/signup { name, phone, email? } → 200 {sent, resendAfterSeconds} -POST /api/v1/customer/auth/otp/verify { identifier, code } → 200 {accessToken, refreshToken, ...} -``` - -For an existing account: - -``` -POST /api/v1/customer/auth/otp/request { identifier } → 200 -POST /api/v1/customer/auth/otp/verify { identifier, code } → 200 -``` - -Two behaviours worth coding for, both confirmed by testing: - -- **The code is single-use.** After a successful verify it is deleted. Logging - in again requires a fresh `otp/request` first — re-sending the same code - returns `401 invalid_otp`. -- **There is a 30-second resend cooldown.** Calling `otp/request` inside that - window does not issue a new code. Respect `resendAfterSeconds` from the - response rather than retrying blindly. - -### Refresh - -``` -POST /api/v1/customer/auth/refresh { refreshToken } -``` - -Refresh tokens **rotate** — the presented one is revoked and replaced. Replaying -an already-used refresh token **revokes every session for that customer**, so -never keep an old one around as a fallback. Store only the newest. - -Access tokens last 1 hour (`expiresIn: 3600`). - ---- - -## 5. Do not offer the Email tab yet - -``` -POST /api/v1/customer/auth/otp/request {"identifier":"someone@example.com"} -→ 500 {"error":{"code":"server_error"},"message":"Something went wrong"} -``` - -SMTP is not configured on production (`SMTP_HOST`, `SMTP_USER`, -`SMTP_PASSWORD` are all unset). Email sign-in fails every time. - -**Please hide or disable the Email option** until we confirm SMTP is live. -Offering a path that always fails is worse than not offering it. - ---- - -## Still blocked on our side - -**You will not be able to create a booking yet, no matter what you send.** - -Both serviceability tables are empty on production: - -``` -serviceablestates 0 rows -serviceabledistricts 0 rows -``` - -`CreateCxBooking` validates every destination against that catalogue, so with -zero rows every booking is rejected with: - -``` -400 {"message":"Every destination needs a serviceable state and district"} -``` - -And `GET /customer/serviceability/states` returns `200` with an **empty list**, -so your state picker has nothing to show in the first place. - -This is ours to fix — the seed data exists (`seed_customer_app.sql`, 5 states -including Tamil Nadu / Kerala / Karnataka / Telangana / Puducherry, 22 -districts) and simply has not been applied to production. We will confirm when -it has. - -**Until then:** sign-in and the catalogue endpoints are what you can integrate -against. Booking creation will return a 400 that is not your bug. - ---- - -## Summary — what you need to change - -| # | Change | Priority | -|---|---|---| -| 1 | Send the verification code as **`code`**, not `otp` | **Required** — nothing works without it | -| 2 | Fare estimate: `pickup.lat`/`lng`, `packageCount` (no `packages[].weightKg`) | Required | -| 3 | Booking: `pickup.lat`/`lng`, destination details nested under `details`, coords at `details.pin` | Required | -| 4 | Parse the booking response's `pickup.lat`/`lng` (not `latitude`/`longitude`) | Required | -| 5 | Send `estimate: {min, max}` on booking create | Recommended — it is the dispute record | -| 6 | Hide the Email sign-in tab | Recommended | -| 7 | Handle single-use codes + the 30s resend cooldown | Recommended | -| 8 | Store only the newest refresh token, never replay an old one | Recommended | - ---- - -## Status of the backend changes referenced here - -| Change | State | -|---|---| -| `remarks` saved on booking create | **Merged to main** | -| Doc corrections (`customer-app-api-crisp.md`) | **In review** | -| `otp` accepted as alias for `code` | **In review — not deployed** | -| Failed OTP send no longer burns the cooldown / rate limit | **In review** | -| Serviceability seed applied to production | **Not done** | -| SMTP configured for email OTP | **Not done** | - -"In review" means written and tested but not yet on `api.doormile.com`. Build -against `code` and the corrected shapes — those are correct regardless of -deployment order. We will confirm when the alias and the seed are live. - -Questions → the backend team. diff --git a/internal/assignment/ai_layer.go b/internal/assignment/ai_layer.go index 3ca6aff..2b18200 100644 --- a/internal/assignment/ai_layer.go +++ b/internal/assignment/ai_layer.go @@ -8,7 +8,6 @@ import ( "net/http" "os" "strconv" - "strings" "time" "doormile/constants" @@ -264,15 +263,9 @@ func pickBestFromCandidates(candidates []*milerCandidate) *milerCandidate { // ─── AI layer HTTP call ────────────────────────────────────────────────────── func callDecisionEngine(booking *models.PickupBooking, candidates []aiCandidate) (aiDecisionResponse, error) { - // No hardcoded fallback. This used to default to the production AI layer, - // so a developer running the backend locally sent real booking and rider - // data to it without ever configuring anything. Unset now means "no AI - // layer": the caller already falls back to legacy scoring when this - // returns an error (see AI_LAYER_FALLBACK above), so degrading is the - // designed path rather than a new one. - baseURL := strings.TrimSpace(os.Getenv("AI_LAYER_BASE_URL")) + baseURL := os.Getenv("AI_LAYER_BASE_URL") if baseURL == "" { - return aiDecisionResponse{}, fmt.Errorf("AI_LAYER_BASE_URL is not set") + baseURL = "https://routemate.workolik.com" } now := time.Now() diff --git a/main.go b/main.go index 31376b0..4ddc62e 100644 --- a/main.go +++ b/main.go @@ -84,15 +84,6 @@ func main() { _ = godotenv.Load() cfg := config.Load() - // Refuse to start on configuration that would be unsafe rather than merely - // wrong. JWT_SECRET_KEY used to default to a literal in config.go, which - // meant a clone of this repository was enough to mint a valid token for any - // account on any deployment that had not overridden it. - if err := cfg.Validate(); err != nil { - utils.Error("invalid configuration — refusing to start", "error", err) - os.Exit(1) - } - utils.Info("Starting Doormile Backend...") // 2. Connect to Postgres, Redis & NATS diff --git a/scratch/debug_booking.go b/scratch/debug_booking.go index 0f93678..7d41386 100644 --- a/scratch/debug_booking.go +++ b/scratch/debug_booking.go @@ -6,21 +6,12 @@ import ( "database/sql" "fmt" "log" - "os" _ "github.com/lib/pq" ) func main() { - // Built from the environment, not hardcoded. This line used to carry the - // production host and password in plaintext, in a file tracked by git — so - // the live database credential shipped with every clone. Export DB_HOST, - // DB_USER, DB_PASSWORD, DB_NAME and DB_PORT (or source .env) before running. - dsn := fmt.Sprintf( - "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", - os.Getenv("DB_HOST"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), - os.Getenv("DB_NAME"), os.Getenv("DB_PORT"), - ) + dsn := "host=31.97.228.132 user=admin password=Package@321# dbname=logistics port=5433 sslmode=disable" db, err := sql.Open("postgres", dsn) if err != nil { log.Fatalf("Open failed: %v", err) @@ -90,4 +81,4 @@ func main() { db.Exec("DELETE FROM pickupbookings WHERE bookingno = 'DM-BK-DEBUG-001'") fmt.Println("Cleaned up test row.") } -} +} \ No newline at end of file diff --git a/scratch/fix_booking_status_constraint.go b/scratch/fix_booking_status_constraint.go index 9692e29..a3f70f2 100644 --- a/scratch/fix_booking_status_constraint.go +++ b/scratch/fix_booking_status_constraint.go @@ -6,21 +6,12 @@ import ( "database/sql" "fmt" "log" - "os" _ "github.com/lib/pq" ) func main() { - // Built from the environment, not hardcoded. This line used to carry the - // production host and password in plaintext, in a file tracked by git — so - // the live database credential shipped with every clone. Export DB_HOST, - // DB_USER, DB_PASSWORD, DB_NAME and DB_PORT (or source .env) before running. - dsn := fmt.Sprintf( - "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", - os.Getenv("DB_HOST"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), - os.Getenv("DB_NAME"), os.Getenv("DB_PORT"), - ) + dsn := "host=31.97.228.132 user=admin password=Package@321# dbname=logistics port=5433 sslmode=disable" db, err := sql.Open("postgres", dsn) if err != nil { log.Fatalf("Open failed: %v", err) @@ -66,4 +57,4 @@ func main() { WHERE rel.relname = 'pickupbookings' AND con.contype = 'c' `).Scan(&def) fmt.Println("Current constraint:", def) -} +} \ No newline at end of file diff --git a/scratch/list_tables.go b/scratch/list_tables.go index e5c1de7..45f4181 100644 --- a/scratch/list_tables.go +++ b/scratch/list_tables.go @@ -6,22 +6,13 @@ import ( "database/sql" "fmt" "log" - "os" _ "github.com/lib/pq" ) func main() { // DSN matches the one in connect.go and .env - // Built from the environment, not hardcoded. This line used to carry the - // production host and password in plaintext, in a file tracked by git — so - // the live database credential shipped with every clone. Export DB_HOST, - // DB_USER, DB_PASSWORD, DB_NAME and DB_PORT (or source .env) before running. - dsn := fmt.Sprintf( - "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", - os.Getenv("DB_HOST"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), - os.Getenv("DB_NAME"), os.Getenv("DB_PORT"), - ) + dsn := "host=31.97.228.132 user=admin password=Package@321# dbname=logistics port=5433 sslmode=disable" db, err := sql.Open("postgres", dsn) if err != nil { log.Fatalf("Failed to open DB: %v", err) @@ -51,4 +42,4 @@ func main() { } fmt.Printf("Table: %s\n", name) } -} +} \ No newline at end of file From bf5a9026fe411ce9c221834766eaad643dd5b8b5 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Tue, 15 Sep 2026 17:08:22 +0530 Subject: [PATCH 10/11] updates on the env and pagination --- .env | 16 ++++ .env.example | 9 ++ controllers/adminController.go | 45 +++++++++- controllers/admin_pagesize_test.go | 88 +++++++++++++++++++ scratch/cx_tenant_probe.go | 131 +++++++++++++++++++++++++++++ 5 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 controllers/admin_pagesize_test.go create mode 100644 scratch/cx_tenant_probe.go diff --git a/.env b/.env index e6fe4e9..d50e11a 100644 --- a/.env +++ b/.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 diff --git a/.env.example b/.env.example index 5fdee7d..c174eb3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/controllers/adminController.go b/controllers/adminController.go index b47fbb4..8b7f115 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -1127,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") @@ -2099,7 +2110,18 @@ func applyDestinationCounts(bookings []models.PickupBooking, counts []bookingDes 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) @@ -2133,8 +2155,27 @@ func GetAdminBookings(c *fiber.Ctx) error { // 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") diff --git a/controllers/admin_pagesize_test.go b/controllers/admin_pagesize_test.go new file mode 100644 index 0000000..6c0537f --- /dev/null +++ b/controllers/admin_pagesize_test.go @@ -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() +} diff --git a/scratch/cx_tenant_probe.go b/scratch/cx_tenant_probe.go new file mode 100644 index 0000000..d1f0169 --- /dev/null +++ b/scratch/cx_tenant_probe.go @@ -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 +} From 25bc33975c75271c05185c6486c773f984eb3fcb Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Wed, 16 Sep 2026 11:42:06 +0530 Subject: [PATCH 11/11] updates --- internal/sms/http_sender.go | 191 +++++++++++++++++++++++++++++++ internal/sms/http_sender_test.go | 186 ++++++++++++++++++++++++++++++ internal/sms/sms.go | 11 ++ main.go | 8 ++ middlewares/idempotency.go | 29 ++++- middlewares/idempotency_test.go | 50 ++++++++ routes/routes.go | 10 ++ 7 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 internal/sms/http_sender.go create mode 100644 internal/sms/http_sender_test.go diff --git a/internal/sms/http_sender.go b/internal/sms/http_sender.go new file mode 100644 index 0000000..0de2d30 --- /dev/null +++ b/internal/sms/http_sender.go @@ -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}, + }) +} diff --git a/internal/sms/http_sender_test.go b/internal/sms/http_sender_test.go new file mode 100644 index 0000000..c24d46a --- /dev/null +++ b/internal/sms/http_sender_test.go @@ -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) + } +} diff --git a/internal/sms/sms.go b/internal/sms/sms.go index 241e829..c374462 100644 --- a/internal/sms/sms.go +++ b/internal/sms/sms.go @@ -39,6 +39,17 @@ 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 diff --git a/main.go b/main.go index 4ddc62e..93a55cb 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "doormile/internal/assignment" "doormile/internal/notify" "doormile/internal/routing" + "doormile/internal/sms" "doormile/internal/worker" "doormile/middlewares" "doormile/migrations" @@ -92,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) diff --git a/middlewares/idempotency.go b/middlewares/idempotency.go index 785d685..9308e78 100644 --- a/middlewares/idempotency.go +++ b/middlewares/idempotency.go @@ -70,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) } @@ -82,6 +99,12 @@ func Idempotency() fiber.Handler { } } +// 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. // diff --git a/middlewares/idempotency_test.go b/middlewares/idempotency_test.go index a9db02f..df9a8f5 100644 --- a/middlewares/idempotency_test.go +++ b/middlewares/idempotency_test.go @@ -120,3 +120,53 @@ func TestPincodeInOperatingCity(t *testing.T) { } } } + +// 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") + } +} diff --git a/routes/routes.go b/routes/routes.go index 966419a..f3eed09 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -7,6 +7,7 @@ import ( "doormile/config" "doormile/controllers" "doormile/db" + "doormile/internal/sms" "doormile/internal/ws" "doormile/middlewares" @@ -73,6 +74,15 @@ 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(), + }, }, }) })