package controllers import ( "encoding/json" "strconv" "strings" "time" "doormile/constants" "doormile/db" "doormile/internal/assignment" "doormile/internal/cxstage" "doormile/middlewares" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) // Bookings — §9 of the contract. // // A customer books a PICKUP: one visit, 1..N destinations, and no tracking // number anywhere in this file. Tracking numbers are minted per destination // when the miler completes the pickup, because until the parcels are in // someone's hands there is no shipment to track — only an intention to collect // one. const ( cxDefaultPageSize = 20 cxMaxPageSize = 50 // cxAbsoluteMaxDestinations is a hard ceiling checked BEFORE any database // work, independent of the configured per-city cap. // // The configured cap (customerbookinglimits.maxdestinations) is the real // policy and stays authoritative — but reading it costs two queries, and // the district lookup above it builds a `WHERE districtcode IN (...)` from // however many entries the caller sent. Without a ceiling, a request // carrying ten thousand destinations does all of that work before anything // says no. // // Set well above any plausible policy so it never masks the real cap: this // is a sanity guard on unbounded input, not a product limit. cxAbsoluteMaxDestinations = 25 ) type cxDetailsInput struct { Street *string `json:"street"` Building *string `json:"building"` Landmark *string `json:"landmark"` RecipientName *string `json:"recipientName"` RecipientPhone *string `json:"recipientPhone"` Instructions *string `json:"instructions"` Pin *struct { Lat float64 `json:"lat"` Lng float64 `json:"lng"` } `json:"pin"` // CodAmount is money the customer wants collected at this door on their // behalf. Doormile is the carrier, not the seller. CodAmount *float64 `json:"codAmount"` } type cxCreateBookingRequest struct { Pickup struct { Title string `json:"title"` Sub string `json:"sub"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` } `json:"pickup"` SlotID string `json:"slotId"` Destinations []struct { StateCode string `json:"stateCode"` DistrictCode string `json:"districtCode"` PackageCount int `json:"packageCount"` Details *cxDetailsInput `json:"details"` } `json:"destinations"` // Estimate is what the customer was shown on Review. Recorded for dispute // audit — when the settled price is questioned months later, the number on // the screen is the fact that matters, not a re-run of today's pricing. Estimate *struct { Min int `json:"min"` Max int `json:"max"` } `json:"estimate"` // Remarks is the free-text note the customer adds on Review ("Handle with // care"). It is top-level in the documented payload // (docs/customer-app-api-crisp.md) and lands in PickupBooking.Notes, which // the admin Orders table displays and searches. Without the field here // BodyParser drops it silently and every customer-app booking reaches the // console with an empty note. Remarks string `json:"remarks"` } // CreateCxBooking creates the pickup. func CreateCxBooking(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) var req cxCreateBookingRequest if err := c.BodyParser(&req); err != nil { return utils.CxBadRequest(c, "We could not read that request") } // "No destinations at all" and "a destination is missing its state or // district" are different problems with different fixes, and the customer // is shown this message verbatim. Telling someone who added nothing that // "every destination needs a serviceable state and district" describes a // problem they do not have and hides the one they do — they need to add a // destination, not correct one. The estimate endpoint already says this // correctly; these two now agree. if len(req.Destinations) == 0 { return utils.CxBadRequest(c, "Add at least one destination") } if strings.TrimSpace(req.SlotID) == "" { return utils.CxBadRequest(c, "Pick a pickup slot") } // Cheapest validation first, before anything reaches the database. A slot id // carries its own date, so a stale one is provably stale without a query — // and this is the case an app left open across midnight actually hits. if CxSlotDateIsPast(req.SlotID) { return utils.CxBadRequest(c, "That pickup time has passed — pick a new slot") } // Unbounded input, bounded before it costs anything. The configured cap // below is the real policy; this only stops a caller making the server do // three queries and build an arbitrarily long IN clause to be told no. if len(req.Destinations) > cxAbsoluteMaxDestinations { return utils.CxBadRequest(c, "That is more destinations than one pickup can carry") } // The pickup point has to be somewhere Doormile actually collects from. // CityGateMiddleware sniffs the body for a `pickuppincode`, which this // request shape does not have — its pickup is a title/sub/lat/lng from the // place search — so it waves every customer booking through. The check is // done here, against the pincode resolved from the coordinates. pickupPincode := pincodeForPoint(req.Pickup.Lat, req.Pickup.Lng) if _, served := middlewares.PincodeInOperatingCity(pickupPincode); !served { return utils.CxFail(c, fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable, "We are not collecting from that area yet") } appLocationID := appLocationForPoint(req.Pickup.Lat, req.Pickup.Lng) maxPackages, maxDestinations := CxBookingLimits(appLocationID) if len(req.Destinations) > maxDestinations { return utils.CxBadRequest(c, "Up to "+strconv.Itoa(maxDestinations)+" destinations per pickup") } // Resolve every district in one query, then validate. Names are copied onto // the destination rather than joined at read time — the client renders // "Chennai, Tamil Nadu" straight from the booking. codes := make([]string, 0, len(req.Destinations)) for _, d := range req.Destinations { codes = append(codes, strings.ToUpper(strings.TrimSpace(d.DistrictCode))) } var districtRows []models.ServiceableDistrict if err := db.DB.Where("districtcode IN ?", codes).Find(&districtRows).Error; err != nil { utils.Error("CreateCxBooking: district lookup failed", "error", err) return utils.CxInternal(c) } districts := make(map[string]models.ServiceableDistrict, len(districtRows)) for _, d := range districtRows { districts[d.Districtcode] = d } stateNames, err := cxStateNames(districtRows) if err != nil { utils.Error("CreateCxBooking: state lookup failed", "error", err) return utils.CxInternal(c) } totalPackages := 0 for _, d := range req.Destinations { code := strings.ToUpper(strings.TrimSpace(d.DistrictCode)) district, ok := districts[code] if !ok || strings.TrimSpace(d.StateCode) == "" { return utils.CxBadRequest(c, "Every destination needs a serviceable state and district") } if !district.Available { // The district was open when the customer picked it and closed // before they confirmed. A distinct code, because the app has a // specific recovery for it: send them back to change that one // destination rather than to a generic retry. return utils.CxFail(c, fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable, "That district is no longer available") } packages := d.PackageCount if packages < 1 { packages = 1 } totalPackages += packages } if totalPackages > maxPackages { return utils.CxBadRequest(c, "Up to "+strconv.Itoa(maxPackages)+" packages per pickup") } slotFrom, slotTo, slotOK := ResolveCxSlot(req.SlotID) if !slotOK { return utils.CxBadRequest(c, "Pick a pickup slot") } // An EXPIRED slot and a FULL slot are different failures and must not share // a message. A slot id encodes its own date, so an app left open across // midnight — or one that cached the slot list for a session — sends // yesterday's window in good faith. Telling that customer the window "just // filled up" is untrue and points them at the wrong recovery: they need to // re-fetch the slot list, not try again for a place in a queue. // // 400 rather than 409 for the same reason. The contract maps 400/invalid to // "Pick a pickup slot", which is exactly the action required, while 409 is // the capacity race below. if !slotFrom.After(utils.ISTNow()) { return utils.CxBadRequest(c, "That pickup time has passed — pick a new slot") } if !CxSlotHasCapacity(req.SlotID, req.Pickup.Lat, req.Pickup.Lng) { return utils.CxConflict(c, "That pickup window just filled up") } // Price the pickup as one visit. A failed estimate must never block a // booking, so a zero range is stored rather than an error returned — the // receipt settles on what the miler weighs regardless. estimateDestinations := make([]cxEstimateDestination, 0, len(req.Destinations)) for _, d := range req.Destinations { estimateDestinations = append(estimateDestinations, cxEstimateDestination{ StateCode: d.StateCode, DistrictCode: d.DistrictCode, PackageCount: d.PackageCount, }) } quote := quoteCxPickup(req.Pickup.Lat, req.Pickup.Lng, estimateDestinations) estimateMin, estimateMax := quote.Min, quote.Max if req.Estimate != nil && req.Estimate.Max > 0 { // The customer's number wins. They agreed to what was on their screen, // and re-pricing at confirm time would quietly change the deal. estimateMin, estimateMax = req.Estimate.Min, req.Estimate.Max } now := utils.DBNow() pickupFromDB := cxToDBTime(slotFrom) pickupToDB := cxToDBTime(slotTo) first := req.Destinations[0] firstDistrict := districts[strings.ToUpper(strings.TrimSpace(first.DistrictCode))] booking := models.PickupBooking{ Bookingno: generateBookingNo(), Appcustomerid: customerID, Pickupaddress: joinNonEmpty(", ", req.Pickup.Title, req.Pickup.Sub), Pickuptitle: req.Pickup.Title, Pickupsub: req.Pickup.Sub, Pickuppincode: pickupPincode, Pickuplatitude: req.Pickup.Lat, Pickuplongitude: req.Pickup.Lng, // The flat delivery columns mirror destination 0. They are NOT the // destination list — that lives in bookingdestinations — but the miler // app, the hub console, the routing code and the hyperlocal check all // read them, and leaving them empty would make a customer-app booking // invisible to every one of those. Destination 0 is the one the rider // is told about first, so it is the one that mirrors. Deliveryaddress: cxDestinationAddress(first.Details, firstDistrict), Deliverypincode: firstDistrict.Pincodeprefix, Deliverylatitude: firstDistrict.Centrelatitude, Deliverylongitude: firstDistrict.Centrelongitude, Deliverycity: firstDistrict.Districtname, Bookingsource: constants.BookingSourceCustomerApp, Pickupsourcetype: constants.PickupSourceCustomer, Status: constants.BookingPendingPickup, Preferredpickupfrom: &pickupFromDB, Preferredpickupto: &pickupToDB, Slotid: req.SlotID, Customerstage: constants.CxStageBooked, Customerstatus: constants.CxStatusActive, Estimateminrupees: estimateMin, Estimatemaxrupees: estimateMax, Routekm: quote.RouteKM, Notes: req.Remarks, Createdat: now, Updatedat: now, } if pin := cxFirstPin(first.Details); pin != nil { booking.Deliverylatitude, booking.Deliverylongitude = pin[0], pin[1] } tx := db.DB.Begin() if err := tx.Create(&booking).Error; err != nil { tx.Rollback() utils.Error("CreateCxBooking: could not create booking", "customer_id", customerID, "error", err) return utils.CxInternal(c) } for i, d := range req.Destinations { code := strings.ToUpper(strings.TrimSpace(d.DistrictCode)) district := districts[code] packages := d.PackageCount if packages < 1 { packages = 1 } dest := models.BookingDestination{ Bookingid: booking.Bookingid, Seq: i, Statecode: district.Statecode, Statename: stateNames[district.Statecode], Districtcode: district.Districtcode, Districtname: district.Districtname, Packagecount: packages, Pincode: district.Pincodeprefix, Createdat: now, Updatedat: now, } applyCxDetails(&dest, d.Details) if err := tx.Create(&dest).Error; err != nil { tx.Rollback() utils.Error("CreateCxBooking: could not create destination", "booking_id", booking.Bookingid, "error", err) return utils.CxInternal(c) } // One parcel row per package, linked to its destination. The miler // weighs and photographs each package at the door, so each needs a row // to be weighed into — and each has to know which order it belongs to. // Weight is deliberately left at zero: it is never collected from the // customer, and a guess here would look like a measurement on the // receipt. for p := 0; p < packages; p++ { parcel := models.BookingParcel{ Bookingid: booking.Bookingid, Bookingdestinationid: &dest.Bookingdestinationid, Itemcategory: "General", Createdat: now, Updatedat: now, } if err := tx.Create(&parcel).Error; err != nil { tx.Rollback() utils.Error("CreateCxBooking: could not create parcel", "booking_id", booking.Bookingid, "error", err) return utils.CxInternal(c) } } } // The service option is what the REST of the platform reads a booking's // value from, and it is not optional just because the customer surface // keeps its own estimate columns: // // * MilerDeliverConsignment and MilerInwardConsignmentAtHub copy // Estimatedprice onto BookingAssignment.ridercharges when a leg closes, // and GET /miler/earnings sums that column — so with no row here every // customer-app job a rider completes would show ₹0 on their Earnings // screen. // * The admin console's Orders list renders serviceoptions[0].estimatedprice // as the Price column, which would read "N/A" for every customer booking. // // The midpoint of the band the customer was shown is the honest figure // before the miler weighs anything, and it is what lookupDoormilePrice // returns everywhere else in this codebase. slaDue := cxToDBTime(slotTo.Add(48 * time.Hour)) estimatedDelivery := cxToDBTime(slotTo.Add(24 * time.Hour)) srvOption := models.BookingServiceOption{ Bookingid: booking.Bookingid, Servicetype: "Normal", Estimatedprice: float64(estimateMin+estimateMax) / 2, Estimateddeliveryat: &estimatedDelivery, Sladueat: &slaDue, Createdat: now, } if err := tx.Create(&srvOption).Error; err != nil { tx.Rollback() utils.Error("CreateCxBooking: could not create service option", "booking_id", booking.Bookingid, "error", err) return utils.CxInternal(c) } if err := cxstage.Record(tx, cxstage.Event{ BookingID: booking.Bookingid, Stage: constants.CxStageBooked, ActorType: constants.CxActorCustomer, ActorID: &customerID, Source: "POST /customer/bookings", At: now, }); err != nil { tx.Rollback() utils.Error("CreateCxBooking: could not record booked stage", "booking_id", booking.Bookingid, "error", err) return utils.CxInternal(c) } if err := tx.Commit().Error; err != nil { utils.Error("CreateCxBooking: commit failed", "customer_id", customerID, "error", err) return utils.CxInternal(c) } // Fire-and-forget, after commit, exactly as the express path does. go assignment.AssignCustomerMiler(booking.Bookingid) publishCxBookingCreated(&booking, len(req.Destinations)) return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusCreated) } // GetCxBookings backs the Orders tabs, Home's recent list and pull-to-refresh. func GetCxBookings(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) limit := cxDefaultPageSize if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 { limit = v } if limit > cxMaxPageSize { limit = cxMaxPageSize } // One filter, applied to both the page and the count, so `total` is the size // of the tab the customer is actually looking at. Counting every booking // they have ever made would put "48" above a Cancelled tab holding two rows. status := strings.ToLower(strings.TrimSpace(c.Query("status"))) scoped := func() *gorm.DB { q := db.DB.Model(&models.PickupBooking{}).Where("appcustomerid = ?", customerID) switch status { case constants.CxStatusActive: // Rows written before this surface existed carry no customerstatus. // Treating a null as active keeps a live booking visible rather // than hiding it from the customer who is waiting on it. // Parenthesised explicitly rather than relying on AND binding // tighter than OR — the two readings differ by "shows every // cancelled booking in the active tab", which is not a thing to // leave to operator precedence. q = q.Where("(customerstatus = ?) OR ((customerstatus IS NULL OR customerstatus = '') AND status <> ?)", constants.CxStatusActive, constants.BookingCancelled) case constants.CxStatusCompleted: q = q.Where("customerstatus = ?", constants.CxStatusCompleted) case constants.CxStatusCancelled: q = q.Where("customerstatus = ? OR status = ?", constants.CxStatusCancelled, constants.BookingCancelled) } return q } q := scoped() // Keyset pagination on the primary key. Offsets drift when a new booking // lands mid-scroll, which shows the customer the same row twice. if cursor := strings.TrimSpace(c.Query("cursor")); cursor != "" { if after, err := strconv.Atoi(cursor); err == nil { q = q.Where("bookingid < ?", after) } } var bookings []models.PickupBooking if err := q.Order("bookingid DESC").Limit(limit + 1).Find(&bookings).Error; err != nil { utils.Error("GetCxBookings: query failed", "customer_id", customerID, "error", err) return utils.CxInternal(c) } var nextCursor *string if len(bookings) > limit { bookings = bookings[:limit] next := strconv.Itoa(bookings[len(bookings)-1].Bookingid) nextCursor = &next } bundle := loadCxBundle(bookings) out := make([]fiber.Map, 0, len(bookings)) for i := range bookings { out = append(out, renderCxBooking(&bookings[i], bundle)) } var total int64 if err := scoped().Count(&total).Error; err != nil { utils.Warn("GetCxBookings: count failed, reporting the page size", "customer_id", customerID, "error", err) total = int64(len(out)) } return utils.CxList(c, out, int(total), nextCursor) } // GetCxBookingDetail is the canonical read — the tracking screen and the // receipt are both rendered from it, and it is polled while tracking is open. func GetCxBookingDetail(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) reference := strings.TrimSpace(c.Params("reference")) booking, err := cxLoadBooking(customerID, reference) if err != nil { return utils.CxNotFound(c, "We could not find that pickup") } bundle := loadCxBundle([]models.PickupBooking{*booking}) payload := renderCxBooking(booking, bundle) // Polled every few seconds while the tracking screen is open. A 304 turns // most of those polls into a header exchange instead of a full render. if served := serveIfNotModified(c, payload); served { return nil } c.Set("Cache-Control", "no-cache") return utils.CxOK(c, payload) } // GetCxOrder returns one order by tracking number, for push deep links. // // It answers with the whole booking object rather than a slimmer order shape — // the client already parses this one, and a second shape for the same data is // a second parser to keep in step. func GetCxOrder(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) trackingID := strings.TrimSpace(c.Params("trackingId")) if trackingID == "" { return utils.CxNotFound(c, "We could not find that order") } var dest models.BookingDestination if err := db.DB.Where("trackingno = ?", trackingID).First(&dest).Error; err != nil { return utils.CxNotFound(c, "We could not find that order") } var booking models.PickupBooking if err := db.DB.Where("bookingid = ? AND appcustomerid = ?", dest.Bookingid, customerID). First(&booking).Error; err != nil { // The tracking number exists but belongs to someone else. Answered as // not-found rather than forbidden: confirming that a tracking number is // real tells an enumerating caller something they should not learn. return utils.CxNotFound(c, "We could not find that order") } bundle := loadCxBundle([]models.PickupBooking{booking}) return utils.CxOK(c, renderCxBooking(&booking, bundle)) } // CancelCxBooking cancels the whole pickup. // // Allowed through arrived and refused from picked_up onward. `cancellable` on // the booking mirrors the same policy so the UI can hide the button, but this // re-checks — the button state is a hint the client renders from a response // that may be seconds old, never the authority. func CancelCxBooking(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) reference := strings.TrimSpace(c.Params("reference")) var req struct { Reason string `json:"reason"` } _ = c.BodyParser(&req) booking, err := cxLoadBooking(customerID, reference) if err != nil { return utils.CxNotFound(c, "We could not find that pickup") } if booking.Customerstatus == constants.CxStatusCancelled || booking.Status == constants.BookingCancelled { // Already cancelled. Answered as success rather than as a conflict: the // customer asked for a state the booking is already in, and a retry // over a flaky network must not read as a failure. return utils.CxOK(c, fiber.Map{ "reference": booking.Bookingno, "status": constants.CxStatusCancelled, "cancelReason": booking.Cancelreason, }) } stage := booking.Customerstage if stage == "" { stage = deriveStageFromStatus(booking) } if !constants.CxCancellable(stage) { return utils.CxConflict(c, "This pickup can no longer be cancelled") } reason := strings.TrimSpace(req.Reason) tx := db.DB.Begin() if err := cxstage.Cancel(tx, booking.Bookingid, reason, constants.CxActorCustomer, &customerID, "POST /customer/bookings/{reference}/cancel"); err != nil { tx.Rollback() utils.Error("CancelCxBooking: cancel failed", "booking_id", booking.Bookingid, "error", err) return utils.CxInternal(c) } // Release the rider. Without this the assignment stays open, the rider // keeps a stop they must not attempt, and MilerEndDuty refuses to let them // go off duty while any assignment is still Assigned or Accepted. if err := tx.Model(&models.BookingAssignment{}). Where("bookingid = ? AND assignmentstatus IN ?", booking.Bookingid, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). Updates(map[string]interface{}{ "assignmentstatus": constants.AssignmentCancelled, "remarks": "cancelled by customer", }).Error; err != nil { tx.Rollback() utils.Error("CancelCxBooking: could not release assignment", "booking_id", booking.Bookingid, "error", err) return utils.CxInternal(c) } if booking.Assignedmileruserid != nil { if err := tx.Model(&models.MilerProfile{}). Where("userid = ? AND availabilitystatus IN ?", *booking.Assignedmileruserid, []string{constants.MilerAssigned, constants.MilerOnPickup, constants.MilerAtCustomer}). Update("availabilitystatus", constants.MilerAvailable).Error; err != nil { tx.Rollback() utils.Error("CancelCxBooking: could not free the rider", "booking_id", booking.Bookingid, "error", err) return utils.CxInternal(c) } } if err := tx.Commit().Error; err != nil { utils.Error("CancelCxBooking: commit failed", "booking_id", booking.Bookingid, "error", err) return utils.CxInternal(c) } publishCxBookingCancelled(booking, reason) return utils.CxOK(c, fiber.Map{ "reference": booking.Bookingno, "status": constants.CxStatusCancelled, "cancelReason": reason, }) } // PatchCxDestination fills in the parts of an address the customer left out. // // Accepted until the parcels are collected. After that the shipment's addresses // are frozen on the consignment and an edit here would change what the customer // sees without changing where the parcel is going — which is worse than // refusing. // // Writes land on the same booking row the miler app reads addresses from, so a // correction made while the rider is on their way reaches them. func PatchCxDestination(c *fiber.Ctx) error { customerID := c.Locals("userid").(int) reference := strings.TrimSpace(c.Params("reference")) index, err := strconv.Atoi(c.Params("index")) if err != nil || index < 0 { return utils.CxNotFound(c, "We could not find that destination") } booking, err := cxLoadBooking(customerID, reference) if err != nil { return utils.CxNotFound(c, "We could not find that pickup") } stage := booking.Customerstage if stage == "" { stage = deriveStageFromStatus(booking) } if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStagePickedUp] { return utils.CxConflict(c, "Your packages have been collected — these details can no longer be changed") } if booking.Customerstatus == constants.CxStatusCancelled || booking.Status == constants.BookingCancelled { return utils.CxConflict(c, "This pickup was cancelled") } var dest models.BookingDestination if err := db.DB.Where("bookingid = ? AND seq = ?", booking.Bookingid, index). First(&dest).Error; err != nil { return utils.CxNotFound(c, "We could not find that destination") } var req cxDetailsInput if err := c.BodyParser(&req); err != nil { return utils.CxBadRequest(c, "We could not read that request") } applyCxDetails(&dest, &req) dest.Updatedat = utils.DBNow() tx := db.DB.Begin() if err := tx.Save(&dest).Error; err != nil { tx.Rollback() utils.Error("PatchCxDestination: save failed", "destination_id", dest.Bookingdestinationid, "error", err) return utils.CxInternal(c) } // Destination 0 mirrors onto the booking's flat delivery columns, which is // where the miler app and the routing code look. An edit that only landed // on bookingdestinations would be invisible to the rider standing at the // door, which is precisely who it was made for. if dest.Seq == 0 { updates := map[string]interface{}{ "deliveryaddress": cxDestinationAddressFromRow(&dest), "updatedat": utils.DBNow(), } if dest.Pinlatitude != nil && dest.Pinlongitude != nil { updates["deliverylatitude"] = *dest.Pinlatitude updates["deliverylongitude"] = *dest.Pinlongitude } if err := tx.Model(&models.PickupBooking{}). Where("bookingid = ?", booking.Bookingid).Updates(updates).Error; err != nil { tx.Rollback() utils.Error("PatchCxDestination: could not mirror onto booking", "booking_id", booking.Bookingid, "error", err) return utils.CxInternal(c) } } if err := tx.Commit().Error; err != nil { utils.Error("PatchCxDestination: commit failed", "booking_id", booking.Bookingid, "error", err) return utils.CxInternal(c) } return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusOK) } // ── Helpers ────────────────────────────────────────────────────────────────── // cxLoadBooking finds a booking by its customer-facing reference, scoped to the // caller. Ownership is part of the lookup, not a check afterwards — a query // that can return someone else's row is one forgotten `if` away from leaking it. func cxLoadBooking(customerID int, reference string) (*models.PickupBooking, error) { if reference == "" { return nil, gorm.ErrRecordNotFound } var booking models.PickupBooking if err := db.DB.Where("bookingno = ? AND appcustomerid = ?", reference, customerID). First(&booking).Error; err != nil { return nil, err } return &booking, nil } // cxRespondWithBooking re-reads and renders, so a create or a patch answers in // exactly the shape a later GET will. func cxRespondWithBooking(c *fiber.Ctx, bookingID, status int) error { var booking models.PickupBooking if err := db.DB.First(&booking, bookingID).Error; err != nil { utils.Error("cxRespondWithBooking: reload failed", "booking_id", bookingID, "error", err) return utils.CxInternal(c) } bundle := loadCxBundle([]models.PickupBooking{booking}) payload := renderCxBooking(&booking, bundle) if status == fiber.StatusCreated { return utils.CxCreated(c, payload) } return utils.CxOK(c, payload) } // applyCxDetails writes only the fields actually present in the request. An // omitted key leaves the stored value alone; an explicit null clears it, which // is how the customer removes a landmark they no longer want the rider to use. func applyCxDetails(dest *models.BookingDestination, in *cxDetailsInput) { if in == nil { return } if in.Street != nil { dest.Street = strings.TrimSpace(*in.Street) } if in.Building != nil { dest.Building = strings.TrimSpace(*in.Building) } if in.Landmark != nil { dest.Landmark = strings.TrimSpace(*in.Landmark) } if in.RecipientName != nil { dest.Recipientname = strings.TrimSpace(*in.RecipientName) } if in.RecipientPhone != nil { if phone, _, ok := normalizePhone(*in.RecipientPhone); ok { dest.Recipientphone = phone } else { dest.Recipientphone = strings.TrimSpace(*in.RecipientPhone) } } if in.Instructions != nil { dest.Instructions = strings.TrimSpace(*in.Instructions) } if in.Pin != nil { lat, lng := in.Pin.Lat, in.Pin.Lng if lat == 0 && lng == 0 { dest.Pinlatitude, dest.Pinlongitude = nil, nil } else { dest.Pinlatitude, dest.Pinlongitude = &lat, &lng } } if in.CodAmount != nil { dest.Codamount = *in.CodAmount } } func cxFirstPin(in *cxDetailsInput) *[2]float64 { if in == nil || in.Pin == nil { return nil } if in.Pin.Lat == 0 && in.Pin.Lng == 0 { return nil } return &[2]float64{in.Pin.Lat, in.Pin.Lng} } // cxDestinationAddress builds the flat address string the rest of the system // stores, from whatever the customer supplied. It always resolves to something // non-empty — pickupbookings.deliveryaddress is NOT NULL, and a booking with // only a state and a district is a legitimate booking. func cxDestinationAddress(in *cxDetailsInput, district models.ServiceableDistrict) string { parts := []string{} if in != nil { if in.Building != nil { parts = append(parts, *in.Building) } if in.Street != nil { parts = append(parts, *in.Street) } if in.Landmark != nil { parts = append(parts, *in.Landmark) } } parts = append(parts, district.Districtname) address := joinNonEmpty(", ", parts...) if address == "" { return district.Districtcode } return address } func cxDestinationAddressFromRow(d *models.BookingDestination) string { address := joinNonEmpty(", ", d.Building, d.Street, d.Landmark, d.Districtname) if address == "" { return d.Districtcode } return address } // cxStateNames resolves display names for the states a set of districts belong // to, in one query. func cxStateNames(districts []models.ServiceableDistrict) (map[string]string, error) { codes := make([]string, 0, len(districts)) seen := map[string]bool{} for _, d := range districts { if d.Statecode != "" && !seen[d.Statecode] { seen[d.Statecode] = true codes = append(codes, d.Statecode) } } names := make(map[string]string, len(codes)) if len(codes) == 0 { return names, nil } var states []models.ServiceableState if err := db.DB.Where("statecode IN ?", codes).Find(&states).Error; err != nil { return names, err } for _, s := range states { names[s.Statecode] = s.Statename } return names, nil } // cxToDBTime converts an IST wall clock into the shape this database stores — // the same digits, tagged UTC so the driver writes them verbatim. See // utils.DBNow: comparing a container's UTC clock against IST-stamped rows is // what made date-range reports undercount. func cxToDBTime(t time.Time) time.Time { ist := t.In(utils.ISTLocation()) return time.Date(ist.Year(), ist.Month(), ist.Day(), ist.Hour(), ist.Minute(), ist.Second(), 0, time.UTC) } // ── Events ─────────────────────────────────────────────────────────────────── // publishCxBookingCreated and publishCxBookingCancelled mirror the existing // booking events onto NATS. Best-effort and nil-checked, like every other // publish in this codebase: the event bus is never allowed to fail a booking. func publishCxBookingCreated(b *models.PickupBooking, destinationCount int) { if db.Js == nil { return } payload := map[string]interface{}{ "booking_id": b.Bookingid, "booking_no": b.Bookingno, "customer_id": b.Appcustomerid, "pickup_address": b.Pickupaddress, "pickup_pincode": b.Pickuppincode, "destination_count": destinationCount, "slot_id": b.Slotid, "status": constants.BookingPendingPickup, "created_at": utils.EpochMillis(b.Createdat), } data, err := json.Marshal(payload) if err != nil { return } if _, err := db.Js.Publish("api.v1.bookings.create", data); err != nil { utils.Warn("Failed to publish booking.create to NATS", "booking_id", b.Bookingid, "error", err) } } func publishCxBookingCancelled(b *models.PickupBooking, reason string) { if db.Js == nil { return } payload := map[string]interface{}{ "booking_id": b.Bookingid, "booking_no": b.Bookingno, "customer_id": b.Appcustomerid, "status": "Cancelled", "reason": reason, "cancelled_at": time.Now().UnixMilli(), } data, err := json.Marshal(payload) if err != nil { return } if _, err := db.Js.Publish("api.v1.bookings.cancel", data); err != nil { utils.Warn("Failed to publish booking.cancel to NATS", "booking_id", b.Bookingid, "error", err) } }