package controllers import ( "encoding/json" "fmt" "sort" "strconv" "strings" "time" "doormile/constants" "doormile/db" "doormile/internal/notify" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" ) // -------------------- // DUTY MANAGEMENT // -------------------- func MilerStartDuty(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var req struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } midnight := todayMidnight() var existing models.MilerDutyLog if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL AND loginat >= ?", milerUserID, true, midnight). First(&existing).Error; err == nil { return utils.BadRequest(c, "already on duty, end current duty first") } now := time.Now() dutyLog := models.MilerDutyLog{ Userid: milerUserID, Loginat: now, Onduty: true, Startlat: req.Lat, Startlon: req.Lon, Lastlat: req.Lat, Lastlon: req.Lon, } if err := db.DB.Create(&dutyLog).Error; err != nil { return utils.Internal(c, "failed to start duty") } db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Updates(map[string]interface{}{ "availabilitystatus": constants.MilerAvailable, "currentlatitude": req.Lat, "currentlongitude": req.Lon, "lastlocationupdatedat": now, }) db.DB.Model(&models.AppUser{}).Where("userid = ?", milerUserID).Update("onduty", 1) // Make the rider findable by auto-assignment straight away, from the // position they started duty at, instead of only after the app's first // location ping. Skipped when the app sent no coordinates. indexMilerLocation(milerUserID, req.Lat, req.Lon) return utils.OK(c, fiber.Map{ "dutylogid": dutyLog.Dutylogid, "loginat": dutyLog.Loginat, }) } func MilerEndDuty(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var req struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } var dutyLog models.MilerDutyLog if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL", milerUserID, true). Order("loginat DESC").First(&dutyLog).Error; err != nil { return utils.BadRequest(c, "not currently on duty") } var activeAssignments int64 db.DB.Model(&models.BookingAssignment{}). Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). Count(&activeAssignments) if activeAssignments > 0 { return utils.BadRequest(c, "cannot go off duty with active assignments") } now := time.Now() dutyLog.Logoutat = &now dutyLog.Onduty = false dutyLog.Lastlat = req.Lat dutyLog.Lastlon = req.Lon if err := db.DB.Save(&dutyLog).Error; err != nil { return utils.Internal(c, "failed to end duty") } db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerOffline) db.DB.Model(&models.AppUser{}).Where("userid = ?", milerUserID).Update("onduty", 0) return utils.OK(c, fiber.Map{ "dutylogid": dutyLog.Dutylogid, "logoutat": dutyLog.Logoutat, "totalkms": dutyLog.Totalkms, }) } func MilerGetDutyStatus(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) midnight := todayMidnight() var dutyLog models.MilerDutyLog if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL AND loginat >= ?", milerUserID, true, midnight). Order("loginat DESC").First(&dutyLog).Error; err != nil { return utils.OK(c, fiber.Map{"onduty": false}) } var profile models.MilerProfile db.DB.Where("userid = ?", milerUserID).First(&profile) return utils.OK(c, fiber.Map{ "onduty": true, "dutylogid": dutyLog.Dutylogid, "loginat": dutyLog.Loginat, "totalkms": dutyLog.Totalkms, "availabilitystatus": profile.Availabilitystatus, }) } // -------------------- // BREAK MANAGEMENT // -------------------- func MilerStartBreak(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var req struct { Breaktype string `json:"breaktype"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } if req.Breaktype == "" { req.Breaktype = "Personal" } var dutyLog models.MilerDutyLog if err := db.DB.Where("userid = ? AND onduty = ? AND logoutat IS NULL", milerUserID, true). Order("loginat DESC").First(&dutyLog).Error; err != nil { return utils.BadRequest(c, "not on duty") } var openBreak models.MilerBreakLog if err := db.DB.Where("userid = ? AND endat IS NULL", milerUserID).First(&openBreak).Error; err == nil { return utils.BadRequest(c, "break already active") } breakLog := models.MilerBreakLog{ Userid: milerUserID, Dutylogid: dutyLog.Dutylogid, Breaktype: req.Breaktype, Startat: time.Now(), } if err := db.DB.Create(&breakLog).Error; err != nil { return utils.Internal(c, "failed to start break") } db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerBreak) return utils.OK(c, fiber.Map{ "breaklogid": breakLog.Breaklogid, "startat": breakLog.Startat, }) } func MilerEndBreak(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var breakLog models.MilerBreakLog if err := db.DB.Where("userid = ? AND endat IS NULL", milerUserID). Order("startat DESC").First(&breakLog).Error; err != nil { return utils.BadRequest(c, "no active break") } now := time.Now() breakLog.Endat = &now if err := db.DB.Save(&breakLog).Error; err != nil { return utils.Internal(c, "failed to end break") } db.DB.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).Update("availabilitystatus", constants.MilerAvailable) return utils.OK(c, fiber.Map{ "breaklogid": breakLog.Breaklogid, "endat": breakLog.Endat, }) } // -------------------- // MILER'S OWN BOOKINGS // -------------------- // milerStopType tells the app whether the actionable leg of a booking is a // pickup or a delivery, so the mixed-route screen no longer has to infer it // structurally and default everything to "pickup". It is derived from the // booking's own status: before the parcel is in the rider's hands the next // action is the pickup; once picked up (or converted to a consignment) the // next action is the delivery. func milerStopType(status string) string { switch status { case constants.BookingPickedUp, constants.BookingConvertedConsignment: return "delivery" default: // Created, Miler_Assigned, Pickup_Scheduled and anything else pre-pickup. return "pickup" } } func MilerGetMyBookings(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) status := c.Query("status") dateStr := c.Query("date") query := db.DB.Where("assignedmileruserid = ?", milerUserID) if status != "" && status != "All" { query = query.Where("status = ?", status) } if dateStr != "" { if date, err := time.Parse("2006-01-02", dateStr); err == nil { query = query.Where("createdat >= ? AND createdat < ?", date, date.Add(24*time.Hour)) } } var bookings []models.PickupBooking if err := query.Preload("Parcels").Preload("ServiceOptions").Preload("Payments"). Order("createdat DESC").Find(&bookings).Error; err != nil { return utils.Internal(c, "failed to fetch bookings") } // Batch the per-booking lookups so the route stays a handful of queries // regardless of how many stops a rider is carrying. bookingIDs := make([]int, 0, len(bookings)) consignmentIDs := make([]int, 0, len(bookings)) for _, b := range bookings { bookingIDs = append(bookingIDs, b.Bookingid) if b.Consignmentid != nil { consignmentIDs = append(consignmentIDs, *b.Consignmentid) } } // 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. seqByBooking := map[int]models.BookingAssignment{} if len(bookingIDs) > 0 { var assignments []models.BookingAssignment db.DB.Where("mileruserid = ? AND bookingid IN ? AND assignmentstatus IN ?", milerUserID, bookingIDs, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). Order("assignedat DESC").Find(&assignments) for _, a := range assignments { if _, seen := seqByBooking[a.Bookingid]; !seen { seqByBooking[a.Bookingid] = a } } } // COD becomes authoritative on the consignment once picked up; before that it // is whatever cash the booking's payment row says is due. codByConsignment := map[int]models.Consignment{} if len(consignmentIDs) > 0 { var cons []models.Consignment db.DB.Where("consignmentid IN ?", consignmentIDs).Find(&cons) for _, cn := range cons { codByConsignment[cn.Consignmentid] = cn } } response := make([]fiber.Map, 0, len(bookings)) for _, b := range bookings { var customer models.AppCustomer db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer) // 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) stops := milerStopsForBooking(&b, destinationsByBooking[b.Bookingid]) 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) } } // Sequenced stops ascend by step; unsequenced (step 0) fall to the end while // 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 { case si == 0 && sj == 0: return false // preserve existing createdat DESC order case si == 0: return false case sj == 0: return true default: return si < sj } }) 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 // failure so the app can branch without parsing messages. func milerConsignmentForRider(milerUserID, consignmentID int) (*models.Consignment, string, error) { var consignment models.Consignment if err := db.DB.First(&consignment, consignmentID).Error; err != nil { return nil, constants.ErrConsignmentNotFound, err } var count int64 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") } return &consignment, "", nil } // MilerGetConsignment returns a consignment's current state to the app so it can // decide what the rider may do next (deliver, skip, wait) without replaying the // whole logs history. Failure cases carry stable codes. func MilerGetConsignment(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) consignmentID, err := strconv.Atoi(c.Params("consignmentid")) if err != nil { return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "invalid consignment ID") } 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") } return utils.OK(c, fiber.Map{ "consignmentid": consignment.Consignmentid, "trackingno": consignment.Trackingno, "status": consignment.Status, "attemptcount": consignment.Attemptcount, "paymentmode": consignment.Paymentmode, "codamount": consignment.Codamount, "codcollected": consignment.Codcollected, // Derived flags so the app doesn't hardcode the status vocabulary. "collected": consignment.Status == constants.ConsignmentCollectedByMiler, "out_for_delivery": consignment.Status == constants.ConsignmentOutForDelivery, "delivered": consignment.Status == constants.ConsignmentDelivered, "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, }) } // MilerStartDelivery moves a hyperlocal consignment the rider is carrying from // Collected_By_Miler to Out_for_Delivery — the explicit "I'm heading to the // customer now" tap. This is where the receiver OTP is issued (for clients that // use one) and where the customer is told the parcel is on its way, so neither // happens a holding-period early at pickup. func MilerStartDelivery(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") } 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") } if consignment.Status != constants.ConsignmentCollectedByMiler { return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState, fmt.Sprintf("consignment is %s, not %s", consignment.Status, constants.ConsignmentCollectedByMiler)) } tx := db.DB.Begin() // Issue the receiver OTP now, only for clients that ask for one. if consignment.Deliveryotp == "" { var tenant models.Tenant if tx.Where("tenantid = ?", consignment.Tenantid).First(&tenant).Error == nil && tenant.Requiredeliveryotp { consignment.Deliveryotp = utils.GenerateNumericOTP(6) } } consignment.Status = constants.ConsignmentOutForDelivery consignment.Updatedat = time.Now() if err := tx.Save(consignment).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to start delivery") } history := models.ConsignmentHistory{ Consignmentid: consignment.Consignmentid, Hubid: consignment.Currenthubid, Userid: &milerUserID, Eventstatus: constants.ConsignmentOutForDelivery, Remarks: "Rider started final-mile delivery", } if err := tx.Create(&history).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to record delivery start") } if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). Update("availabilitystatus", constants.MilerOnDelivery).Error; err != nil { tx.Rollback() 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). // // 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." payload := map[string]string{"consignment_id": strconv.Itoa(consignment.Consignmentid)} 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, "Out for Delivery", body, payload); notifyErr != nil { utils.Warn("FCM: failed to notify customer on delivery start", "consignment_id", consignment.Consignmentid, "error", notifyErr) } } } return utils.OK(c, fiber.Map{ "consignmentid": consignment.Consignmentid, "status": consignment.Status, }) } // -------------------- // DELIVERY CONFIRMATION // -------------------- func MilerDeliverConsignment(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) consignmentID, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid consignment ID") } var req struct { Otp string `json:"otp"` Deliveredtoname string `json:"deliveredtoname"` Photourl string `json:"photourl"` Receiversignatureurl string `json:"receiversignatureurl"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } if req.Deliveredtoname == "" { return utils.BadRequest(c, "deliveredtoname is required") } // 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, "consignment is not out for delivery — start delivery first") } // An OTP is only present when the client asked for one (Tenant.Requiredeliveryotp), // so an empty one means this delivery was never meant to need a code — that // covers food clients like DailyGrubs as well as parcels already in the // network from before OTPs existed, which would otherwise be unclosable. if consignment.Deliveryotp != "" { if req.Otp == "" { return utils.Fail(c, fiber.StatusBadRequest, constants.ErrOtpRequired, "otp is required for this delivery") } if req.Otp != consignment.Deliveryotp { return utils.Fail(c, fiber.StatusBadRequest, constants.ErrOtpInvalid, "incorrect delivery OTP") } } tx := db.DB.Begin() proof := models.DeliveryProof{ Consignmentid: consignment.Consignmentid, Deliveredat: time.Now(), Deliveredtoname: req.Deliveredtoname, Receiversignatureurl: req.Receiversignatureurl, Photourl: req.Photourl, Otpverified: consignment.Deliveryotp != "", Geolatitude: req.Lat, Geolongitude: req.Lon, Createdby: milerUserID, } if err := tx.Create(&proof).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to record delivery proof") } consignment.Status = constants.ConsignmentDelivered // Cleared once redeemed so the same code can't close out a second attempt. consignment.Deliveryotp = "" consignment.Updatedat = time.Now() if err := tx.Save(&consignment).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to mark consignment delivered") } // Every other state change on a consignment writes a history row; delivery // did not, so a customer following the tracking timeline never saw the // parcel arrive — it just stopped at Out_for_Delivery. deliveredEvent := models.ConsignmentHistory{ Consignmentid: consignment.Consignmentid, Hubid: consignment.Currenthubid, Userid: &milerUserID, Eventstatus: constants.ConsignmentDelivered, Remarks: fmt.Sprintf("Delivered to %s at (%.5f, %.5f)", req.Deliveredtoname, req.Lat, req.Lon), } if err := tx.Create(&deliveredEvent).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to record delivery history") } // riderkms is the distance actually ridden for this booking, measured from // the pickup point to where the rider stood when they confirmed delivery // (falling back to the booking's delivery coordinates if the app sent none). // ridercharges is the order amount the tenant is billed, passed in at // creation as finalprice and stored on the service option — Doormile does // not compute it. Both were only ever read by GET /miler/earnings and never // written, so every completed job reported zero distance and zero value. dropLat, dropLon := req.Lat, req.Lon if dropLat == 0 && dropLon == 0 { dropLat, dropLon = consignment.Deliverylatitude, consignment.Deliverylongitude } riderKms := haversineKM(consignment.Pickuplatitude, consignment.Pickuplongitude, dropLat, dropLon) var serviceOpt models.BookingServiceOption orderAmount := 0.0 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 = ?", booking.Bookingid, milerUserID). Updates(map[string]interface{}{ "assignmentstatus": constants.AssignmentCompleted, "completedat": time.Now(), "riderkms": riderKms, "ridercharges": orderAmount, }).Error; err != nil { tx.Rollback() 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, "outcome": "on_time", "consignmentid": consignment.Consignmentid, "mileruserid": milerUserID, } if data, err := json.Marshal(payload); err == nil { if _, err := db.Js.Publish("booking.outcome", data); err != nil { utils.Warn("MilerDeliverConsignment: NATS publish failed", "consignment_id", consignment.Consignmentid, "error", err) } } } var customer models.AppCustomer if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" { if notifyErr := notify.SendToDevice( customer.Devicetoken, "Parcel Delivered", "Your parcel has been delivered!", map[string]string{ "booking_id": strconv.Itoa(booking.Bookingid), "consignment_id": strconv.Itoa(consignment.Consignmentid), }, ); notifyErr != nil { utils.Warn("FCM: failed to notify customer on delivery", "booking_id", booking.Bookingid, "error", notifyErr) } } return c.JSON(fiber.Map{ "success": true, "message": "delivery confirmed", "data": fiber.Map{ "proofid": proof.Proofid, "deliveredat": proof.Deliveredat, }, }) } // MilerSkipDelivery records a failed/incomplete final-mile delivery attempt // (customer unavailable, gate locked, wrong address, etc.) without closing // out the consignment — the miler still holds the parcel, the consignment // stays Out_for_Delivery, and the attempt is logged for a retry. This is the // "skipped" half of the old system's 8-in-1 status endpoint; MilerDeliverConsignment // above is the "delivered" half, and MilerCancelAssignment (milerController.go) // is the pre-pickup "cancelled"/"rejected" half. func MilerSkipDelivery(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) consignmentID, err := strconv.Atoi(c.Params("id")) if err != nil { return utils.BadRequest(c, "invalid consignment ID") } var req struct { Reason string `json:"reason"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } if req.Reason == "" { return utils.BadRequest(c, "reason 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 { return utils.Fail(c, fiber.StatusNotFound, constants.ErrConsignmentNotAssigned, "assigned consignment not found") } // A failed attempt can be reported once the rider is carrying the parcel — // whether they had already tapped start-delivery (Out_for_Delivery) or not // (Collected_By_Miler). Both mean "in hand, delivery didn't happen". if consignment.Status != constants.ConsignmentOutForDelivery && consignment.Status != constants.ConsignmentCollectedByMiler { return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState, "consignment is not in a deliverable state") } tx := db.DB.Begin() consignment.Attemptcount += 1 consignment.Updatedat = time.Now() if err := tx.Save(&consignment).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to record skipped attempt") } history := models.ConsignmentHistory{ Consignmentid: consignment.Consignmentid, Userid: &milerUserID, Eventstatus: "Delivery_Skipped", Remarks: fmt.Sprintf("Attempt %d skipped at (%.5f, %.5f): %s", consignment.Attemptcount, req.Lat, req.Lon, req.Reason), } if err := tx.Create(&history).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to log skipped attempt") } if err := tx.Commit().Error; err != nil { return utils.Internal(c, "failed to commit skipped attempt") } // After 3 failed attempts, flag it for hub attention rather than leaving // it silently retrying forever. if consignment.Attemptcount >= 3 { exception := models.ConsignmentException{ Consignmentid: consignment.Consignmentid, Reportedbyuserid: &milerUserID, Exceptiontype: constants.ExceptionUndeliverable, Severity: "Medium", Description: fmt.Sprintf("3 delivery attempts failed. Last reason: %s", req.Reason), } db.DB.Create(&exception) } return utils.OK(c, fiber.Map{ "consignmentid": consignment.Consignmentid, "attemptcount": consignment.Attemptcount, "status": consignment.Status, }) } // MilerSkipPickup is the pre-pickup counterpart of MilerSkipDelivery: a rider // defers a booking they were assigned but have not yet picked up (kitchen not // ready, gate shut, arrived too early) without giving it up. Unlike // MilerCancelAssignment this does NOT release the booking for reassignment — // it stays assigned to the same rider so it can be resumed, which is the "route // back" the app had no endpoint for: the delivery-side skip requires a // consignment that a not-yet-picked-up booking does not have. // // The status is left untouched (still pre-pickup and actionable); the attempt // is appended to the booking notes for ops audit, since a pre-pickup booking // has no consignment-history table to write to yet. func MilerSkipPickup(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) bookingID, err := strconv.Atoi(c.Params("bookingid")) if err != nil { return utils.BadRequest(c, "invalid booking ID") } var req struct { Reason string `json:"reason"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } if req.Reason == "" { return utils.BadRequest(c, "reason is required") } var booking models.PickupBooking if err := db.DB.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID). First(&booking).Error; err != nil { return utils.NotFound(c, "assigned booking not found") } // Once the parcel is in the network this is the wrong endpoint — the rider // must use the consignment delivery-skip instead. if booking.Status == constants.BookingPickedUp || booking.Status == constants.BookingConvertedConsignment { return utils.BadRequest(c, "booking already picked up — use the delivery skip on its consignment") } note := fmt.Sprintf("[%s] pickup skipped by miler %d at (%.5f, %.5f): %s", time.Now().Format("2006-01-02 15:04"), milerUserID, req.Lat, req.Lon, req.Reason) if booking.Notes != "" { booking.Notes = booking.Notes + "\n" + note } else { booking.Notes = note } booking.Updatedat = time.Now() if err := db.DB.Model(&booking). Updates(map[string]interface{}{"notes": booking.Notes, "updatedat": booking.Updatedat}).Error; err != nil { return utils.Internal(c, "failed to record skipped pickup") } // Booking stays assigned and pre-pickup — the app keeps it in the list and // the rider resumes the normal pickup flow when able. return utils.OK(c, fiber.Map{ "bookingid": booking.Bookingid, "status": booking.Status, "stoptype": milerStopType(booking.Status), "resumable": true, }) } // -------------------- // EARNINGS // -------------------- type milerEarningsSummary struct { CompletedStops int64 `gorm:"column:completed_stops"` TotalKms float64 `gorm:"column:total_kms"` TotalEarnings float64 `gorm:"column:total_earnings"` TotalBonus int `gorm:"column:total_bonus"` } func MilerGetEarnings(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) period := c.Query("period", "daily") var start, end time.Time // Database-local, not container-local — see utils.DBNow. The rest of the // range logic already moved off the container clock; this branch would // otherwise put a rider in the wrong month for 5h30m either side of it. now := utils.DBNow() switch period { case "weekly": start = todayMidnight().Add(-6 * 24 * time.Hour) end = todayMidnight().Add(24 * time.Hour) case "monthly": start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) end = start.AddDate(0, 1, 0) default: period = "daily" day := todayMidnight() if dateStr := c.Query("date"); dateStr != "" { if parsed, err := time.Parse("2006-01-02", dateStr); err == nil { day = parsed } } start = day end = day.Add(24 * time.Hour) } var summary milerEarningsSummary db.DB.Model(&models.BookingAssignment{}). Where("mileruserid = ? AND assignmentstatus = ? AND completedat >= ? AND completedat < ?", milerUserID, constants.AssignmentCompleted, start, end). Select("COUNT(*) as completed_stops, COALESCE(SUM(riderkms),0) as total_kms, COALESCE(SUM(ridercharges),0) as total_earnings, COALESCE(SUM(bonuspoints),0) as total_bonus"). Scan(&summary) // Cancelled/rejected assignments carry no completedat, so they are counted // over the same window by assignedat. total_stops lets the app show a real // success rate (completed / total) instead of inferring it from completions // alone. var cancelledStops int64 db.DB.Model(&models.BookingAssignment{}). Where("mileruserid = ? AND assignmentstatus IN ? AND assignedat >= ? AND assignedat < ?", milerUserID, []string{constants.AssignmentCancelled, constants.AssignmentRejected}, start, end). Count(&cancelledStops) var breakdown []models.BookingAssignment db.DB.Where("mileruserid = ? AND assignmentstatus = ? AND completedat >= ? AND completedat < ?", milerUserID, constants.AssignmentCompleted, start, end). Order("completedat DESC").Find(&breakdown) breakdownResp := make([]fiber.Map, 0, len(breakdown)) for _, a := range breakdown { breakdownResp = append(breakdownResp, fiber.Map{ "bookingid": a.Bookingid, "riderkms": a.Riderkms, "ridercharges": a.Ridercharges, "bonuspoints": a.Bonuspoints, "completedat": a.Completedat, }) } data := fiber.Map{ "period": period, "completed_stops": summary.CompletedStops, "cancelled_stops": cancelledStops, "total_stops": summary.CompletedStops + cancelledStops, "total_kms": summary.TotalKms, "total_earnings": summary.TotalEarnings, "total_bonus": summary.TotalBonus, "breakdown": breakdownResp, } if period == "daily" { data["date"] = start.Format("2006-01-02") } return utils.OK(c, data) } // -------------------- // NOTIFICATIONS (synthetic — no dedicated table yet) // -------------------- // MilerGetNotifications generates notifications from real assignment events, // mirroring GetHubNotifications' synthetic-notification pattern. func MilerGetNotifications(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) midnight := todayMidnight() last24h := time.Now().Add(-24 * time.Hour) var notifications []notificationEntry var assigned []models.BookingAssignment db.DB.Where("mileruserid = ? AND assignedat >= ?", milerUserID, midnight).Find(&assigned) for _, a := range assigned { notifications = append(notifications, notificationEntry{ Title: fmt.Sprintf("New pickup assigned — Booking #%d", a.Bookingid), Type: "assignment", Time: a.Assignedat, }) } var completed []models.BookingAssignment db.DB.Where("mileruserid = ? AND assignmentstatus = ? AND completedat >= ?", milerUserID, constants.AssignmentCompleted, midnight).Find(&completed) for _, a := range completed { t := a.Assignedat if a.Completedat != nil { t = *a.Completedat } notifications = append(notifications, notificationEntry{ Title: fmt.Sprintf("Booking #%d completed", a.Bookingid), Type: "completed", Time: t, }) } var rejected []models.BookingAssignment db.DB.Where("mileruserid = ? AND assignmentstatus = ? AND assignedat >= ?", milerUserID, constants.AssignmentRejected, last24h).Find(&rejected) for _, a := range rejected { notifications = append(notifications, notificationEntry{ Title: fmt.Sprintf("Assignment rejected — Booking #%d", a.Bookingid), Type: "rejected", Time: a.Assignedat, }) } sort.Slice(notifications, func(i, j int) bool { return notifications[i].Time.After(notifications[j].Time) }) if len(notifications) > 20 { notifications = notifications[:20] } response := make([]fiber.Map, 0, len(notifications)) for i, n := range notifications { response = append(response, fiber.Map{ "id": i + 1, "title": n.Title, "type": n.Type, "time": humanizeRelativeTime(n.Time), "read": false, }) } return utils.List(c, response, int64(len(response))) } // MilerMarkNotificationRead is a stub until a notifications table with // read-state exists — always succeeds without persisting anything, matching // MarkNotificationRead's hub-console equivalent. func MilerMarkNotificationRead(c *fiber.Ctx) error { return c.JSON(fiber.Map{"success": true}) } // -------------------- // SUPPORT // -------------------- func MilerCreateTicket(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var req struct { Subject string `json:"subject"` Description string `json:"description"` } if err := c.BodyParser(&req); err != nil { return utils.BadRequest(c, "invalid request body") } if req.Subject == "" || req.Description == "" { return utils.BadRequest(c, "subject and description are required") } ticket := models.MilerSupportTicket{ Userid: milerUserID, Subject: req.Subject, Description: req.Description, Status: "Open", } if err := db.DB.Create(&ticket).Error; err != nil { return utils.Internal(c, "failed to create support ticket") } return utils.Created(c, fiber.Map{ "ticketid": ticket.Ticketid, "status": ticket.Status, }) } func MilerGetTickets(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) var tickets []models.MilerSupportTicket if err := db.DB.Where("userid = ?", milerUserID).Order("createdat DESC").Find(&tickets).Error; err != nil { return utils.Internal(c, "failed to fetch support tickets") } return utils.List(c, tickets, int64(len(tickets))) }